Skip to main content

magi/
land.rs

1//! Landing the winner: watch the pull request, fix what it complains about,
2//! and merge it.
3//!
4//! Opening the pull request used to be where magi stopped and the operator
5//! started: watch the checks, read what the review bots found, push a fix,
6//! wait again, merge. That loop is mechanical, it takes an hour of wall-clock
7//! time per pull request, and doing it by hand six times in one session is how
8//! a queue that drains unattended stops being unattended. So it lives here.
9//!
10//! # Shape
11//!
12//! [`PrState`] is one observation of a pull request and [`decide`] is the whole
13//! policy as a *pure* function of it. Nothing in [`decide`] talks to `gh`,
14//! which is what makes "green with an unresolved comment is a fix, not a merge"
15//! an assertion in a test rather than a claim in a comment. [`land`] is the
16//! only part that performs I/O: observe, decide, act, repeat.
17//!
18//! # What it refuses to do
19//!
20//! Merging is the one irreversible thing magi can do to a repository, so the
21//! loop is built to stop rather than to guess:
22//!
23//! * A red pull request is never merged. When the budget runs out the pull
24//!   request is left open with a comment naming what is still failing, because
25//!   a magi that force-merges a red pull request is worse than one that stops.
26//! * A pull request whose checks cannot be read at all (`gh` reported no
27//!   rollup) is not merged either. Landing is for repositories with CI; with no
28//!   signal there is nothing to be green.
29//! * A pull request a human merged or closed underneath us is
30//!   [`Step::Done`] - the person won, and their decision is not an error.
31//!
32//! # Why `--subject` is not optional
33//!
34//! A candidate branch holds one commit whose subject is
35//! `magi: candidate A (uncommitted work)`, and `gh pr merge --squash` prefers a
36//! single commit's message over the pull request title. Merging without
37//! [`merge_argv`]'s explicit `--subject` therefore writes a `main` history that
38//! says nothing about what landed. `AGENTS.md` records the trap; this module is
39//! where it is prevented.
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::MergeMode;
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{
58    ContestedHandoff, LandApproval, LandReruns, MergeOutcome, RunState, RunStatus, tail,
59};
60
61/// How often the pull request is re-read while its checks are still running.
62///
63/// Thirty seconds: a CI matrix takes minutes, so anything shorter is spent
64/// entirely on `gh` invocations, and anything much longer adds latency to every
65/// single round of a loop that already waits for agents.
66pub const POLL: Duration = Duration::from_secs(30);
67
68/// How long one wait may last before landing gives up on the checks finishing.
69///
70/// A workflow that has not settled in forty-five minutes is stuck on a runner
71/// queue, a missing approval, or a hung job - none of which more polling fixes,
72/// and all of which a person needs to see.
73pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
74
75/// How long the checks may stay unreadable before landing gives up on them.
76///
77/// GitHub registers a workflow run some seconds after the branch is pushed, so
78/// immediately after a pull request is opened "no checks" and "no CI in this
79/// repository" look identical. Measured on run 01c2: magi opened pull request
80/// 22, read `unknown` four seconds later, refused to merge on a guess and
81/// marked the run blocked - and every check on that pull request was green
82/// minutes afterwards, with the whole competition then re-run from scratch for
83/// a task that was already finished. Three minutes is well past the observed
84/// registration delay and still bounded, so a repository that genuinely has no
85/// checks costs one three-minute wait and then says so.
86pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
87
88/// Bytes of failing log kept per check. The fixer needs the assertion and the
89/// frame around it, not the forty thousand lines of `cargo` output above it.
90const LOG_TAIL: usize = 4_000;
91
92/// Failing checks whose logs are fetched. Beyond a handful the failures share a
93/// cause, and fetching each one costs a `gh` round trip.
94const MAX_LOGS: usize = 3;
95
96/// Marker carried by every comment magi posts on a pull request.
97///
98/// Without it magi's own "still failing" comment is indistinguishable from a
99/// reviewer's, and the next observation would hand magi's own prose to the
100/// fixer as a finding.
101pub const MARKER: &str = "<!-- magi:land -->";
102
103/// Markers a bot puts in a comment to say that the comment is not a review.
104///
105/// CodeRabbit labels its own machinery in HTML comments - the trigger notice,
106/// the walkthrough summary, the "thanks for using" footer - and its actual
107/// findings arrive as *inline* review comments with a path and a line. Taking
108/// the bot at its word is more honest than guessing from prose, and it is the
109/// difference between a fix round that has something to fix and one that asks
110/// an agent to act on a quota notice.
111const NOT_A_REVIEW: [&str; 3] = [
112    "skip review by coderabbit.ai",
113    "summarize by coderabbit.ai",
114    "<!-- tips_start -->",
115];
116
117/// Where a pull request is in its life.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrLifecycle {
120    /// Still ours to land.
121    Open,
122    /// Already merged, by us or by a person.
123    Merged,
124    /// Closed without merging.
125    Closed,
126}
127
128/// The aggregate verdict of a pull request's checks.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Checks {
131    /// At least one check has not finished.
132    Pending,
133    /// Every check passed (a skipped check counts as passed: the review
134    /// workflow skips release and bot pull requests by design).
135    Green,
136    /// At least one check finished without passing.
137    Red,
138    /// Nothing readable - no rollup at all, or a status magi does not know.
139    Unknown,
140}
141
142impl PrLifecycle {
143    /// Stable lower-case name, as the API and the reports spell it.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::Open => "open",
147            Self::Merged => "merged",
148            Self::Closed => "closed",
149        }
150    }
151}
152
153impl Checks {
154    /// Stable lower-case name, as the API and the reports spell it.
155    pub fn as_str(self) -> &'static str {
156        match self {
157            Self::Pending => "pending",
158            Self::Green => "green",
159            Self::Red => "red",
160            Self::Unknown => "unknown",
161        }
162    }
163}
164
165/// One outstanding review comment, human or bot.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ReviewComment {
168    /// Login of whoever wrote it.
169    pub author: String,
170    /// File it was left on, for inline review comments.
171    pub path: Option<String>,
172    /// Line it was left on, when the comment is inline and still anchored.
173    pub line: Option<u64>,
174    /// The comment itself, as written.
175    pub body: String,
176}
177
178/// One observation of a pull request.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct PrState {
181    /// Pull request url, as `gh` reports it.
182    pub url: String,
183    /// Pull request number.
184    pub number: u64,
185    /// Open, merged, or closed.
186    pub state: PrLifecycle,
187    /// Aggregate check verdict.
188    pub checks: Checks,
189    /// Names of the checks that finished without passing.
190    pub failing: Vec<String>,
191    /// Comments that still want an answer, human and bot.
192    pub review_comments: Vec<ReviewComment>,
193    /// Whether the forge itself considers the failures blocking.
194    pub blocking: Blocking,
195}
196
197/// Whether a failing check actually stands between the pull request and
198/// `main`, according to the forge.
199///
200/// The rollup lists every check equally, so `coverage` going red on a
201/// repository that deliberately does not require it looked exactly like a
202/// broken build - and magi answered by spending a fix round on a change that
203/// was fine. Pull request 37 had to be merged by hand for that reason: the
204/// only red check was `editorconfig`, which was failing because the *action*
205/// could not fetch its own binary, and which the repository does not require.
206///
207/// `mergeStateStatus` is where GitHub applies the required-check set, so it
208/// is the one field that can tell the difference.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Blocking {
211    /// Required checks are satisfied and the branch merges cleanly.
212    No,
213    /// Something required is failing or missing.
214    Yes,
215    /// The branch no longer merges: the base moved under it.
216    Conflict,
217    /// The forge did not say - an older `gh`, or a token without the scope.
218    /// Treated as `Yes`, because refusing to guess is the rule everywhere
219    /// else in this module.
220    Unsaid,
221}
222
223impl Blocking {
224    /// Read `mergeStateStatus`, which is upper-case in `gh`'s output.
225    fn of(raw: &str) -> Self {
226        match raw.to_ascii_uppercase().as_str() {
227            // Mergeable. `UNSTABLE` is the interesting one: mergeable, with a
228            // non-required check failing or still running.
229            "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
230            "DIRTY" => Self::Conflict,
231            "" | "UNKNOWN" => Self::Unsaid,
232            // BLOCKED, BEHIND, DRAFT: something has to change first.
233            _ => Self::Yes,
234        }
235    }
236
237    /// Does this stand between the pull request and the base branch?
238    #[must_use]
239    pub fn stops_a_merge(self) -> bool {
240        !matches!(self, Self::No)
241    }
242}
243
244/// What the loop decided to do next. Pure, so the policy is testable.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Step {
247    /// Checks are still running; re-read the pull request after [`POLL`].
248    Wait,
249    /// The base moved and the branch no longer merges: rebase it.
250    ///
251    /// Not a fix round. Nothing is wrong with the change - a competition
252    /// that runs for two hours against a repository merging pull requests
253    /// all day conflicts on the way in, and that is arithmetic rather than a
254    /// defect. Pull requests 35 and 37 were both rebased by hand for exactly
255    /// this.
256    Rebase,
257    /// Red checks or unresolved comments; run a fix round.
258    Fix {
259        /// What is unhappy, in one line, for the run log and the fix prompt.
260        reason: String,
261    },
262    /// Green and nothing outstanding; merge it.
263    Merge,
264    /// The pull request left our hands.
265    Done {
266        /// Did it land, or was it closed?
267        merged: bool,
268    },
269    /// Stop and leave the pull request to a person.
270    GiveUp {
271        /// Why magi stopped, in one line.
272        reason: String,
273    },
274}
275
276/// The outcome to record when `gh pr merge` exits non-zero, given what the
277/// pull request looked like immediately afterwards.
278///
279/// `gh pr merge` merges server-side first and only then does local work -
280/// deleting the branch, switching back to a base branch - so a non-zero exit
281/// does not mean the merge did not happen. In a jj-colocated repository it
282/// reliably does not mean that: git HEAD is detached, and `--delete-branch`
283/// ends with "could not determine current branch: not on any branch" *after*
284/// the merge has landed. Run ec12 merged pull request 28 into `main` and
285/// recorded `ok: false`, and its task was held waiting for a merge that was
286/// already done.
287///
288/// So the forge is asked, and its answer wins - the same authority [`decide`]
289/// gives the pull request's own state over everything else. The recorded
290/// detail carries both facts, because "the command failed and the merge
291/// happened anyway" is exactly what someone reading the run later needs to
292/// know.
293///
294/// `None` means the merge really did not happen, including when the pull
295/// request could not be read at all: an unreadable answer is not evidence of
296/// success.
297pub(crate) fn merged_after_all(
298    argv: &[String],
299    stderr: &str,
300    after: Option<PrLifecycle>,
301) -> Option<MergeOutcome> {
302    if after? != PrLifecycle::Merged {
303        return None;
304    }
305    Some(MergeOutcome {
306        mode: MergeMode::Pr,
307        ok: true,
308        detail: format!(
309            "gh {} (the command reported `{}`, but the pull request is merged)",
310            argv.join(" "),
311            stderr.trim()
312        ),
313        empty: false,
314    })
315}
316
317/// Decide the next step. No I/O.
318///
319/// `round` counts the fix rounds already spent, so `round == budget` means the
320/// budget is gone. A wait never spends a round: waiting is free, and a slow CI
321/// must not consume the allowance meant for actual fixes.
322///
323/// The order of the tests is the policy:
324///
325/// 1. **The pull request's own state wins.** A merge or a close that happened
326///    underneath us is the end of the story regardless of what the checks say.
327/// 2. **Pending beats red.** A check that is still running may yet fail, and one
328///    fix round that addresses every failure is cheaper than two that each
329///    address half - the fix pushes and restarts the whole suite anyway.
330/// 3. **Comments outrank green.** An unresolved comment holds the merge even
331///    when CI is happy; that is what a review is for.
332/// 4. **Unreadable is not absent.** Checks that cannot be read yet are waited
333///    on for [`CHECKS_GRACE`], because a pull request opened a moment ago has
334///    not been given its workflow runs yet. Past the grace they are treated as
335///    genuinely missing and magi stops rather than merge on a guess.
336pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
337    decide_with(pr, round, budget, waited, CiExpectation::Expected)
338}
339
340/// Whether the repository's CI is expected to report on this pull request.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum CiExpectation {
343    /// Checks will come: wait for them, judge them (the default, and what
344    /// [`decide`] always uses).
345    Expected,
346    /// The repository explicitly disables CI expectations with `[merge] ci = "none"`.
347    /// Ignore all check statuses while retaining conflicts and review comments.
348    Absent,
349}
350
351/// [`decide`] with the CI expectation made explicit. `Absent` ignores the
352/// rollup, but still requires conflicts and review comments to be resolved.
353pub fn decide_with(
354    pr: &PrState,
355    round: usize,
356    budget: usize,
357    waited: Duration,
358    ci: CiExpectation,
359) -> Step {
360    match pr.state {
361        PrLifecycle::Merged => return Step::Done { merged: true },
362        PrLifecycle::Closed => return Step::Done { merged: false },
363        PrLifecycle::Open => {}
364    }
365
366    // Before the checks: every check on a branch that cannot land is an
367    // answer about a state that cannot land.
368    if pr.blocking == Blocking::Conflict {
369        return Step::Rebase;
370    }
371
372    let spent = round >= budget;
373    let checks = match ci {
374        CiExpectation::Expected => pr.checks,
375        CiExpectation::Absent => Checks::Green,
376    };
377    match checks {
378        Checks::Pending => Step::Wait,
379        Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
380        Checks::Unknown => Step::GiveUp {
381            reason: format!(
382                "no check status is readable on the pull request after {} minute(s); \
383                 refusing to merge on a guess",
384                CHECKS_GRACE.as_secs() / 60
385            ),
386        },
387        // Red, but the forge says it does not stand in the way: the failing
388        // checks are ones this repository chose not to require. Spending a fix
389        // round on them asks an agent to repair something nobody is gating on
390        // - and pull request 37's only red check was an *action* that could
391        // not fetch its own binary. Merge, and name them so the record is
392        // honest about what was red when it landed.
393        Checks::Red if pr.blocking == Blocking::Unsaid && waited < CHECKS_GRACE => Step::Wait,
394        Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
395        Checks::Red => {
396            let what = format!(
397                "{} check(s) failing: {}",
398                pr.failing.len(),
399                pr.failing.join(", ")
400            );
401            if spent {
402                Step::GiveUp {
403                    reason: format!("{what} — still red after {budget} fix round(s)"),
404                }
405            } else {
406                Step::Fix { reason: what }
407            }
408        }
409        Checks::Green if pr.review_comments.is_empty() => Step::Merge,
410        Checks::Green => {
411            let what = format!(
412                "{} but {} review comment(s) are unresolved: {}",
413                if ci == CiExpectation::Absent {
414                    "CI is not expected"
415                } else {
416                    "checks are green"
417                },
418                pr.review_comments.len(),
419                authors(&pr.review_comments)
420            );
421            if spent {
422                Step::GiveUp {
423                    reason: format!("{what} — still unresolved after {budget} fix round(s)"),
424                }
425            } else {
426                Step::Fix { reason: what }
427            }
428        }
429    }
430}
431
432/// Distinct comment authors, in the order they first appear.
433fn authors(comments: &[ReviewComment]) -> String {
434    let mut seen: Vec<&str> = Vec::new();
435    for c in comments {
436        if !seen.contains(&c.author.as_str()) {
437            seen.push(&c.author);
438        }
439    }
440    seen.join(", ")
441}
442
443/// The argv magi merges with, minus the program name.
444///
445/// `--subject` is the point of this function existing: see the module docs.
446pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
447    vec![
448        "pr".to_owned(),
449        "merge".to_owned(),
450        number.to_string(),
451        "--squash".to_owned(),
452        "--delete-branch".to_owned(),
453        "--subject".to_owned(),
454        subject.to_owned(),
455    ]
456}
457
458/// [`merge_argv`] pinned to the commit the decision was made about.
459///
460/// `--match-head-commit` makes the forge refuse the merge if the branch moved
461/// after it was observed, so a push landing between the look and the merge is
462/// never merged unseen.
463pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
464    let mut argv = merge_argv(number, subject);
465    argv.push("--match-head-commit".to_owned());
466    argv.push(head.to_owned());
467    argv
468}
469
470/// Take auto-merge back. magi no longer arms auto-merge, but a run recorded by
471/// an older build may still have one standing on the forge, so the disarm
472/// paths (and `RunState::land_armed_head`) stay. Run before anything that changes the head, so an
473/// armed merge never outlives the commit that was approved for it.
474pub fn disable_automerge_argv(number: u64) -> Vec<String> {
475    ["pr", "merge", &number.to_string(), "--disable-auto"]
476        .map(str::to_owned)
477        .to_vec()
478}
479
480/// May the direct merge go ahead, judged from a fresh read?
481///
482/// The pull request must still be open on the approved head, the checks must
483/// have been read for that very head, and the policy must still say merge.
484/// Pure, so the head guard is asserted without a forge.
485fn direct_merge_is_safe(
486    fresh: Option<&Seen>,
487    approved_head: &str,
488    shown: &BTreeSet<String>,
489    round: usize,
490    budget: usize,
491    waited: Duration,
492    ci: CiExpectation,
493) -> bool {
494    let Some(fresh) = fresh else {
495        return false;
496    };
497    if fresh.pr.state != PrLifecycle::Open {
498        return false;
499    }
500    let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
501        return false;
502    };
503    if !bound.eq_ignore_ascii_case(approved_head) {
504        return false;
505    }
506    let mut pr = fresh.pr.clone();
507    pr.review_comments.retain(|c| !shown.contains(&c.body));
508    decide_with(&pr, round, budget, waited, ci) == Step::Merge
509}
510
511/// What GitHub is still waiting for, for the stop reason when an armed merge
512/// does not happen in time. No I/O.
513///
514/// Required checks that are pending or failing are named. A check whose
515/// required-ness could not be read is never assumed optional: every unsettled
516/// check is listed and the reason says so.
517fn waiting_on(
518    merge_state: &str,
519    contexts: &[CheckInfo],
520    required_set: Option<&BTreeSet<String>>,
521) -> String {
522    let state = if merge_state.is_empty() {
523        "unknown"
524    } else {
525        merge_state
526    };
527    let tag = |c: &CheckInfo| match c.verdict {
528        Verdict::Fail => "failed",
529        _ => "pending",
530    };
531    let unsettled: Vec<&CheckInfo> = contexts
532        .iter()
533        .filter(|c| c.verdict != Verdict::Pass)
534        .collect();
535    let required: Vec<String> = unsettled
536        .iter()
537        .filter(|c| c.required == Some(true))
538        .map(|c| format!("{} ({})", c.label, tag(c)))
539        .collect();
540    let unknown: Vec<String> = unsettled
541        .iter()
542        .filter(|c| c.required.is_none())
543        .map(|c| format!("{} ({})", c.label, tag(c)))
544        .collect();
545    // Required contexts the rollup never listed: nothing reported them, so no
546    // pending/failing entry exists to name. Matched case-insensitively against
547    // the labels as the rollup spells them, and no further.
548    let never: Vec<&str> = required_set
549        .map(|set| {
550            set.iter()
551                .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
552                .map(String::as_str)
553                .collect()
554        })
555        .unwrap_or_default();
556    let mut out = format!("merge state: {state}");
557    if !never.is_empty() {
558        let _ = write!(
559            out,
560            "; required checks never reported: {}",
561            never.join(", ")
562        );
563    }
564    if !required.is_empty() {
565        let _ = write!(
566            out,
567            "; required checks not passing: {}",
568            required.join(", ")
569        );
570    }
571    if !unknown.is_empty() {
572        let _ = write!(
573            out,
574            "; whether these are required could not be read, so they may be: {}",
575            unknown.join(", ")
576        );
577    }
578    if required.is_empty() && unknown.is_empty() && never.is_empty() {
579        if required_set.is_some() {
580            out.push_str(
581                "; no required check is pending, failing or unreported, so GitHub is probably \
582                 waiting for a review or another branch rule",
583            );
584        } else {
585            out.push_str(
586                "; the required check list could not be read, so a required check that was \
587                 never reported cannot be ruled out",
588            );
589        }
590    }
591    out
592}
593
594/// The squash subject to merge under.
595///
596/// The pull request title, unless it is empty or is a candidate branch's commit
597/// subject that leaked into the title - in which case the task's own first line
598/// is used, because `magi: candidate A (uncommitted work)` in `main` tells a
599/// reader nothing about what landed.
600pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
601    let title = pr_title.trim();
602    if !title.is_empty() && !title.starts_with("magi: candidate") {
603        return title.to_owned();
604    }
605    let first = instruction
606        .lines()
607        .map(str::trim)
608        .find(|l| !l.is_empty())
609        .unwrap_or("magi: land the winning candidate");
610    first.trim_start_matches(['#', ' ']).to_owned()
611}
612
613/// The choice that lets the merge happen, verbatim as the owner taps it.
614pub const APPROVE: &str = "merge";
615
616/// The choice that leaves the pull request open.
617pub const HOLD: &str = "hold";
618
619/// A bounded repair followed by a new review and a fresh owner decision.
620pub const REPAIR: &str = "fix findings and re-review";
621
622/// Whether review evidence offers a meaningful repair choice. This does not
623/// decide whether owner approval is required.
624pub fn repair_available(state: &RunState) -> bool {
625    state.reviews.last().is_some_and(|r| {
626        state.open_findings().iter().any(|f| f.severity.blocks())
627            || r.final_votes()
628                .iter()
629                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
630    })
631}
632
633/// All applicable reasons for consulting the owner, independent of repair choices.
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub enum ApprovalReason {
636    /// Universal owner approval is enabled for every merge.
637    EveryMerge,
638    /// Contested-merge approval is enabled and blocking findings remain open.
639    OpenBlockingFindings,
640    /// Contested-merge approval is enabled and a final reviewer vote rejects.
641    FinalReject,
642    /// An existing owner decision must be honored even if controls change.
643    ExistingDecision,
644    /// Merge repair requires a fresh owner decision, even on the same head.
645    AfterRepair,
646}
647
648/// Disabling fresh approval controls never bypasses an existing owner decision
649/// or the fresh decision owed after repair, even if repair kept the same head.
650pub fn approval_reasons(state: &RunState) -> Vec<ApprovalReason> {
651    let mut reasons = Vec::new();
652    if state.owner_hold(crate::config::OwnerHold::LandApproval) {
653        reasons.push(ApprovalReason::EveryMerge);
654    }
655    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
656        if state.open_findings().iter().any(|f| f.severity.blocks()) {
657            reasons.push(ApprovalReason::OpenBlockingFindings);
658        }
659        if state.reviews.last().is_some_and(|r| {
660            r.final_votes()
661                .iter()
662                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
663        }) {
664            reasons.push(ApprovalReason::FinalReject);
665        }
666    }
667    if state.land_approval.is_some() {
668        reasons.push(ApprovalReason::ExistingDecision);
669    }
670    if !state.merge_repairs.is_empty() {
671        reasons.push(ApprovalReason::AfterRepair);
672    }
673    reasons
674}
675
676/// Bound free-form approval evidence by characters, including an explicit
677/// ellipsis when abbreviated. Keep UTF-8 text intact in questions and panels.
678fn approval_excerpt(text: &str, max_chars: usize) -> String {
679    if text.chars().count() <= max_chars {
680        text.to_owned()
681    } else {
682        let mut excerpt: String = text.chars().take(max_chars.saturating_sub(1)).collect();
683        excerpt.push('…');
684        excerpt
685    }
686}
687
688/// Persisted verbatim in the question and panel at creation. Deputies and
689/// resumed questions use that snapshot, never reconstruct past policy from
690/// today's settings. Fixer claims and successful gates are not resolutions.
691fn approval_evidence(state: &RunState) -> String {
692    const TITLE_CHARS: usize = 100;
693    const REASON_CHARS: usize = 300;
694    let reason = |text: &str| approval_excerpt(text, REASON_CHARS);
695    let ja = words(&state.config.graph.language).html_lang == "ja";
696    let mut s = if ja {
697        "承認が必要な理由:\n"
698    } else {
699        "Approval required because:\n"
700    }
701    .to_owned();
702    for reason in approval_reasons(state) {
703        let label = match (reason, ja) {
704            (ApprovalReason::EveryMerge, false) => {
705                "land_approval=true: owner approval for every merge"
706            }
707            (ApprovalReason::EveryMerge, true) => {
708                "land_approval=true: すべてのマージに所有者の承認が必要"
709            }
710            (ApprovalReason::OpenBlockingFindings, false) => {
711                "hold_contested_merge=true: open Major-or-above internal findings"
712            }
713            (ApprovalReason::OpenBlockingFindings, true) => {
714                "hold_contested_merge=true: Major 以上の内部指摘が未解決"
715            }
716            (ApprovalReason::FinalReject, false) => {
717                "hold_contested_merge=true: final reviewer reject after reconsideration (even without Major findings)"
718            }
719            (ApprovalReason::FinalReject, true) => {
720                "hold_contested_merge=true: 再考後の最終票が reject(Major 指摘がなくても承認が必要)"
721            }
722            (ApprovalReason::ExistingDecision, false) => {
723                "existing owner decision: changing controls cannot bypass it"
724            }
725            (ApprovalReason::ExistingDecision, true) => {
726                "既存の所有者判断: 設定変更では迂回できません"
727            }
728            (ApprovalReason::AfterRepair, false) => {
729                "fresh owner approval after merge repair, even with an unchanged head"
730            }
731            (ApprovalReason::AfterRepair, true) => {
732                "マージ修復後の新しい所有者承認(head に変更がなくても必要)"
733            }
734        };
735        let control = match reason {
736            ApprovalReason::EveryMerge => Some(crate::config::OwnerHold::LandApproval),
737            ApprovalReason::OpenBlockingFindings | ApprovalReason::FinalReject => {
738                Some(crate::config::OwnerHold::HoldContestedMerge)
739            }
740            _ => None,
741        };
742        let source = control
743            .filter(|c| !c.base_value(&state.config.graph))
744            .and_then(|c| state.branch_owner_holds.iter().find(|h| h.control == c));
745        if let Some(source) = source {
746            if ja {
747                let _ = writeln!(
748                    s,
749                    "- {}=true(レビュー対象ブランチの {} 由来, {})",
750                    source.control.key(),
751                    source.file,
752                    source.head
753                );
754            } else {
755                let _ = writeln!(
756                    s,
757                    "- {}=true from the reviewed branch's {} ({})",
758                    source.control.key(),
759                    source.file,
760                    source.head
761                );
762            }
763        }
764        let _ = writeln!(s, "- {label}");
765    }
766    s.push_str(if ja {
767        "\n再考後の各レビュアーの最終票:\n"
768    } else {
769        "\nFinal reviewer votes after reconsideration:\n"
770    });
771    if let Some(round) = state.reviews.last() {
772        let votes = round.final_votes();
773        for r in &round.reviews {
774            let vote = votes
775                .iter()
776                .find(|(seat, _, _)| *seat == r.reviewer)
777                .map(|(_, _, v)| v.label())
778                .unwrap_or(if ja { "記録なし" } else { "not recorded" });
779            let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
780            let rv = round
781                .reconsideration
782                .iter()
783                .find(|rv| rv.reviewer == r.reviewer);
784            if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
785                let initial = r.vote.map(|v| v.label()).unwrap_or("?");
786                let _ = writeln!(
787                    s,
788                    "  {}: {initial} → {vote}",
789                    if ja { "再考" } else { "reconsideration" }
790                );
791                let _ = writeln!(
792                    s,
793                    "  {}: {}",
794                    if ja { "理由" } else { "reason" },
795                    reason(if rv.reason.trim().is_empty() {
796                        if ja { "記録なし" } else { "not recorded" }
797                    } else {
798                        &rv.reason
799                    })
800                );
801            } else {
802                let _ = writeln!(
803                    s,
804                    "  {}: {}",
805                    if ja {
806                        "初回票を保持・理由"
807                    } else {
808                        "initial vote retained; reason"
809                    },
810                    reason(if r.summary.trim().is_empty() {
811                        if ja { "記録なし" } else { "not recorded" }
812                    } else {
813                        &r.summary
814                    })
815                );
816                if let Some(rv) = rv {
817                    let _ = writeln!(
818                        s,
819                        "  {}: {}",
820                        if ja {
821                            "再投票の回答なし"
822                        } else {
823                            "no reconsideration vote"
824                        },
825                        reason(rv.failed.as_deref().unwrap_or(if ja {
826                            "理由の記録なし"
827                        } else {
828                            "reason not recorded"
829                        }))
830                    );
831                }
832            }
833            if let Some(failed) = &r.failed {
834                let _ = writeln!(
835                    s,
836                    "  {}: {}",
837                    if ja { "回答なし" } else { "no answer" },
838                    reason(failed)
839                );
840            }
841        }
842    } else {
843        s.push_str(if ja {
844            "レビュー記録なし\n"
845        } else {
846            "No review recorded\n"
847        });
848    }
849    s.push_str(if ja { "\n内部レビュー指摘(最終ラウンド): 採用申告や gate 成功だけでは個別指摘の解決確認にはなりません。\n" } else { "\nInternal review findings (last round): adoption claims or a passing gate alone do not confirm individual resolution.\n" });
850    let findings = state.last_round_findings();
851    for f in &findings {
852        let _ = writeln!(
853            s,
854            "[{}] {} {}:{}: {}\n{}",
855            f.id,
856            f.severity,
857            f.file.as_deref().unwrap_or("?"),
858            f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
859            approval_excerpt(&f.title, TITLE_CHARS),
860            reason(&f.detail)
861        );
862        let mut evidence = false;
863        for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
864            state
865                .operator_fixes
866                .iter()
867                .filter_map(|request| request.fix.as_ref()),
868        ) {
869            if fix.addressed.contains(&f.id) {
870                evidence = true;
871                let _ = writeln!(
872                    s,
873                    "  {} ({}): {}",
874                    if ja {
875                        "採用申告(解決未確認)"
876                    } else {
877                        "claimed addressed (resolution unconfirmed)"
878                    },
879                    fix.agent,
880                    reason(&fix.notes)
881                );
882            }
883            for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
884                evidence = true;
885                let _ = writeln!(
886                    s,
887                    "  {}: {}",
888                    if ja { "不採用理由" } else { "declined" },
889                    reason(&rejection.why)
890                );
891            }
892        }
893        if !evidence {
894            s.push_str(if ja {
895                "  採用・解決確認の記録なし\n"
896            } else {
897                "  No adoption or resolution evidence recorded\n"
898            });
899        }
900    }
901    if findings.is_empty() {
902        s.push_str(if ja {
903            "最終ラウンドに指摘の記録なし\n"
904        } else {
905            "No findings recorded in the last round\n"
906        });
907    }
908    s
909}
910
911/// Shared choices for web, CLI and consultation questions.
912pub fn approval_choices(state: &RunState) -> Vec<String> {
913    let mut choices = vec![
914        APPROVE.to_owned(),
915        HOLD.to_owned(),
916        REPAIR.to_owned(),
917        crate::merge_decision::WITHDRAW.to_owned(),
918        crate::merge_decision::DISMISS.to_owned(),
919    ];
920    choices.extend(
921        state
922            .config
923            .agents
924            .iter()
925            .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
926    );
927    choices
928}
929
930/// Extend unanswered legacy cards in place. Answered cards keep their choices;
931/// a legacy repair answer gains only its missing dispatch action.
932pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
933    if q.node != APPROVAL_NODE
934        || !state
935            .land_approval
936            .as_ref()
937            .is_some_and(|a| a.question == q.id)
938    {
939        return false;
940    }
941    let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
942    if q.status.open() {
943        // Older cards attached Resume to hold; a hold must remain answerable
944        // without dispatching a question action.
945        q.actions.remove(HOLD);
946        for choice in approval_choices(state) {
947            if !q.choices.contains(&choice) {
948                q.choices.push(choice);
949            }
950        }
951        if q.merge_target.is_none() {
952            q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
953                pr: pr.url.clone(),
954                head: state.land_approval.as_ref().unwrap().head.clone(),
955                language: state.config.graph.language.clone(),
956            });
957        }
958    }
959    for choice in q.choices.clone() {
960        if (q.status.open()
961            && crate::merge_decision::stops(&choice)
962            && choice != HOLD
963            && choice != crate::merge_decision::DISMISS)
964            || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
965        {
966            q.actions.insert(
967                choice,
968                ask::ChoiceAction::Resume {
969                    run: state.id.clone(),
970                },
971            );
972        }
973    }
974    before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
975}
976
977/// Graph node recorded on the approval question.
978///
979/// The phone keys its high-stakes card off this rather than off the choice
980/// strings, so renaming a button cannot silently downgrade the card that
981/// guards the one irreversible action magi takes.
982pub const APPROVAL_NODE: &str = "land-approval";
983
984/// Unified diff lines carried in the panel before it is truncated.
985///
986/// Four hundred: the panel is read on a 390px phone, where a diff line often
987/// wraps to two rows, so this is already a few thousand rows of scrolling -
988/// past that nobody is reading, and the bytes still count against the panel's
989/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
990/// cut and which worktree holds the whole patch.
991pub const DIFF_MAX_LINES: usize = 400;
992
993/// What the owner's answer to the approval question means.
994#[derive(Debug, Clone, Copy, PartialEq, Eq)]
995pub enum Approval {
996    /// The owner said [`APPROVE`]. Merge.
997    Merge,
998    /// Repair the existing PR, then ask again; never approval to merge.
999    Repair,
1000    /// Anything else, including silence. Leave the pull request open.
1001    Hold,
1002}
1003
1004/// Read the owner's answer, where `None` is an unanswered question.
1005///
1006/// Silence is a hold. A timed-out question means the owner never saw it or
1007/// never decided, and defaulting an irreversible merge to "yes" would make this
1008/// gate worse than no gate at all: it would merge unattended while claiming to
1009/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
1010/// function does not recognise holds too.
1011pub fn approval(answer: Option<&str>) -> Approval {
1012    match answer {
1013        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1014        Some(a) if a.trim() == REPAIR => Approval::Repair,
1015        _ => Approval::Hold,
1016    }
1017}
1018
1019/// What [`approval_gate`] found on one check of the owner's merge decision.
1020#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1021enum ApprovalGate {
1022    /// The owner said [`APPROVE`]. Merge.
1023    Approved,
1024    /// The owner explicitly requested repair, without authorizing a merge.
1025    Repair,
1026    /// The owner said anything else, the question timed out, or it was
1027    /// closed with no decision recorded.
1028    Held,
1029    /// Filed and still waiting - the caller parks rather than blocking on it.
1030    Pending,
1031}
1032
1033/// Escape text for HTML, including both quote characters.
1034///
1035/// Every string in the panel is agent-influenced: a branch name, a file path, a
1036/// commit subject, a review comment. The sandboxed frame stops such text from
1037/// *running*, but it does not stop a `<` from ending the document early or a
1038/// `"` from ending an attribute and inventing a new one - the panel would then
1039/// render a lie, or not render at all. Both quotes are escaped because the same
1040/// function is used inside attributes, where remembering which quote style the
1041/// caller used is one mistake away from an injected attribute.
1042fn esc(s: &str) -> String {
1043    let mut out = String::with_capacity(s.len());
1044    for c in s.chars() {
1045        match c {
1046            '&' => out.push_str("&amp;"),
1047            '<' => out.push_str("&lt;"),
1048            '>' => out.push_str("&gt;"),
1049            '"' => out.push_str("&quot;"),
1050            '\'' => out.push_str("&#39;"),
1051            _ => out.push(c),
1052        }
1053    }
1054    out
1055}
1056
1057/// One row of the diffstat table.
1058#[derive(Debug, Clone, PartialEq, Eq)]
1059struct StatRow {
1060    path: String,
1061    /// `None` for a binary file, which `git` reports as `-`.
1062    added: Option<u64>,
1063    removed: Option<u64>,
1064}
1065
1066impl StatRow {
1067    /// Lines touched, for sorting. A binary file counts as zero rather than as
1068    /// unknown, which puts it at the bottom where it needs no attention.
1069    fn churn(&self) -> u64 {
1070        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1071    }
1072}
1073
1074/// Parse `git diff --numstat` into rows, biggest churn first.
1075///
1076/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
1077/// terminal width, so counting its characters would print fabricated numbers in
1078/// the one table an operator approves an irreversible action from.
1079fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1080    let mut rows: Vec<StatRow> = numstat
1081        .lines()
1082        .filter_map(|line| {
1083            let mut parts = line.splitn(3, '\t');
1084            let added = parts.next()?.trim();
1085            let removed = parts.next()?.trim();
1086            let path = parts.next()?.trim();
1087            if path.is_empty() {
1088                return None;
1089            }
1090            Some(StatRow {
1091                path: path.to_owned(),
1092                added: added.parse().ok(),
1093                removed: removed.parse().ok(),
1094            })
1095        })
1096        .collect();
1097    // Path breaks the tie so the same change always renders the same table; an
1098    // operator comparing two panels should not see rows shuffle.
1099    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1100    rows
1101}
1102
1103/// How one diff line is shown: a gutter character, a style, and the body to
1104/// print - which is the line minus its marker, so the marker appears exactly
1105/// once, in the gutter.
1106///
1107/// The gutter is why this exists at all. The operator may be colour blind, or
1108/// reading in sunlight with the screen dimmed, so an added line is never
1109/// distinguished by its background alone: `+` and `-` sit in a fixed column,
1110/// the same mark they already read in a terminal.
1111fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1112    if line.starts_with("+++") || line.starts_with("---") {
1113        (" ", "color:#57606a;font-weight:600", line)
1114    } else if let Some(body) = line.strip_prefix('+') {
1115        ("+", "background:#e6ffec;color:#0a3622", body)
1116    } else if let Some(body) = line.strip_prefix('-') {
1117        ("-", "background:#ffebe9;color:#5c1a17", body)
1118    } else if line.starts_with("@@") {
1119        ("~", "background:#eef2ff;color:#3730a3", line)
1120    } else if let Some(body) = line.strip_prefix(' ') {
1121        (" ", "", body)
1122    } else {
1123        (" ", "color:#57606a;font-weight:600", line)
1124    }
1125}
1126
1127/// The handful of words the approval panel says in its own voice.
1128///
1129/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
1130/// the operator asked why the merge question spoke English on a repository
1131/// configured for Japanese, and "because that string is a literal in Rust" is
1132/// not an answer. Only the languages magi can actually check are translated;
1133/// anything else falls back to English rather than shipping a guess, and that
1134/// fallback is deliberate.
1135struct Words {
1136    html_lang: &'static str,
1137    task: &'static str,
1138    what_changed: &'static str,
1139    review_verdict: &'static str,
1140    reviewer: &'static str,
1141    reviewer_no_answer: &'static str,
1142    checks: &'static str,
1143    nothing_failing: &'static str,
1144    files_changed: &'static str,
1145    commits: &'static str,
1146    no_commits: &'static str,
1147    comments: &'static str,
1148    no_comments: &'static str,
1149    diff: &'static str,
1150    truncated: &'static str,
1151    lands_as: &'static str,
1152}
1153
1154const EN: Words = Words {
1155    html_lang: "en",
1156    task: "Task",
1157    what_changed: "What changed",
1158    review_verdict: "Review verdict",
1159    reviewer: "Reviewer",
1160    reviewer_no_answer: "produced no answer",
1161    checks: "Checks",
1162    nothing_failing: "Nothing failing.",
1163    files_changed: "file(s) changed",
1164    commits: "Commits being squashed",
1165    no_commits: "No commit subjects could be read from the branch.",
1166    comments: "External PR review comments",
1167    no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1168    diff: "Diff",
1169    truncated: "Truncated",
1170    lands_as: "They land as one commit titled",
1171};
1172
1173const JA: Words = Words {
1174    html_lang: "ja",
1175    task: "タスク",
1176    what_changed: "変更内容",
1177    review_verdict: "レビューの結論",
1178    reviewer: "レビュアー",
1179    reviewer_no_answer: "回答なし",
1180    checks: "チェック",
1181    nothing_failing: "失敗しているものはありません。",
1182    files_changed: "ファイル変更",
1183    commits: "squash されるコミット",
1184    no_commits: "ブランチからコミット件名を読めませんでした。",
1185    comments: "外部 PR レビューコメント",
1186    no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1187    diff: "差分",
1188    truncated: "省略",
1189    lands_as: "これらは次の件名の1コミットとして入ります:",
1190};
1191
1192impl Words {
1193    /// The clause after the merge subject. Split out because word order moves:
1194    /// Japanese puts the subject before the verb, so a shared template with a
1195    /// hole in the middle would read as machine translation.
1196    fn lands_as_tail(&self) -> &'static str {
1197        if self.html_lang == "ja" {
1198            "。この件名も承認の対象です。"
1199        } else {
1200            ", which you are approving too."
1201        }
1202    }
1203
1204    /// The question's own one-line summary, which is what a phone shows first.
1205    fn approval_summary(&self, number: u64, subject: &str) -> String {
1206        if self.html_lang == "ja" {
1207            format!("プルリクエスト #{number} をマージ: {subject}")
1208        } else {
1209            format!("merge pull request #{number}: {subject}")
1210        }
1211    }
1212
1213    /// The body under the summary, above the panel.
1214    fn approval_detail(
1215        &self,
1216        url: &str,
1217        base: &str,
1218        subject: &str,
1219        contested: Option<&ContestedHandoff>,
1220    ) -> String {
1221        let body = if self.html_lang == "ja" {
1222            format!(
1223                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1224                 できる状態です。差分の要約・パッチ・squash されるコミットは\
1225                 下のパネルにあります。"
1226            )
1227        } else {
1228            format!(
1229                "{url} is green and ready to squash into `{base}` as `{subject}`. \
1230                 The panel holds the diffstat, the patch and the commits being squashed."
1231            )
1232        };
1233        match contested {
1234            Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1235            None => body,
1236        }
1237    }
1238
1239    /// Why this question exists although merge approvals are off: the open
1240    /// blocking findings and who rejected. Short enough for a phone.
1241    fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1242        const SHOWN: usize = 5;
1243        const TITLE_CHARS: usize = 100;
1244        let ja = self.html_lang == "ja";
1245        let mut out = if ja {
1246            format!(
1247                "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1248                 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1249            )
1250        } else {
1251            format!(
1252                "{url} is held for approval although merge approvals are off: the \
1253                 review ended with blocking findings still open and a reviewer \
1254                 voting reject.\n"
1255            )
1256        };
1257        for f in c.findings.iter().take(SHOWN) {
1258            let at = match (&f.file, f.line) {
1259                (Some(file), Some(line)) => format!("{file}:{line}"),
1260                (Some(file), None) => file.clone(),
1261                _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1262            };
1263            let title: String = f.title.chars().take(TITLE_CHARS).collect();
1264            let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1265        }
1266        if c.findings.len() > SHOWN {
1267            let more = c.findings.len() - SHOWN;
1268            let _ = writeln!(
1269                out,
1270                "{}",
1271                if ja {
1272                    format!("- ほか {more} 件")
1273                } else {
1274                    format!("- and {more} more")
1275                }
1276            );
1277        }
1278        let seats: Vec<String> = c
1279            .rejecters
1280            .iter()
1281            .map(|(seat, agent)| format!("#{seat} ({agent})"))
1282            .collect();
1283        let _ = write!(
1284            out,
1285            "{} {}",
1286            if ja {
1287                "却下したレビュアー:"
1288            } else {
1289                "Rejected by reviewer:"
1290            },
1291            seats.join(", ")
1292        );
1293        out
1294    }
1295
1296    /// The truncation note, written whole in each language for the same reason.
1297    fn truncated_note(
1298        &self,
1299        omitted: usize,
1300        total: usize,
1301        shown: usize,
1302        where_: &str,
1303        base: &str,
1304        head: &str,
1305    ) -> String {
1306        if self.html_lang == "ja" {
1307            format!(
1308                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1309                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1310                 プルリクエストにあります。"
1311            )
1312        } else {
1313            format!(
1314                "{omitted} of {total} diff lines omitted after the first {shown}. \
1315                 The whole patch is in <code>{where_}</code> \
1316                 (<code>git diff {base}...{head}</code>) and on the pull request."
1317            )
1318        }
1319    }
1320}
1321
1322/// Pick the panel's language. Codes and names both, because `[graph] language`
1323/// has always accepted either.
1324fn words(language: &str) -> &'static Words {
1325    if crate::lang::is_japanese(language) {
1326        &JA
1327    } else {
1328        &EN
1329    }
1330}
1331
1332/// The approval panel's html: what is about to land, and the evidence for it.
1333///
1334/// Pure, so the whole document is asserted in tests without `gh`, without a
1335/// network and without a repository. The caller gathers `diffstat`
1336/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
1337/// being squashed) and `subject` (what the squash will be called) from the
1338/// winner's worktree.
1339///
1340/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
1341/// content security policy blocks all three: anything of the sort here would be
1342/// dead markup that misleads the next reader into thinking it works.
1343pub fn approval_panel(
1344    state: &RunState,
1345    pr: &PrState,
1346    diffstat: &str,
1347    diff: &str,
1348    commits: &[String],
1349    subject: &str,
1350) -> String {
1351    let rows = parse_numstat(diffstat);
1352    let w = words(&state.config.graph.language);
1353    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1354
1355    let _ = writeln!(
1356        h,
1357        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1358         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1359        w.html_lang
1360    );
1361    let _ = writeln!(
1362        h,
1363        "<title>merge #{} — {}</title>\n</head>",
1364        pr.number,
1365        esc(subject)
1366    );
1367    h.push_str(
1368        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1369         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1370         word-break:break-word\">\n",
1371    );
1372
1373    // The decision, in the words the operator is approving.
1374    let _ = writeln!(
1375        h,
1376        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1377         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1378         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1379         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1380         <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1381        pr.number,
1382        esc(&state.base_branch),
1383        esc(subject),
1384        esc(&state.id),
1385        esc(&pr.url),
1386        esc(&pr.url),
1387    );
1388
1389    let _ = writeln!(
1390        h,
1391        "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1392        esc(&approval_evidence(state))
1393    );
1394
1395    // The task, verbatim: the operator's own words for what was asked, so the
1396    // panel does not make them reconstruct the request from a diffstat.
1397    let _ = writeln!(
1398        h,
1399        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1400         <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1401        w.task,
1402        esc(&state.instruction)
1403    );
1404
1405    // The winner's own account of what it did and why, when there is one.
1406    if let Some(summary) = state
1407        .winner()
1408        .map(|c| c.summary.as_str())
1409        .filter(|s| !s.is_empty())
1410    {
1411        let _ = writeln!(
1412            h,
1413            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1414             <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1415            w.what_changed,
1416            esc(summary)
1417        );
1418    }
1419
1420    // Only the last round's reviewer prose is current. Its votes and
1421    // remaining findings are shown explicitly in the evidence above.
1422    if let Some(round) = state.reviews.last() {
1423        let _ = writeln!(
1424            h,
1425            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1426            w.review_verdict
1427        );
1428        for r in &round.reviews {
1429            // A seat the review loop counted as answered has real prose in
1430            // `summary`; one it counted against `incomplete` (see
1431            // `graph::Runner::review_loop`) never produced any and left it
1432            // empty - which must not be read back as a blank verdict, since
1433            // an empty box here looks like "nothing to say" rather than
1434            // "never answered".
1435            let body = match &r.failed {
1436                Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1437                None => esc(&r.summary),
1438            };
1439            let _ = writeln!(
1440                h,
1441                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1442                 border-radius:6px\">\
1443                 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1444                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1445                w.reviewer,
1446                r.reviewer,
1447                esc(&r.agent),
1448                body,
1449            );
1450        }
1451    }
1452
1453    let _ = writeln!(
1454        h,
1455        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1456        w.checks,
1457        esc(pr.checks.as_str())
1458    );
1459    if pr.failing.is_empty() {
1460        let _ = writeln!(
1461            h,
1462            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1463            w.nothing_failing
1464        );
1465    } else {
1466        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1467        for f in &pr.failing {
1468            let _ = writeln!(h, "<li>{}</li>", esc(f));
1469        }
1470        h.push_str("</ul>\n");
1471    }
1472
1473    // Diffstat as a real table, so a phone reads what moved without scrolling
1474    // sideways through a terminal bar chart.
1475    let _ = writeln!(
1476        h,
1477        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1478        rows.len(),
1479        w.files_changed
1480    );
1481    h.push_str(
1482        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1483         <thead><tr>\
1484         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1485         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1486         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1487         </th></tr></thead>\n<tbody>\n",
1488    );
1489    let mut total_added = 0u64;
1490    let mut total_removed = 0u64;
1491    for r in &rows {
1492        total_added += r.added.unwrap_or(0);
1493        total_removed += r.removed.unwrap_or(0);
1494        let cell = |n: Option<u64>| match n {
1495            Some(n) => n.to_string(),
1496            None => "bin".to_owned(),
1497        };
1498        let _ = writeln!(
1499            h,
1500            "<tr>\
1501             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1502             font-family:ui-monospace,monospace\">{}</td>\
1503             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1504             color:#0a3622\">{}</td>\
1505             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1506             color:#5c1a17\">{}</td></tr>",
1507            esc(&r.path),
1508            cell(r.added),
1509            cell(r.removed),
1510        );
1511    }
1512    let _ = writeln!(
1513        h,
1514        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1515         <td style=\"padding:4px 2px\">total</td>\
1516         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1517         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1518         </tr></tfoot>\n</table>"
1519    );
1520
1521    // The commits being squashed, and the subject that replaces them.
1522    let _ = writeln!(
1523        h,
1524        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1525        w.commits
1526    );
1527    if commits.is_empty() {
1528        h.push_str(&format!(
1529            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1530            w.no_commits
1531        ));
1532    } else {
1533        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1534        for c in commits {
1535            let _ = writeln!(h, "<li>{}</li>", esc(c));
1536        }
1537        h.push_str("</ol>\n");
1538    }
1539    let _ = writeln!(
1540        h,
1541        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1542        w.lands_as,
1543        esc(subject),
1544        w.lands_as_tail()
1545    );
1546
1547    // The review comments that shaped this branch, and who asked for them.
1548    let _ = writeln!(
1549        h,
1550        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1551        w.comments
1552    );
1553    if pr.review_comments.is_empty() {
1554        h.push_str(&format!(
1555            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1556            w.no_comments
1557        ));
1558    } else {
1559        for c in &pr.review_comments {
1560            let anchor = match (&c.path, c.line) {
1561                (Some(p), Some(l)) => format!("{p}:{l}"),
1562                (Some(p), None) => p.clone(),
1563                _ => "pull request thread".to_owned(),
1564            };
1565            let _ = writeln!(
1566                h,
1567                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1568                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1569                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1570                esc(&c.author),
1571                esc(&anchor),
1572                esc(&tail(&c.body, 800)),
1573            );
1574        }
1575    }
1576
1577    // The patch itself.
1578    let total = diff.lines().count();
1579    let shown = total.min(DIFF_MAX_LINES);
1580    let _ = writeln!(
1581        h,
1582        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1583        w.diff
1584    );
1585    h.push_str(
1586        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1587         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1588    );
1589    for line in diff.lines().take(shown) {
1590        let (gutter, style, body) = diff_row(line);
1591        let _ = writeln!(
1592            h,
1593            "<div style=\"display:flex;{style}\">\
1594             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1595             border-right:1px solid #d0d7de\">{gutter}</span>\
1596             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1597            esc(body),
1598        );
1599    }
1600    h.push_str("</div>\n");
1601    if total > shown {
1602        let omitted = total - shown;
1603        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1604        let where_ = state.winner().map_or_else(
1605            || state.repo.display().to_string(),
1606            |w| w.worktree.display().to_string(),
1607        );
1608        let _ = writeln!(
1609            h,
1610            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1611             font-size:13px\">{}: {}</p>",
1612            w.truncated,
1613            w.truncated_note(
1614                omitted,
1615                total,
1616                shown,
1617                &esc(&where_),
1618                &esc(&state.base_branch),
1619                &esc(head),
1620            ),
1621        );
1622    }
1623
1624    h.push_str("</body>\n</html>\n");
1625    h
1626}
1627
1628/// Historical contested hand-off evidence for the question detail. Approval
1629/// itself is decided by `approval_reasons`, including reject-only cases.
1630fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1631    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
1632        state.contested_handoff.clone()
1633    } else {
1634        None
1635    }
1636}
1637
1638/// What a merge-approval question's deputy is told: the pull request, the run,
1639/// what each answer does, and the evidence saved when the question was filed.
1640///
1641/// Rebuilt for every deputy turn, it is still a snapshot of that moment, so it
1642/// says so and points at `magi show` / `gh pr view` for anything current.
1643/// `state` is `None` for a run that cannot be read; what is missing is named as
1644/// missing, and no past approval basis is rebuilt from today's state.
1645pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1646    let mut s = format!(
1647        "This is the merge approval for run {run} (`magi show {run}`). The question's \
1648         own text above names the pull request. Answering `{APPROVE}` squash-merges \
1649         it into the base branch; `{HOLD}` leaves the pull request open. Silence is a \
1650         hold: the owner not answering never merges. When the owner tells you to \
1651         merge, in whatever words, settle `{APPROVE}`; when they ask for what another \
1652         offered choice does, settle that one. If you cannot tell what they want, ask \
1653         with `magi ask --thread`.\n\n\
1654         This brief is a snapshot from when your turn started: check `magi show {run}` \
1655         and `gh pr view` (read-only) before telling the owner anything current. The \
1656         driver carries out the merge, repair, withdrawal or replacement: do not \
1657         change files, branches or the pull request yourself.",
1658        run = q.run
1659    );
1660    if let Some(target) = &q.merge_target {
1661        s.push_str(&format!("\n\nThis decision names PR {} at head `{}`. A settled `{APPROVE}` merges exactly that head; a newer head is never merged on this answer. Withdrawal explicitly closes the PR and ends the task. Replacement closes the old PR before dispatching a new attempt with the selected implementer; do not start a separate task for it yourself.", target.pr, target.head));
1662    }
1663    if q.choices.iter().any(|c| c == REPAIR) {
1664        s.push_str("\n\n`fix findings and re-review` requests one repair and bounded re-review on the existing PR. It never approves a merge. The driver performs the repair; do not edit or queue another task for it. Updated review and gate results require a fresh merge decision.");
1665    }
1666    if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1667    {
1668        s.push_str("\n\nSaved approval evidence (at question creation):\n");
1669        s.push_str(&q.detail);
1670    } else {
1671        s.push_str("\n\nApproval reasons and final review evidence were not recorded in this legacy question. Do not infer its original trigger from current settings; consult the saved panel and review history.");
1672    }
1673    let Some(state) = state else {
1674        s.push_str(
1675            "\n\nThe run's record could not be read, so the pull request, the panel \
1676             summary and any contested findings are not known to you beyond the \
1677             question's own text. Say so to the owner rather than guessing.",
1678        );
1679        return s;
1680    };
1681    if let Some(pr) = &state.pr {
1682        s.push_str(&format!(
1683            "\n\nPull request #{} {} (recorded state: {}, last seen).",
1684            pr.number, pr.url, pr.state
1685        ));
1686    }
1687    s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1688    if let Some(w) = state.winner() {
1689        s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1690    }
1691
1692    s
1693}
1694
1695// Releasing a task permits work to resume; it does not renew merge consent.
1696// Check the persisted operator decision both when reusing a card and at the
1697// final merge boundary, since a hold/release can arrive between those reads.
1698fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1699    if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1700        return Ok(false);
1701    }
1702    let Some(task_id) = state
1703        .origin
1704        .as_ref()
1705        .and_then(|origin| origin.task.as_ref())
1706    else {
1707        return Ok(false);
1708    };
1709    let task = crate::queue::Queue::open()
1710        .get(task_id)
1711        .context("cannot verify operator decisions for the merge approval")?;
1712    Ok(task
1713        .operator_decided_at
1714        .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1715}
1716
1717/// Ask the owner before merging, with the whole case attached as a panel.
1718///
1719/// The evidence is gathered from the winner's own worktree with the `git` CLI,
1720/// never from the network, so a phone on a slow link gets the diff magi is
1721/// looking at rather than a link it has to go and open.
1722///
1723/// Never blocks. `land` used to sit inside [`ask::ask_and_wait`]'s poll loop
1724/// for up to a day right here, which held the whole run's task claim - and
1725/// the daemon's one slot with it - for exactly as long as the owner took to
1726/// notice their phone. [`ApprovalGate::Pending`] is the answer that lets the
1727/// caller park the run and hand the slot back instead: the question is on
1728/// disk either way, so nothing about the wait itself changes, only who is
1729/// blocked on it.
1730///
1731/// Idempotent across resumes: called again for a run already waiting on its
1732/// own question, this finds that question by [`crate::ask::Questions::list`]
1733/// rather than filing a second one - asking twice would double the
1734/// notification for one decision, and leave the first question's panel an
1735/// orphan nobody's answer ever reaches.
1736async fn approval_gate(
1737    state: &mut RunState,
1738    pr: &PrState,
1739    subject: &str,
1740    contested: Option<&ContestedHandoff>,
1741    head: &str,
1742) -> Result<ApprovalGate> {
1743    let _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                    && (a.head.eq_ignore_ascii_case(head)
1757                        || q.resolution()
1758                            .as_deref()
1759                            .is_some_and(crate::merge_decision::stops))
1760            })
1761    });
1762    let reusable = match reusable {
1763        Some(q) if merge_consent_superseded(state, &q)? => None,
1764        other => other,
1765    };
1766    if reusable.is_none() {
1767        for stale in store
1768            .list()
1769            .into_iter()
1770            .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1771        {
1772            let why =
1773                "the pull request head or operator decision changed; asked again for approval";
1774            if let Err(e) = store.update(&stale.id, |q| {
1775                q.abandon(why);
1776                Ok(())
1777            }) {
1778                tracing::warn!("could not retire the superseded approval question: {e:#}");
1779            }
1780        }
1781    }
1782
1783    let q = match reusable {
1784        Some(mut q) => {
1785            if upgrade_repair_choice(&mut q, state) {
1786                q = store
1787                    .update(&q.id, |current| {
1788                        upgrade_repair_choice(current, state);
1789                        Ok(())
1790                    })?
1791                    .0;
1792            }
1793            q
1794        }
1795        None => {
1796            let worktree = match state.winner() {
1797                Some(w) => w.worktree.clone(),
1798                None => state.repo.clone(),
1799            };
1800            // The diff is built from the commit being approved, not from the
1801            // branch name, which may already point somewhere else.
1802            let head = if head.is_empty() {
1803                state
1804                    .winner()
1805                    .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1806            } else {
1807                head.to_owned()
1808            };
1809            // The diff is against what the remote's base holds, never the
1810            // local branch of that name; unreadable means less evidence.
1811            let remote = &state.config.merge.remote;
1812            let tracking = format!("{remote}/{}", state.base_branch);
1813            let base = if git::rev_exists(&worktree, &tracking).await {
1814                tracking
1815            } else {
1816                String::new()
1817            };
1818            let range = format!("{base}...{head}");
1819            // A failed `git` must not decide the merge: the panel degrades to
1820            // less evidence and the owner still chooses. Merging because the
1821            // diff could not be read would be the worst of both.
1822            let numstat = if base.is_empty() {
1823                String::new()
1824            } else {
1825                git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1826                    .await
1827                    .map(|o| o.stdout)
1828                    .unwrap_or_default()
1829            };
1830            let diff = if base.is_empty() {
1831                String::new()
1832            } else {
1833                git::diff(&worktree, &base, &head).await.unwrap_or_default()
1834            };
1835            let commits: Vec<String> = if base.is_empty() {
1836                Vec::new()
1837            } else {
1838                git::git_raw(
1839                    &worktree,
1840                    &[
1841                        "log",
1842                        "--reverse",
1843                        "--format=%s",
1844                        &format!("{base}..{head}"),
1845                    ],
1846                )
1847                .await
1848                .map(|o| o.stdout)
1849                .unwrap_or_default()
1850                .lines()
1851                .filter(|l| !l.trim().is_empty())
1852                .map(str::to_owned)
1853                .collect()
1854            };
1855
1856            let w = words(&state.config.graph.language);
1857            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1858            let mut fresh = ask::Question::new(
1859                state.id.clone(),
1860                APPROVAL_NODE.to_owned(),
1861                "land".to_owned(),
1862                w.approval_summary(pr.number, subject),
1863                format!(
1864                    "{}\n\n{}",
1865                    approval_evidence(state),
1866                    w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1867                ),
1868                approval_choices(state),
1869            );
1870            fresh.merge_target = Some(crate::merge_decision::Target {
1871                pr: pr.url.clone(),
1872                head: head.clone(),
1873                language: state.config.graph.language.clone(),
1874            });
1875            if fresh.choices.iter().any(|c| c == REPAIR) {
1876                fresh.actions.insert(
1877                    REPAIR.to_owned(),
1878                    ask::ChoiceAction::Resume {
1879                        run: state.id.clone(),
1880                    },
1881                );
1882                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.");
1883            }
1884            for choice in fresh.choices.clone() {
1885                if crate::merge_decision::stops(&choice)
1886                    && choice != HOLD
1887                    && choice != crate::merge_decision::DISMISS
1888                {
1889                    fresh.actions.insert(
1890                        choice,
1891                        ask::ChoiceAction::Resume {
1892                            run: state.id.clone(),
1893                        },
1894                    );
1895                }
1896            }
1897            store
1898                .put_panel(&mut fresh, &html, &[])
1899                .context("write the merge approval panel")?;
1900            store
1901                .put_executable(&mut fresh)
1902                .context("file the merge approval question")?;
1903            state.land_approval = Some(LandApproval {
1904                question: fresh.id.clone(),
1905                head: head.clone(),
1906            });
1907            state.event(
1908                "land",
1909                format!("asking for merge approval ({})", fresh.short()),
1910            );
1911            state.save()?;
1912            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1913                // A broken webhook is not a reason to lose the merge: the
1914                // question is already on disk and the web UI already shows
1915                // it, so the operator still has a way in.
1916                tracing::warn!(
1917                    "could not notify about merge approval question {}: {e:#} - \
1918                     the web UI is the only surface for it now",
1919                    fresh.short()
1920                );
1921            }
1922            fresh
1923        }
1924    };
1925
1926    Ok(match q.status {
1927        ask::QuestionStatus::Open => ApprovalGate::Pending,
1928        // Nobody answered before `state.config.graph.answer_timeout` passed,
1929        // or the question was closed with no decision recorded underneath
1930        // this run - either way there is nothing left to wait on.
1931        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1932        // The merge gate does not speak `--thread`: an owner who talked back
1933        // instead of choosing never reaches `Answered`, so this arm only
1934        // ever sees an actual decision.
1935        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1936            Approval::Merge => ApprovalGate::Approved,
1937            Approval::Repair if !state.reviews.is_empty() => {
1938                if !state.merge_repairs.iter().any(|r| r.question == q.id) {
1939                    state.merge_repairs.push(crate::run::MergeRepair {
1940                        question: q.id.clone(),
1941                        head: head.to_owned(),
1942                        pr: pr.url.clone(),
1943                        reviews: state.reviews.clone(),
1944                        gate: state.gate.clone(),
1945                        gate_head: state.gate_head.clone(),
1946                        gate_fixes: state.gate_fixes.clone(),
1947                        pre_gate: state.pre_gate.clone(),
1948                        phase: crate::run::MergeRepairPhase::Requested,
1949                        failure: None,
1950                    });
1951                    state.land_approval = None;
1952                    state.event(
1953                        "repair",
1954                        "owner requested fix findings and re-review; merge is not approved",
1955                    );
1956                    state.save()?;
1957                }
1958                ApprovalGate::Repair
1959            }
1960            Approval::Repair | Approval::Hold => ApprovalGate::Held,
1961        },
1962    })
1963}
1964
1965/// Fold a rollup's entries into one verdict plus the failing checks' labels.
1966/// No I/O. An empty rollup is `Unknown`, never green.
1967fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1968    let mut failing = Vec::new();
1969    let mut pending = false;
1970    let mut unknown = false;
1971    for check in rollup {
1972        match check.verdict() {
1973            Verdict::Pass => {}
1974            Verdict::Pending => pending = true,
1975            Verdict::Fail => failing.push(check.label()),
1976            Verdict::Unknown => unknown = true,
1977        }
1978    }
1979    let checks = if rollup.is_empty() {
1980        Checks::Unknown
1981    } else if pending {
1982        Checks::Pending
1983    } else if !failing.is_empty() {
1984        Checks::Red
1985    } else if unknown {
1986        Checks::Unknown
1987    } else {
1988        Checks::Green
1989    };
1990    (checks, failing)
1991}
1992
1993/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
1994/// output into a [`PrState`]. No I/O.
1995pub fn parse_pr(json: &str) -> Result<PrState> {
1996    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1997    let state = match raw.state.to_ascii_uppercase().as_str() {
1998        "OPEN" => PrLifecycle::Open,
1999        "MERGED" => PrLifecycle::Merged,
2000        "CLOSED" => PrLifecycle::Closed,
2001        other => bail!("unknown pull request state `{other}`"),
2002    };
2003
2004    let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2005
2006    let mut review_comments = Vec::new();
2007    for r in raw.reviews {
2008        push_if_outstanding(
2009            &mut review_comments,
2010            ReviewComment {
2011                author: r.author.login,
2012                path: None,
2013                line: None,
2014                body: r.body,
2015            },
2016        );
2017    }
2018    for c in raw.comments {
2019        push_if_outstanding(
2020            &mut review_comments,
2021            ReviewComment {
2022                author: c.author.login,
2023                path: None,
2024                line: None,
2025                body: c.body,
2026            },
2027        );
2028    }
2029
2030    Ok(PrState {
2031        url: raw.url,
2032        number: raw.number,
2033        state,
2034        checks,
2035        failing,
2036        review_comments,
2037        blocking: Blocking::of(&raw.merge_state_status),
2038    })
2039}
2040
2041/// One rollup entry as the release watcher reads it.
2042#[derive(Debug, Clone, PartialEq, Eq)]
2043pub(crate) struct CheckView {
2044    pub name: String,
2045    pub verdict: Verdict,
2046    /// Workflow run behind the check, when its url names one.
2047    pub run: Option<String>,
2048    pub url: Option<String>,
2049}
2050
2051/// A pull request's lifecycle, head commit and per-check verdicts, without
2052/// [`rollup_verdict`]'s aggregation (a pending check anywhere hides a failure
2053/// from it, which is exactly what the release watcher must not inherit).
2054#[derive(Debug, Clone, PartialEq, Eq)]
2055pub(crate) struct RollupView {
2056    pub url: String,
2057    pub number: u64,
2058    pub state: PrLifecycle,
2059    pub head: String,
2060    pub checks: Vec<CheckView>,
2061}
2062
2063/// Parse `gh pr view --json url,number,state,headRefOid,statusCheckRollup`
2064/// output. No I/O.
2065pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2066    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2067    let state = match raw.state.to_ascii_uppercase().as_str() {
2068        "OPEN" => PrLifecycle::Open,
2069        "MERGED" => PrLifecycle::Merged,
2070        "CLOSED" => PrLifecycle::Closed,
2071        other => bail!("unknown pull request state `{other}`"),
2072    };
2073    let checks = raw
2074        .status_check_rollup
2075        .iter()
2076        .map(|c| CheckView {
2077            name: c.label(),
2078            verdict: c.verdict(),
2079            run: c.url().and_then(run_of),
2080            url: c.url().map(str::to_owned),
2081        })
2082        .collect();
2083    Ok(RollupView {
2084        url: raw.url,
2085        number: raw.number,
2086        state,
2087        head: raw.head_ref_oid,
2088        checks,
2089    })
2090}
2091
2092/// Read just a pull request's lifecycle state - open, merged, or closed -
2093/// with none of the checks/reviews/comments [`land`] itself needs to decide
2094/// what to do next.
2095///
2096/// For a caller that only ever wants one fact and must not risk anything
2097/// else: `magi fold --merged` uses this to confirm a URL the operator hands
2098/// it is actually a merged pull request *before* touching a run's state, so a
2099/// typo or a still-open PR fails loudly instead of quietly recording a merge
2100/// that never happened.
2101pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2102    let view = gh(
2103        repo,
2104        &[
2105            "pr".to_owned(),
2106            "view".to_owned(),
2107            pr_url.to_owned(),
2108            "--json".to_owned(),
2109            "state".to_owned(),
2110        ],
2111    )
2112    .await?;
2113    if !view.0 {
2114        bail!("gh pr view {pr_url}: {}", view.1);
2115    }
2116    // `parse_pr` reads every other field of `GhPr` as its serde default
2117    // (empty string, empty vec, zero) when this narrower `--json` selection
2118    // does not carry them - harmless, since only `.state` is read back.
2119    Ok(parse_pr(&view.1)?.state)
2120}
2121
2122/// A pull request the operator merged outside of `land::land`'s own loop,
2123/// found by asking GitHub about the run's own winning branch rather than
2124/// requiring the operator to go and find the URL themselves.
2125#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2126pub struct ExternalMerge {
2127    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
2128    pub url: String,
2129    /// The pull request's number.
2130    pub number: u64,
2131}
2132
2133#[derive(Debug, Deserialize)]
2134#[serde(rename_all = "camelCase")]
2135struct GhMergedPr {
2136    url: String,
2137    number: u64,
2138    merged_at: String,
2139    base_ref_name: String,
2140}
2141
2142/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
2143/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
2144/// decide whether exactly one of the pull requests it lists could actually
2145/// be *this* run's.
2146///
2147/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
2148/// from the run's own short id, so a collision with some other, unrelated
2149/// task's merged pull request from a same-named branch is rare but not
2150/// impossible once branches are deleted and ids run out. Filtering on
2151/// `base_ref_name` (the branch this run actually targets) and `merged_at`
2152/// (which cannot predate the run itself) rules that case out. More than one
2153/// survivor is exactly as uninformative as zero — something this run cannot
2154/// tell apart from another — so only a unique survivor is returned.
2155fn pick_merged_pr(
2156    json: &str,
2157    base_branch: &str,
2158    created_at: Timestamp,
2159) -> Result<Option<ExternalMerge>> {
2160    let raw: Vec<GhMergedPr> =
2161        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2162    let mut matches: Vec<ExternalMerge> = Vec::new();
2163    for pr in raw {
2164        if pr.base_ref_name != base_branch {
2165            continue;
2166        }
2167        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2168            continue;
2169        };
2170        if merged_at < created_at {
2171            continue;
2172        }
2173        matches.push(ExternalMerge {
2174            url: pr.url,
2175            number: pr.number,
2176        });
2177    }
2178    if matches.len() == 1 {
2179        Ok(matches.pop())
2180    } else {
2181        Ok(None)
2182    }
2183}
2184
2185/// What `gh pr list --head <branch> --base <base> --state open` found.
2186#[derive(Debug, Clone, PartialEq, Eq)]
2187pub enum OpenPr {
2188    /// Nothing open: the caller creates one.
2189    None,
2190    /// Exactly one: the caller adopts it instead of creating a second.
2191    One {
2192        /// The pull request's URL.
2193        url: String,
2194        /// Its current title.
2195        title: String,
2196    },
2197    /// More than one: magi does not pick between them.
2198    Many(Vec<String>),
2199}
2200
2201#[derive(Debug, Deserialize)]
2202#[serde(rename_all = "camelCase")]
2203struct GhOpenPr {
2204    // `url` and `baseRefName` are required: a record missing either must be a
2205    // parse error, not a pull request that silently fails the base filter and
2206    // reads as "none open" (which would go on to create a duplicate).
2207    url: String,
2208    #[serde(default)]
2209    title: String,
2210    base_ref_name: String,
2211    // A fork's branch of the same name is not the task head; `gh pr list --head`
2212    // matches by branch name alone, so the filter has to be ours.
2213    #[serde(default)]
2214    is_cross_repository: bool,
2215}
2216
2217/// Pure half of [`find_open_pr`]: classify the raw `--json
2218/// number,url,title,baseRefName` output. Entries whose base is not `base` are
2219/// dropped even though the query already filtered on it, so a stub or an old
2220/// `gh` that ignores `--base` cannot get a pull request into the wrong branch
2221/// adopted.
2222pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2223    let raw: Vec<GhOpenPr> =
2224        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2225    let mut hits: Vec<GhOpenPr> = raw
2226        .into_iter()
2227        .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2228        .collect();
2229    Ok(match hits.len() {
2230        0 => OpenPr::None,
2231        1 => {
2232            let p = hits.remove(0);
2233            OpenPr::One {
2234                url: p.url,
2235                title: p.title,
2236            }
2237        }
2238        _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2239    })
2240}
2241
2242/// Open pull requests whose head is `branch` and whose base is `base`. A
2243/// failing `gh` is an error carrying its own output, never "none": guessing
2244/// there is how a duplicate gets created.
2245pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2246    let (ok, out) = gh(
2247        repo,
2248        &[
2249            "pr".to_owned(),
2250            "list".to_owned(),
2251            "--head".to_owned(),
2252            branch.to_owned(),
2253            "--base".to_owned(),
2254            base.to_owned(),
2255            "--state".to_owned(),
2256            "open".to_owned(),
2257            "--json".to_owned(),
2258            "number,url,title,baseRefName,isCrossRepository".to_owned(),
2259        ],
2260    )
2261    .await?;
2262    if !ok {
2263        bail!("gh pr list failed: {out}");
2264    }
2265    pick_open_pr(&out, base)
2266}
2267
2268#[derive(Debug, Deserialize)]
2269#[serde(rename_all = "camelCase")]
2270struct GhPrHead {
2271    head_ref_name: String,
2272    base_ref_name: String,
2273    state: String,
2274    // Required, like `GhOpenPr`'s fields: a record that cannot say whether the
2275    // head lives in a fork must be a parse error, never "same repository".
2276    is_cross_repository: bool,
2277    // Required too: it is what ties the pull request to the commits that were
2278    // actually proven to be on the base.
2279    head_ref_oid: String,
2280}
2281
2282/// Why [`closable`] said no, and whether asking again later could say yes.
2283#[derive(Debug, Clone, PartialEq, Eq)]
2284pub struct Refusal {
2285    /// A later attempt may succeed (the head moved, the view was unreadable);
2286    /// `false` means this pull request is simply not the run's to close.
2287    pub retry: bool,
2288    /// What was wrong.
2289    pub why: String,
2290}
2291
2292impl Refusal {
2293    fn final_(why: String) -> Self {
2294        Self { retry: false, why }
2295    }
2296}
2297
2298/// Pure half of [`close_superseded_pr`]: read `gh pr view --json
2299/// headRefName,baseRefName,state,isCrossRepository` and say whether closing
2300/// is safe, `Err` carrying the reason when it is not.
2301///
2302/// Closing is outward-facing, so every property is checked on what the forge
2303/// says now, not on what magi recorded: the head must be exactly `branch` in
2304/// this repository (a fork's branch of the same name is somebody else's), the
2305/// base must be `base`, and the pull request must still be open.
2306pub fn closable(
2307    json: &str,
2308    branch: &str,
2309    base: &str,
2310    verified: &[String],
2311) -> std::result::Result<(), Refusal> {
2312    let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2313        retry: true,
2314        why: format!("could not read the pull request ({e})"),
2315    })?;
2316    if pr.head_ref_name != branch {
2317        return Err(Refusal::final_(format!(
2318            "its head is `{}`, not this run's `{branch}`",
2319            pr.head_ref_name
2320        )));
2321    }
2322    if pr.is_cross_repository {
2323        return Err(Refusal::final_("its head lives in a fork".to_owned()));
2324    }
2325    if pr.base_ref_name != base {
2326        return Err(Refusal::final_(format!(
2327            "it targets `{}`, not `{base}`",
2328            pr.base_ref_name
2329        )));
2330    }
2331    if !pr.state.eq_ignore_ascii_case("open") {
2332        return Err(Refusal::final_(format!(
2333            "it is already {}",
2334            pr.state.to_ascii_lowercase()
2335        )));
2336    }
2337    // Last, so a pull request that is not this run's at all is reported as
2338    // such. A head that is this branch but not a commit checked against the
2339    // base (somebody pushed since) may well get checked next time: retry.
2340    if !verified.contains(&pr.head_ref_oid) {
2341        return Err(Refusal {
2342            retry: true,
2343            why: format!(
2344                "its head {} is not a commit this run checked against the base",
2345                crate::already::short_sha(&pr.head_ref_oid)
2346            ),
2347        });
2348    }
2349    Ok(())
2350}
2351
2352/// Does `remote` point at something a forge could host - a URL or an scp-style
2353/// `user@host:path` - rather than a filesystem path or nothing at all? Judged
2354/// from the URL alone, so it answers the same on every machine, whatever `gh`
2355/// happens to be installed or logged in to.
2356async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2357    let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2358        return false;
2359    };
2360    is_forge_url(url.trim())
2361}
2362
2363fn is_forge_url(url: &str) -> bool {
2364    url.contains("://") && !url.starts_with("file://")
2365        || url
2366            .split_once(':')
2367            .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2368}
2369
2370/// Does this `gh` failure mean there is no GitHub to ask, as opposed to a
2371/// request that failed?
2372fn forge_unavailable(message: &str) -> bool {
2373    message.contains("known GitHub host") || message.contains("spawn gh")
2374}
2375
2376/// The comment left on a pull request closed because its change is already on
2377/// the base.
2378pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2379    let how = match evidence.proof {
2380        crate::already::Proof::PatchId => format!(
2381            "carried by commit {} on `{base}` with the same patch",
2382            evidence.names()
2383        ),
2384        crate::already::Proof::Ancestry => {
2385            format!("already in the history of `{base}` as {}", evidence.names())
2386        }
2387        crate::already::Proof::Tree => format!(
2388            "already part of `{base}` (merging this branch changes nothing at {})",
2389            crate::already::short_sha(&evidence.tip)
2390        ),
2391    };
2392    format!(
2393        "Closing: everything this branch adds is {how}, so there is nothing left to \
2394         land. This pull request was closed automatically after that was verified; \
2395         reopen it if you disagree."
2396    )
2397}
2398
2399/// Close the open pull request for `branch`, if there is one and it is
2400/// provably this run's, with a comment naming what supersedes it. `Ok(Ok(url))` is
2401/// the URL closed; `Ok(Err(why))` is a final "nothing to close" (no pull request,
2402/// not this run's, no forge); `Err` is anything a later attempt could resolve (a
2403/// failed `gh` call, a head that moved since it was checked), which the caller
2404/// must not treat as settled.
2405///
2406/// The recorded `state.pr` is preferred, else the forge is asked for an open
2407/// pull request on `branch`; either way the candidate is re-read and passed
2408/// through [`closable`] before anything is written; `verified` lists the
2409/// commits proven to be on the base, and the pull request's head must be one.
2410pub async fn close_superseded_pr(
2411    state: &mut RunState,
2412    branch: &str,
2413    evidence: &crate::already::Evidence,
2414    verified: &[String],
2415) -> Result<std::result::Result<String, String>> {
2416    let repo = state.repo.clone();
2417    let base = state.base_branch.clone();
2418    let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2419        Some(p) => p.url.clone(),
2420        // No recorded pull request. A forge that cannot be asked at all (no
2421        // GitHub remote, no `gh`) says nothing about this run, so that is
2422        // "none found"; any other lookup failure is an error, because "could
2423        // not look" is not "nothing there" and the caller must retry.
2424        None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2425            return Ok(Err(
2426                "the remote is not a forge, so there is no pull request".to_owned(),
2427            ));
2428        }
2429        None => match find_open_pr(&repo, branch, &base).await {
2430            Err(e) if forge_unavailable(&format!("{e:#}")) => {
2431                return Ok(Err(format!("no forge to ask: {e:#}")));
2432            }
2433            Err(e) => return Err(e),
2434            Ok(OpenPr::One { url, .. }) => url,
2435            Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2436            Ok(OpenPr::Many(urls)) => {
2437                return Ok(Err(format!(
2438                    "{} open pull requests name it; not choosing between them",
2439                    urls.len()
2440                )));
2441            }
2442        },
2443    };
2444    let (ok, view) = gh(
2445        &repo,
2446        &[
2447            "pr".to_owned(),
2448            "view".to_owned(),
2449            url.clone(),
2450            "--json".to_owned(),
2451            "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2452        ],
2453    )
2454    .await?;
2455    if !ok {
2456        bail!("gh pr view {url} failed: {view}");
2457    }
2458    if let Err(refusal) = closable(&view, branch, &base, verified) {
2459        // A pull request whose head cannot be tied to what was proven is not
2460        // one to walk away from: the caller keeps the run resumable.
2461        if refusal.retry {
2462            bail!("left {url} open: {}", refusal.why);
2463        }
2464        return Ok(Err(format!("left {url} open: {}", refusal.why)));
2465    }
2466    let (ok, out) = gh(
2467        &repo,
2468        &[
2469            "pr".to_owned(),
2470            "close".to_owned(),
2471            url.clone(),
2472            "--comment".to_owned(),
2473            superseded_comment(&base, evidence),
2474        ],
2475    )
2476    .await?;
2477    if !ok {
2478        bail!("gh pr close {url} failed: {out}");
2479    }
2480    if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2481        p.state = "closed".to_owned();
2482    }
2483    Ok(Ok(url))
2484}
2485
2486/// `gh pr edit <url> --title <title>`, for an adopted pull request whose title
2487/// differs from the one this run computed. Only the title: the body may have
2488/// been edited by the owner and cannot be compared.
2489pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2490    let (title, _) = crate::github_text::prepare(state, title, "");
2491    edit_pr_title(repo, url, &title).await
2492}
2493
2494/// [`set_pr_title`] for a title the owner explicitly chose to post unmodified
2495/// (`github_text::POST_ORIGINAL` or `github_text::USE_REWRITE`): the posting
2496/// gate is skipped on purpose.
2497pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2498    edit_pr_title(repo, url, title).await
2499}
2500
2501/// Post both fields of the owner's chosen rewrite to an adopted pull request,
2502/// unmodified and without the posting gate.
2503pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2504    let (ok, _) = gh(
2505        repo,
2506        &[
2507            "pr".to_owned(),
2508            "edit".to_owned(),
2509            url.to_owned(),
2510            "--title".to_owned(),
2511            title.to_owned(),
2512            "--body".to_owned(),
2513            body.to_owned(),
2514        ],
2515    )
2516    .await
2517    .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2518    if !ok {
2519        // Command output may echo the chosen text; keep it out of run events.
2520        bail!("gh pr edit failed while posting the chosen rewrite");
2521    }
2522    Ok(())
2523}
2524
2525async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2526    let (ok, out) = gh(
2527        repo,
2528        &[
2529            "pr".to_owned(),
2530            "edit".to_owned(),
2531            url.to_owned(),
2532            "--title".to_owned(),
2533            title.to_owned(),
2534        ],
2535    )
2536    .await?;
2537    if !ok {
2538        bail!("gh pr edit failed: {out}");
2539    }
2540    Ok(())
2541}
2542
2543/// Ask GitHub whether this run's winning candidate branch was actually merged
2544/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2545/// --merged` exists to close, minus the operator having to find the URL by
2546/// hand.
2547///
2548/// `Ok(None)` covers every case where nothing can be said with confidence: no
2549/// winner decided yet (nothing to check a branch for), no merged pull request
2550/// found, or [`pick_merged_pr`] found more than one candidate and would not
2551/// guess between them. Never wired to a weaker, URL-less signal like
2552/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2553/// separately, precisely because it cannot drive an automatic correction on
2554/// its own (see that function's own doc).
2555pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2556    let Some(winner) = state.winner() else {
2557        return Ok(None);
2558    };
2559    let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2560    let out = gh(
2561        &state.repo,
2562        &[
2563            "pr".to_owned(),
2564            "list".to_owned(),
2565            "--head".to_owned(),
2566            branch.clone(),
2567            "--state".to_owned(),
2568            "merged".to_owned(),
2569            "--json".to_owned(),
2570            "url,number,mergedAt,baseRefName".to_owned(),
2571        ],
2572    )
2573    .await?;
2574    if !out.0 {
2575        bail!("gh pr list --head {branch}: {}", out.1);
2576    }
2577    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2578}
2579
2580/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2581/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2582///
2583/// Deliberately never consulted by [`find_external_merge`]: a base branch
2584/// that has moved since the run started can make an old, abandoned branch
2585/// look like an ancestor of the *current* base for reasons that have nothing
2586/// to do with a merge (a later commit that happens to supersede it, an
2587/// unrelated squash), and there is no pull request URL here to confirm
2588/// against or to land through anyway. Its only honest use is a weaker
2589/// notice — "this looks merged, go check" — never an automatic rewrite of
2590/// `status`/`merge`.
2591pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2592    let out = tokio::process::Command::new("git")
2593        .args(["merge-base", "--is-ancestor", branch, base_branch])
2594        .current_dir(repo)
2595        .quiet()
2596        .stdin(std::process::Stdio::null())
2597        .output()
2598        .await
2599        .context("spawn git merge-base --is-ancestor")?;
2600    Ok(out.status.success())
2601}
2602
2603/// Parse `host/owner/repo` out of a forge URL, with no network access.
2604///
2605/// The host is part of the slug, not discarded: `owner/repo` alone would
2606/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2607/// repository, which is exactly the mix-up the same-repo guard exists to
2608/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2609/// shape at all.
2610fn forge_slug(url: &str) -> Option<(String, &str)> {
2611    let rest = url.rsplit("://").next()?;
2612    let (host, path) = rest.split_once('/')?;
2613    if host.is_empty() {
2614        return None;
2615    }
2616    Some((host.to_ascii_lowercase(), path))
2617}
2618
2619/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2620/// access - the first half of the same-repo guard [`correct_manual_merge`]
2621/// applies before it writes anything.
2622///
2623/// Returns `None` for anything that does not look like
2624/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2625/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2626pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2627    let (host, path) = forge_slug(url)?;
2628    let mut segments = path.split('/');
2629    let owner = segments.next()?;
2630    let repo = segments.next()?;
2631    let kind = segments.next()?;
2632    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2633        return None;
2634    }
2635    Some(format!("{host}/{owner}/{repo}"))
2636}
2637
2638/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2639/// suffix), the shape `gh repo view --json url` returns - the other half of
2640/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2641fn slug_of_repo_url(url: &str) -> Option<String> {
2642    let (host, path) = forge_slug(url)?;
2643    let mut segments = path.split('/');
2644    let owner = segments.next()?;
2645    let repo = segments.next()?;
2646    if owner.is_empty() || repo.is_empty() {
2647        return None;
2648    }
2649    Some(format!("{host}/{owner}/{repo}"))
2650}
2651
2652/// Refuse to correct a run against a pull request from a different
2653/// repository than the one it is recorded against.
2654///
2655/// This is the guard the shun/8c75 incident argued for: an operator ran
2656/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2657/// in a different repository, omitted the run id, and the id defaulted to
2658/// this machine's most recently created run - an unrelated, still-in-progress
2659/// run in a completely different repository - which then had its `status`
2660/// rewritten to `merged` from a pull request it had nothing to do with.
2661/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2662/// CLI help), but a mistyped or stale id could still name a run in a
2663/// different repository than the one the URL belongs to, so this checks that
2664/// independently rather than trusting the id alone.
2665///
2666/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2667/// mismatch names both slugs rather than just refusing, so an operator whose
2668/// local checkout's `origin` is a fork of the repository the pull request was
2669/// opened against (a legitimate setup this cannot tell apart from a genuine
2670/// mix-up) can judge for themselves rather than being blocked with no way to
2671/// see why.
2672pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2673    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2674        return Ok(());
2675    }
2676    bail!(
2677        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2678         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2679         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2680         verify by hand before treating this as a false positive)"
2681    );
2682}
2683
2684/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2685/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2686///
2687/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2688/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2689/// repository the same way GitHub itself would recognize it, so the
2690/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2691/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2692/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2693/// a same-named one on a GitHub Enterprise host.
2694async fn repo_slug(repo: &Path) -> Result<String> {
2695    let out = gh(
2696        repo,
2697        &[
2698            "repo".to_owned(),
2699            "view".to_owned(),
2700            "--json".to_owned(),
2701            "url".to_owned(),
2702        ],
2703    )
2704    .await?;
2705    if !out.0 {
2706        bail!("gh repo view --json url: {}", out.1);
2707    }
2708    #[derive(Debug, Deserialize)]
2709    struct GhRepo {
2710        url: String,
2711    }
2712    let parsed: GhRepo = serde_json::from_str(&out.1)
2713        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2714    slug_of_repo_url(&parsed.url)
2715        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2716}
2717
2718/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2719/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2720/// would have written them had magi opened and merged this pull request
2721/// itself.
2722///
2723/// This is `magi fold --merged`'s whole implementation, and also what the
2724/// web `fold-merged` route calls once it has a URL in hand — an operator
2725/// recovery path for a merge magi could not finish on its own: a PR title too
2726/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2727/// closed by hand with a pull request magi never opened and so never
2728/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2729/// directly keeps this one authoritative: a merged pull request decides
2730/// `Step::Done { merged: true }` on the very first read, before any of
2731/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2732/// safe to call here even though this pull request was never magi's own.
2733///
2734/// [`ensure_same_repo`] is checked before anything else: a pull request from
2735/// a different repository than the one `state` is recorded against is
2736/// refused outright, regardless of its lifecycle. This is the guard for a
2737/// URL an *operator* hands in - the CLI or the web route - where a stale or
2738/// mistyped run id could otherwise get corrected from an unrelated
2739/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2740/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2741/// goes through [`correct_confirmed_external_merge`] instead, which skips
2742/// this check: its `url` was never operator-supplied, it comes from
2743/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2744/// it is already guaranteed to name a pull request in that same repository -
2745/// re-deriving and re-checking the repository here would only be a second
2746/// `gh repo view` call that can fail for reasons that have nothing to do with
2747/// correctness (a rate limit, a network blip), turning a self-heal that would
2748/// otherwise have succeeded into a run left `Blocked` for another pass.
2749///
2750/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2751/// URL fails loudly without writing anything, rather than handing an open
2752/// pull request to the full autonomous loop by accident.
2753///
2754/// Correcting `status` this way does not run `bump::after_merge`
2755/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2756/// which this path never goes through. A release version bump the change
2757/// might have earned is therefore not filed automatically and has to be
2758/// requested by hand - recorded as an event on the run so the gap is visible
2759/// to whoever reads it later, not just wherever this was called from. Follow-up
2760/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2761///
2762/// Returns the status before and after, so every caller (CLI, janitor, web
2763/// route) can build its own log line or response from the same pair rather
2764/// than each re-deriving it.
2765pub async fn correct_manual_merge(
2766    state: &mut RunState,
2767    url: &str,
2768) -> Result<(RunStatus, RunStatus)> {
2769    let Some(pr_slug) = slug_of_pr_url(url) else {
2770        bail!(
2771            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2772             this pull request belongs to"
2773        );
2774    };
2775    let run_slug = repo_slug(&state.repo).await?;
2776    ensure_same_repo(&run_slug, &pr_slug)?;
2777    correct_merge(state, url).await
2778}
2779
2780/// The janitor's own entry point into the same correction
2781/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2782/// same-repo guard - see that function's own doc for why skipping it here is
2783/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2784/// calls this with a `url` [`find_external_merge`] already found by querying
2785/// `state.repo`'s own remote, so the guard could never do anything here but
2786/// fail on its own transient errors.
2787///
2788/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2789/// because it lives in a different module.
2790pub(crate) async fn correct_confirmed_external_merge(
2791    state: &mut RunState,
2792    url: &str,
2793) -> Result<(RunStatus, RunStatus)> {
2794    correct_merge(state, url).await
2795}
2796
2797/// Same pull request, same repository: the url, or the number within one repo.
2798fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2799    let Some(pr) = a.pr.as_ref() else {
2800        return false;
2801    };
2802    if !url.is_empty()
2803        && pr
2804            .url
2805            .trim_end_matches('/')
2806            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2807    {
2808        return true;
2809    }
2810    number > 0
2811        && pr.number == number
2812        && match (a.repo.canonicalize(), repo.canonicalize()) {
2813            (Ok(x), Ok(y)) => x == y,
2814            _ => a.repo == repo,
2815        }
2816}
2817
2818/// Rewrite `pr.state` to a final state on every run record under `home` that
2819/// `decide` picks, and report how many were rewritten.
2820///
2821/// Lifecycle writes through to other runs are deliberately narrow (task-head
2822/// ownership is recorded separately): only a **terminal** run (never one a
2823/// driver may still be writing), only a proven-stopped driver with no daemon
2824/// claim (the shared liveness judge), only a record
2825/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2826/// The record is read as folding reads it (no schema check: every bump so far
2827/// only added fields, and the whole struct round-trips) and written through
2828/// [`RunState::save_under`], the path every record uses, so nothing but
2829/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2830/// A run that cannot be read or written is skipped with a warning.
2831fn rewrite_open_prs(
2832    home: &Path,
2833    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2834) -> usize {
2835    let now = Timestamp::now();
2836    let mut changed = 0;
2837    for id in crate::run::list_ids_in(&home.join("runs")) {
2838        let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2839            continue;
2840        };
2841        let path = home.join("runs").join(&id).join("run.json");
2842        let Ok(body) = std::fs::read_to_string(&path) else {
2843            continue;
2844        };
2845        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2846            continue;
2847        };
2848        if !state.status.done()
2849            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2850            || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2851                != crate::run::Liveness::Dead
2852        {
2853            continue;
2854        }
2855        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2856            continue;
2857        };
2858        if let Some(pr) = state.pr.as_mut() {
2859            pr.state = to.as_str().to_owned();
2860        }
2861        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2862        state.event(
2863            "land",
2864            format!("recorded {url} as {}: another run settled it", to.as_str()),
2865        );
2866        match state.save_under(home) {
2867            Ok(()) => changed += 1,
2868            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2869        }
2870    }
2871    changed
2872}
2873
2874/// A run reached a final state for its pull request: tell every other terminal
2875/// run in the same repository that names the same pull request (handed-over
2876/// predecessors, blocked attempts, anything), so their records stop saying
2877/// `open`. Best effort; `run`'s own record is the caller's.
2878pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2879    if to == PrLifecycle::Open {
2880        return;
2881    }
2882    let Some(home) = crate::run::try_home() else {
2883        return;
2884    };
2885    write_pr_state_through_in(&home, run, to);
2886}
2887
2888pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2889    let Some(pr) = run.pr.as_ref() else {
2890        return 0;
2891    };
2892    let (url, number) = (pr.url.clone(), pr.number);
2893    rewrite_open_prs(home, &mut |other| {
2894        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2895    })
2896}
2897
2898/// Terminal runs whose record still says their pull request is open, as
2899/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
2900pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2901    let now = Timestamp::now();
2902    let mut out = Vec::new();
2903    for id in crate::run::list_ids_in(&home.join("runs")) {
2904        let path = home.join("runs").join(&id).join("run.json");
2905        let Ok(body) = std::fs::read_to_string(&path) else {
2906            continue;
2907        };
2908        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2909            continue;
2910        };
2911        if let Some(pr) = state.pr.as_ref()
2912            && state.status.done()
2913            && pr.state == "open"
2914            && !pr.url.is_empty()
2915            && !crate::daemon::is_working_on(home, &id, now)
2916        {
2917            out.push((id, state.repo.clone(), pr.url.clone()));
2918        }
2919    }
2920    out
2921}
2922
2923/// Apply forge answers (`pr url -> state`) to every stale terminal record.
2924/// A url with no answer (the forge was unreadable) changes nothing.
2925pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2926    rewrite_open_prs(home, &mut |s| {
2927        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2928    })
2929}
2930
2931/// One-time (and idempotent) repair of records that froze an `open` pull
2932/// request: ask the forge about each distinct pull request that a terminal run
2933/// still calls open - at most `max_lookups` of them - and rewrite the merged
2934/// and closed ones. A genuinely open pull request is left alone, and a lookup
2935/// that fails is "unknown, change nothing". Returns `(records rewritten,
2936/// lookups that failed)`.
2937pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2938    let mut known = BTreeMap::new();
2939    let mut failed = 0;
2940    let mut seen = BTreeSet::new();
2941    for (_, repo, url) in stale_open_prs(home) {
2942        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2943            continue;
2944        }
2945        match lifecycle(&repo, &url).await {
2946            Ok(state) => {
2947                known.insert(url, state);
2948            }
2949            Err(e) => {
2950                tracing::warn!("repair pr state of {url}: {e:#}");
2951                failed += 1;
2952            }
2953        }
2954    }
2955    (apply_pr_states(home, &known), failed)
2956}
2957
2958async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2959    match lifecycle(&state.repo, url).await? {
2960        PrLifecycle::Merged => {}
2961        other => bail!(
2962            "{url} is {}, not merged; refusing to record {} as merged on a guess",
2963            other.as_str(),
2964            state.id
2965        ),
2966    }
2967    let before = state.status;
2968    if let Err(e) = land(state, url).await {
2969        // `land` sets `status` to `Landing` and saves before its first read
2970        // of the pull request — see its own doc — so a failure here (a
2971        // transient `gh` hiccup between the two forge reads this function
2972        // makes) can leave the run stuck on that in-between value with
2973        // nothing left driving it. Land it on the same terminal shape an
2974        // automated `land` failure lands on instead of leaving it stuck.
2975        state.status = RunStatus::Blocked;
2976        state.event("fold", format!("manual-merge correction failed: {e:#}"));
2977        state.save()?;
2978        return Err(e).context(format!("confirming the merge of {url}"));
2979    }
2980    state.event(
2981        "fold",
2982        "operator recorded this pull request as a manual merge; this run never \
2983         re-entered `land`, so `bump::after_merge` did not run for it - a release \
2984         bump this change might warrant has to be filed by hand",
2985    );
2986    // Unlike the bump, the findings the merge left open are filed on this
2987    // path too: nothing else would ever carry them forward.
2988    if state.status == RunStatus::Merged {
2989        crate::followup::after_merge(state, url).await;
2990    }
2991    state.save()?;
2992    Ok((before, state.status))
2993}
2994
2995/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
2996/// comments. No I/O.
2997///
2998/// `gh pr view` does not surface inline comments, and inline is exactly where
2999/// both review bots put their findings - a landing loop that read only the
3000/// top-level thread would never see the thing it is supposed to fix.
3001pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3002    let raw: Vec<GhInline> =
3003        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3004    let mut out = Vec::new();
3005    for c in raw {
3006        push_if_outstanding(
3007            &mut out,
3008            ReviewComment {
3009                author: c.user.login,
3010                path: c.path,
3011                line: c.line,
3012                body: c.body,
3013            },
3014        );
3015    }
3016    Ok(out)
3017}
3018
3019/// Keep a comment only when it asks for something.
3020///
3021/// An inline comment always does: it names a file and a line. A top-level
3022/// comment is dropped when it is empty, when it is magi's own, or when it is
3023/// [noise](is_noise).
3024fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3025    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3026        return;
3027    }
3028    if comment.path.is_none() && is_noise(&comment.body) {
3029        return;
3030    }
3031    out.push(comment);
3032}
3033
3034/// Is this comment body machinery rather than a finding?
3035///
3036/// Two tests, both structural, because guessing from prose is how a "looks
3037/// good to me" turns into a fix round:
3038///
3039/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
3040///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
3041///    footer.
3042/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
3043///    horizontal rules, and the bot's own status banner are removed, every
3044///    remaining line is a task-list item. That is exactly the shape of the
3045///    comment the Claude review job posts while it is still working.
3046///
3047/// Anything else is input, including bot prose. A bot that writes a paragraph
3048/// has said something, and the fix prompt tells the fixer it may decline a
3049/// comment with an argument - a wasted sentence in a prompt is cheaper than a
3050/// missed finding.
3051pub fn is_noise(body: &str) -> bool {
3052    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3053        return true;
3054    }
3055    let mut content = false;
3056    for line in strip_blocks(body).lines() {
3057        let line = unquote(line);
3058        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3059            continue;
3060        }
3061        content = true;
3062        break;
3063    }
3064    !content
3065}
3066
3067/// Remove HTML comments and collapsed `<details>` blocks.
3068fn strip_blocks(body: &str) -> String {
3069    let mut out = String::with_capacity(body.len());
3070    let mut rest = body;
3071    loop {
3072        let open = ["<!--", "<details>"]
3073            .iter()
3074            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3075            .min_by_key(|(i, _)| *i);
3076        let Some((at, tag)) = open else {
3077            out.push_str(rest);
3078            return out;
3079        };
3080        out.push_str(&rest[..at]);
3081        let after = &rest[at + tag.len()..];
3082        let close = if tag == "<!--" { "-->" } else { "</details>" };
3083        match after.find(close) {
3084            Some(end) => rest = &after[end + close.len()..],
3085            // Unterminated: the rest of the body is inside the block.
3086            None => return out,
3087        }
3088    }
3089}
3090
3091/// Strip blockquote markers, which both bots wrap their callouts in.
3092fn unquote(line: &str) -> &str {
3093    let mut s = line.trim();
3094    while let Some(rest) = s.strip_prefix('>') {
3095        s = rest.trim_start();
3096    }
3097    s.trim()
3098}
3099
3100/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
3101fn is_checklist(line: &str) -> bool {
3102    let rest = line
3103        .strip_prefix("- ")
3104        .or_else(|| line.strip_prefix("* "))
3105        .unwrap_or("");
3106    let rest = rest.trim_start();
3107    matches!(
3108        rest.get(..3),
3109        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3110    )
3111}
3112
3113/// A heading, a horizontal rule, or a callout tag - shape, never content.
3114fn is_decoration(line: &str) -> bool {
3115    line.starts_with('#')
3116        || line.starts_with("[!")
3117        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3118}
3119
3120/// A line that is nothing but emphasis and links.
3121///
3122/// Both review jobs open with a status banner
3123/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
3124/// to a line-based test and asks for nothing, so it is measured the same way a
3125/// heading is: strip the markup, and if no word survives, it was decoration.
3126fn is_banner(line: &str) -> bool {
3127    let plain = drop_spans(line, "**", "**");
3128    let plain = if plain.contains("](") {
3129        drop_spans(&plain, "[", ")")
3130    } else {
3131        plain
3132    };
3133    !plain.chars().any(char::is_alphanumeric)
3134}
3135
3136/// Remove every `open` .. `close` span, including the delimiters. An
3137/// unterminated span swallows the rest of the input, which is what a reader
3138/// sees too.
3139fn drop_spans(s: &str, open: &str, close: &str) -> String {
3140    let mut out = String::with_capacity(s.len());
3141    let mut rest = s;
3142    while let Some(at) = rest.find(open) {
3143        out.push_str(&rest[..at]);
3144        let after = &rest[at + open.len()..];
3145        match after.find(close) {
3146            Some(end) => rest = &after[end + close.len()..],
3147            None => return out,
3148        }
3149    }
3150    out.push_str(rest);
3151    out
3152}
3153
3154/// The lock that keeps at most one run per repository actually moving the
3155/// base branch at a time: a rebase push, or `gh pr merge`.
3156///
3157/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
3158/// running a fix round in the winner's own worktree, waiting on the owner's
3159/// approval - touches nothing a *different* run in the same repository could
3160/// collide with, and holding a lock across any of that would serialise one
3161/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
3162/// resume, which is precisely the "must not wait on another task" property
3163/// the daemon's slot-freeing exists to give a resume. Only the two moments
3164/// that actually write to the shared base branch need mutual exclusion, and
3165/// both are brief.
3166///
3167/// One entry per repository, each its own `tokio::sync::Mutex`, so two
3168/// different repositories' runs never wait on each other. The outer
3169/// `std::sync::Mutex` guards only the map itself, held long enough to find or
3170/// insert an entry and clone its `Arc`, never across an `.await`.
3171fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3172    static LOCKS: std::sync::LazyLock<
3173        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3174    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3175    LOCKS
3176        .lock()
3177        .unwrap_or_else(std::sync::PoisonError::into_inner)
3178        .entry(repo.to_path_buf())
3179        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3180        .clone()
3181}
3182
3183/// `owner/repo` out of a pull request url, falling back to the checkout's
3184/// directory name when the url is not the usual `host/owner/repo/pull/N`.
3185fn repo_label(repo: &Path, pr_url: &str) -> String {
3186    let parts: Vec<&str> = pr_url.split('/').collect();
3187    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3188        && at >= 2
3189        && !parts[at - 1].is_empty()
3190        && !parts[at - 2].is_empty()
3191    {
3192        return format!("{}/{}", parts[at - 2], parts[at - 1]);
3193    }
3194    repo.file_name()
3195        .map(|n| n.to_string_lossy().into_owned())
3196        .unwrap_or_default()
3197}
3198
3199/// The operator-facing sentence for a merge that went ahead with red checks,
3200/// or `None` when the checks were not red. Judged on `checks`, not on
3201/// `failing`, which can be non-empty on a green observation.
3202fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3203    (pr.checks == Checks::Red).then(|| {
3204        let details = crate::notices::format_list(&pr.failing)
3205            .map(|names| format!(": {names}"))
3206            .unwrap_or_default();
3207        format!(
3208            "Merged {repo_name} PR #{} with red checks{details} ({})",
3209            pr.number, pr.url
3210        )
3211    })
3212}
3213
3214/// After a merge that succeeded: record which checks were red and tell the
3215/// operator. The decision to merge is already made; this only makes it audible.
3216/// Best-effort - a broken notifier never fails the run.
3217async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3218    let repo_name = repo_label(&state.repo, &pr.url);
3219    let Some(summary) = red_merge_summary(&repo_name, pr) else {
3220        return;
3221    };
3222    if let Some(rec) = state.pr.as_mut() {
3223        rec.red_at_merge = pr.failing.clone();
3224    }
3225    state.event("land", summary.clone());
3226    // The notice pages through `[notify]` itself, once, unless a question
3227    // already carries the cause.
3228    crate::notices::raise_with(
3229        crate::notices::merged_red(&state.id, &summary),
3230        &state.config.notify,
3231    );
3232}
3233
3234/// Run the loop against a real pull request until it merges or the budget runs
3235/// out.
3236///
3237/// The caller decides whether landing happens at all: this is only reached when
3238/// `graph.land` is on. Returns the last observation, so the caller can report
3239/// what magi was looking at when it stopped.
3240pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3241    land_with(state, pr_url, &GhForge).await
3242}
3243
3244/// The forge calls whose timing the loop's decisions depend on, behind a seam
3245/// so a test can script what the pull request looks like from one observation
3246/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
3247trait Forge {
3248    async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3249        let (ok, out) = gh(
3250            repo,
3251            &[
3252                "pr".into(),
3253                "close".into(),
3254                pr_url.into(),
3255                "--comment".into(),
3256                comment.into(),
3257            ],
3258        )
3259        .await?;
3260        if !ok {
3261            bail!("PR closure failed: {out}");
3262        }
3263        Ok(())
3264    }
3265    async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
3266        None
3267    }
3268    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3269    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3270    async fn poll(&self);
3271    /// Rerun the failed jobs of one workflow run.
3272    async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3273    /// Unix seconds, behind the seam so a test owns the clock.
3274    fn now(&self) -> i64;
3275    /// The check contexts the base branch requires, for a stop reason only.
3276    /// `None` when they could not be read, which is not the same as none.
3277    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3278    #[allow(clippy::too_many_arguments)]
3279    async fn fix(
3280        &self,
3281        state: &mut RunState,
3282        pr: &PrState,
3283        round: usize,
3284        budget: usize,
3285        reason: &str,
3286        logs: &str,
3287    ) -> Result<Fixed>;
3288}
3289
3290struct GhForge;
3291
3292impl Forge for GhForge {
3293    async fn attribution(&self, state: &RunState, seen: &Seen) -> Option<CiEvidence> {
3294        collect_ci_evidence(state, seen).await
3295    }
3296    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3297        observe(repo, pr_url).await
3298    }
3299    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3300        gh(repo, argv).await
3301    }
3302    async fn poll(&self) {
3303        tokio::time::sleep(POLL).await;
3304    }
3305    async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3306        let args = [
3307            "run".to_owned(),
3308            "rerun".to_owned(),
3309            run.to_owned(),
3310            "--failed".to_owned(),
3311        ];
3312        let (ok, out) = gh(repo, &args).await?;
3313        if !ok {
3314            bail!("gh run rerun {run}: {out}");
3315        }
3316        Ok(())
3317    }
3318    fn now(&self) -> i64 {
3319        jiff::Timestamp::now().as_second()
3320    }
3321    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3322        required_contexts_of(repo, base).await
3323    }
3324    async fn fix(
3325        &self,
3326        state: &mut RunState,
3327        pr: &PrState,
3328        round: usize,
3329        budget: usize,
3330        reason: &str,
3331        logs: &str,
3332    ) -> Result<Fixed> {
3333        fix_round(state, pr, round, budget, reason, logs).await
3334    }
3335}
3336
3337/// Percent-encode a branch name for a URL path segment (`/` included).
3338fn encode_path_segment(s: &str) -> String {
3339    let mut out = String::new();
3340    for b in s.bytes() {
3341        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3342            out.push(b as char);
3343        } else {
3344            let _ = write!(out, "%{b:02X}");
3345        }
3346    }
3347    out
3348}
3349
3350/// Read the required contexts of `base` from classic branch protection and
3351/// from rulesets, once, for a stop reason. Organisation-level rulesets that
3352/// these two endpoints do not list are missed. Any unreadable source makes the
3353/// whole answer `None`: a partial set would read as a complete one.
3354async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3355    let enc = encode_path_segment(base);
3356    let mut all = BTreeSet::new();
3357    // Classic protection answers 404 for an unprotected branch, which is
3358    // "nothing required here", not a failure to read.
3359    let classic = gh(
3360        repo,
3361        &[
3362            "api".to_owned(),
3363            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3364        ],
3365    )
3366    .await
3367    .ok()?;
3368    if classic.0 {
3369        all.extend(parse_classic_required(&classic.1)?);
3370    } else if !classic.1.contains("404") {
3371        return None;
3372    }
3373    let rules = gh(
3374        repo,
3375        &[
3376            "api".to_owned(),
3377            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3378        ],
3379    )
3380    .await
3381    .ok()?;
3382    if !rules.0 {
3383        return None;
3384    }
3385    all.extend(parse_ruleset_required(&rules.1)?);
3386    Some(all)
3387}
3388
3389/// `required_status_checks` of classic protection: `contexts` plus the
3390/// `checks[].context` form.
3391fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3392    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3393    let mut out = BTreeSet::new();
3394    for c in v.get("contexts")?.as_array()? {
3395        out.insert(c.as_str()?.to_owned());
3396    }
3397    for c in v
3398        .get("checks")
3399        .and_then(|c| c.as_array())
3400        .into_iter()
3401        .flatten()
3402    {
3403        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3404            out.insert(name.to_owned());
3405        }
3406    }
3407    Some(out)
3408}
3409
3410/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
3411fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3412    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3413    let mut out = BTreeSet::new();
3414    for rule in v.as_array()? {
3415        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3416            continue;
3417        }
3418        let checks = rule
3419            .pointer("/parameters/required_status_checks")?
3420            .as_array()?;
3421        for c in checks {
3422            out.insert(c.get("context")?.as_str()?.to_owned());
3423        }
3424    }
3425    Some(out)
3426}
3427
3428/// Is the observation older than the commit a fix round pushed?
3429///
3430/// The forge takes a few seconds to move the pull request to a new head and
3431/// attach that head's check runs, and until it has, the rollup is the previous
3432/// head's - all green, which is exactly what a merge decision must not read.
3433/// An absent or different head counts as not yet: guessing "close enough"
3434/// would reopen the hole.
3435fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3436    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3437}
3438
3439/// The commit an observation may be decided on, or `None` while it cannot be
3440/// trusted to describe one.
3441///
3442/// `None` when a pushed commit is awaited and the pull request is not on it,
3443/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3444/// last commit's) is not the head's: that is the previous commit's checks. The
3445/// caller re-polls on `None`. A rollup that cannot be read (including one
3446/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3447/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3448fn bound_head<'a>(
3449    seen_head: &'a str,
3450    rollup_head: &str,
3451    awaiting: Option<&str>,
3452) -> Option<&'a str> {
3453    if seen_head.is_empty()
3454        || awaiting_new_head(awaiting, seen_head)
3455        || !rollup_head.eq_ignore_ascii_case(seen_head)
3456    {
3457        return None;
3458    }
3459    Some(seen_head)
3460}
3461
3462/// What a refused `gh pr merge` means.
3463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3464enum Refused {
3465    /// The branch policy is not satisfied *yet*: go back to waiting.
3466    Pending,
3467    /// Checks have settled and the merge state still says no, seen for the
3468    /// first time. The forge updates `mergeStateStatus` a moment after the last
3469    /// check finishes, so one more look is allowed before believing it.
3470    Recheck,
3471    /// Nothing is in flight and the policy still refuses: a person has to
3472    /// supply what it asks for (a review, say).
3473    Final,
3474}
3475
3476/// Judged from the pull request's state after the refusal, never from the
3477/// refusal's wording, which belongs to the forge and changes.
3478fn classify_refusal(
3479    after: Option<&Seen>,
3480    rechecked: bool,
3481    observed_head: &str,
3482    ci: CiExpectation,
3483) -> Refused {
3484    let Some(after) = after else {
3485        // Unreadable is not evidence of anything; the next loop reads again.
3486        return Refused::Pending;
3487    };
3488    if after.pr.state != PrLifecycle::Open {
3489        return Refused::Final;
3490    }
3491    // The branch moved after it was observed (the forge refuses a merge pinned
3492    // to the old commit): not a verdict on anything, look again.
3493    if !after.head.eq_ignore_ascii_case(observed_head) {
3494        return Refused::Pending;
3495    }
3496    // The same binding the loop applies: checks of another commit say nothing.
3497    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3498        return Refused::Pending;
3499    }
3500    let state = after.merge_state.to_ascii_uppercase();
3501    if (ci == CiExpectation::Expected
3502        && matches!(after.pr.checks, Checks::Pending | Checks::Unknown))
3503        || state.is_empty()
3504        || state == "UNKNOWN"
3505    {
3506        return Refused::Pending;
3507    }
3508    if rechecked {
3509        Refused::Final
3510    } else {
3511        Refused::Recheck
3512    }
3513}
3514
3515/// Take auto-merge back from the forge and forget that it was armed.
3516///
3517/// `Err` carries the forge's message: the caller must not push or move on, as
3518/// an armed merge left behind could still fire for a commit nobody approved.
3519async fn disarm<F: Forge>(
3520    forge: &F,
3521    state: &mut RunState,
3522    repo: &Path,
3523    number: u64,
3524) -> std::result::Result<(), String> {
3525    let argv = disable_automerge_argv(number);
3526    let out = {
3527        let merge_lock = repo_merge_lock(repo);
3528        let _merge_slot = merge_lock.lock().await;
3529        forge.merge(repo, &argv).await
3530    };
3531    match out {
3532        Ok((true, _)) => {
3533            state.land_armed_head = None;
3534            state.event("land", "auto-merge disabled");
3535            state.save().map_err(|e| format!("{e:#}"))?;
3536            Ok(())
3537        }
3538        Ok((false, msg)) => Err(msg),
3539        Err(e) => Err(format!("{e:#}")),
3540    }
3541}
3542
3543/// [`stop`], first taking back an armed auto-merge so a pull request magi
3544/// gave up on does not merge on its own later. A failure to disarm is added
3545/// to the reason rather than hiding it.
3546async fn stop_disarmed<F: Forge>(
3547    forge: &F,
3548    state: &mut RunState,
3549    repo: &Path,
3550    pr: &PrState,
3551    why: &str,
3552) -> Result<()> {
3553    if state.land_armed_head.is_none() {
3554        return stop(state, repo, pr, why).await;
3555    }
3556    match disarm(forge, state, repo, pr.number).await {
3557        Ok(()) => stop(state, repo, pr, why).await,
3558        Err(e) => {
3559            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3560            stop(state, repo, pr, &why).await
3561        }
3562    }
3563}
3564
3565/// Check the persisted card again around awaits that can outlive an owner tap.
3566fn owner_requested_stop(state: &RunState) -> Result<bool> {
3567    let Some(binding) = &state.land_approval else {
3568        return Ok(false);
3569    };
3570    let q = ask::Questions::open().get(&binding.question)?;
3571    Ok(q.run == state.id
3572        && q.node == APPROVAL_NODE
3573        && q.resolution()
3574            .as_deref()
3575            .is_some_and(crate::merge_decision::stops))
3576}
3577
3578/// Read stop choices before checks, repair or rebase. These are about stopping
3579/// ownership, so a changed head never cancels them as it cancels merge consent.
3580async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3581    use crate::merge_decision as md;
3582    let Some(binding) = &state.land_approval else {
3583        return Ok(false);
3584    };
3585    let q = ask::Questions::open().get(&binding.question)?;
3586    let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3587        return Ok(false);
3588    };
3589    if q.run != state.id || q.node != APPROVAL_NODE {
3590        bail!("stop decision names another run");
3591    }
3592    let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3593        pr: pr.url.clone(),
3594        head: binding.head.clone(),
3595        language: state.config.graph.language.clone(),
3596    });
3597    if target.pr != pr.url {
3598        bail!("stop decision names another PR");
3599    }
3600    if state.merge_decision.is_none() {
3601        state.merge_decision = Some(md::Decision {
3602            question: q.id.clone(),
3603            target,
3604            choice: choice.clone(),
3605            closed: false,
3606            replacement_run: None,
3607            replacement_pr: None,
3608        });
3609        state.save()?;
3610    }
3611    let repo = state.repo.clone();
3612    // Always disable, including arms left by an older driver.
3613    if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3614        stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3615        return Ok(true);
3616    }
3617    if choice.eq_ignore_ascii_case(HOLD) {
3618        stop(
3619            state,
3620            &repo,
3621            pr,
3622            "owner held the decision: PR left open; no further repair or merge work",
3623        )
3624        .await?;
3625        return Ok(true);
3626    }
3627    if md::selected_agent(&choice).is_some()
3628        || state
3629            .origin
3630            .as_ref()
3631            .and_then(|o| o.task.as_ref())
3632            .is_some()
3633    {
3634        let owners: Vec<_> = crate::queue::Queue::open()
3635            .list()
3636            .into_iter()
3637            .filter(|task| task.runs.last() == Some(&state.id))
3638            .collect();
3639        let owner = (owners.len() == 1).then(|| &owners[0]);
3640        if !owner.is_some_and(|task| {
3641            task.runs.last() == Some(&state.id)
3642                && (!task
3643                    .operator_decided_at
3644                    .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3645                    || task.resumption.as_ref().is_some_and(|release| {
3646                        release.question_id.as_deref() == Some(q.id.as_str())
3647                            && task.operator_decided_at == Some(release.at)
3648                    }))
3649        }) {
3650            stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3651            return Ok(true);
3652        }
3653    }
3654    if pr.state == PrLifecycle::Merged {
3655        stop(
3656            state,
3657            &repo,
3658            pr,
3659            "PR already merged; withdrawal/replacement cannot be applied",
3660        )
3661        .await?;
3662        return Ok(true);
3663    }
3664    let comment = format!(
3665        "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3666        q.id, choice, state.id
3667    );
3668    if pr.state == PrLifecycle::Open {
3669        if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3670            stop(
3671                state,
3672                &repo,
3673                pr,
3674                &format!(
3675                    "owner requested closure but it failed: {e}; task held, no replacement started"
3676                ),
3677            )
3678            .await?;
3679            return Ok(true);
3680        }
3681    }
3682    let confirmed = forge.view(&repo, &pr.url).await?;
3683    if confirmed.pr.state != PrLifecycle::Closed {
3684        stop(
3685            state,
3686            &repo,
3687            pr,
3688            "PR closure was not confirmed; task held, no replacement started",
3689        )
3690        .await?;
3691        return Ok(true);
3692    }
3693    *pr = confirmed.pr;
3694    state.merge_decision.as_mut().unwrap().closed = true;
3695    if let Some(record) = &mut state.pr {
3696        record.state = "closed".into();
3697    }
3698    state.status = RunStatus::Superseded;
3699    state.merge = Some(MergeOutcome {
3700        mode: MergeMode::Pr,
3701        ok: false,
3702        detail: format!("owner decision: {choice}; PR closure confirmed"),
3703        empty: false,
3704    });
3705    state.event(
3706        "land",
3707        format!(
3708            "{} applied: PR closure confirmed; old execution retired",
3709            choice
3710        ),
3711    );
3712    state.save()?;
3713    Ok(true)
3714}
3715
3716/// Keep an unstarted verification resumable without charging a task attempt.
3717fn check_landing_resources(
3718    state: &mut RunState,
3719    node: &str,
3720    outcomes: &[crate::run::CommandOutcome],
3721) -> Result<()> {
3722    if let Some(blocked) = outcomes.iter().find(|outcome| outcome.resource_blocked) {
3723        let detail = format!("{}: {}", blocked.command, blocked.output_tail);
3724        state
3725            .resource_block
3726            .get_or_insert_with(|| crate::run::ResourceBlock {
3727                cause: crate::run::ResourceBlockCause::Other,
3728                node: node.into(),
3729                kind: "shared build cache".into(),
3730                detail: detail.clone(),
3731                at: jiff::Timestamp::now(),
3732            });
3733        state.event("resource", &detail);
3734        state.save()?;
3735        bail!("landing verification could not start: {detail}");
3736    }
3737    Ok(())
3738}
3739
3740/// Gate the local winner and publish that exact SHA, retaining the saved lease.
3741/// A push completed before a crash is recoverable only when its SHA was gated.
3742async fn publish_landing_head<F: Forge>(
3743    state: &mut RunState,
3744    observed: &str,
3745    pr_url: &str,
3746    forge: &F,
3747) -> Result<String> {
3748    crate::pr_head::ensure_owner(state)?;
3749    let winner = state.winner().cloned().context("landing has no winner")?;
3750    let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3751    let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3752    anyhow::ensure!(
3753        observed == expected
3754            || (observed == head
3755                && state.gate_head.as_deref() == Some(&head)
3756                && state.gate_allows_progress()),
3757        "public head moved outside this run; refusing to overwrite it"
3758    );
3759    anyhow::ensure!(
3760        git::is_clean(&winner.worktree).await?,
3761        "landing worktree is dirty"
3762    );
3763    if state.gate_head.as_deref() != Some(&head)
3764        || !state.gate_ran
3765        || !state.gate_commands_allow_progress()
3766    {
3767        state.reconcile_verification_head(&head);
3768        state.gate.clear();
3769        state.gate_head = None;
3770        state.gate_ran = false;
3771        state.pre_gate.clear();
3772        state.pre_gate_commit = None;
3773        state.save()?;
3774        let inherited = crate::repo_lock::WaitPolicy::current();
3775        let wait = crate::repo_lock::WaitPolicy::new(
3776            Duration::from_secs(state.config.graph.timeout_resource_wait),
3777            move || inherited.cancelled(),
3778        );
3779        let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3780        loop {
3781            if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3782                state.gate = outcomes.clone();
3783                check_landing_resources(state, "gate", &outcomes)?;
3784            }
3785            if outcomes.iter().all(|o| o.ok()) {
3786                break;
3787            }
3788            match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3789                crate::graph::GateFix::Retry => {
3790                    outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3791                }
3792                crate::graph::GateFix::Stop => break,
3793                crate::graph::GateFix::Defer => {
3794                    // The repair checks e2e immediately, before the next gate.
3795                    // Preserve a cache wait here as well as below; otherwise
3796                    // the daemon charges an attempt for unstarted verification.
3797                    if let Some(e2e) = &state.gate_repair_e2e {
3798                        let outcomes = e2e.outcomes.clone();
3799                        check_landing_resources(state, "e2e", &outcomes)?;
3800                    }
3801                    bail!("landing gate repair deferred");
3802                }
3803            }
3804        }
3805        state.gate = outcomes;
3806        state.gate_ran = true;
3807        state.save()?;
3808    }
3809    let inherited = crate::repo_lock::WaitPolicy::current();
3810    let wait = crate::repo_lock::WaitPolicy::new(
3811        Duration::from_secs(state.config.graph.timeout_resource_wait),
3812        move || inherited.cancelled(),
3813    );
3814    let checked = state
3815        .gate_head
3816        .clone()
3817        .context("landing gate has no checked SHA")?;
3818    anyhow::ensure!(
3819        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3820            && git::is_clean(&winner.worktree).await?,
3821        "landing tree changed after gate"
3822    );
3823    let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3824    if matches!(e2e, crate::graph::GateFix::Defer) {
3825        let outcomes = state
3826            .gate_repair_e2e
3827            .as_ref()
3828            .context("deferred landing e2e has no outcomes")?
3829            .outcomes
3830            .clone();
3831        check_landing_resources(state, "e2e", &outcomes)?;
3832    }
3833    anyhow::ensure!(
3834        matches!(e2e, crate::graph::GateFix::Retry),
3835        "post-repair e2e has not passed on the landing head; nothing published"
3836    );
3837    anyhow::ensure!(
3838        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3839            && git::is_clean(&winner.worktree).await?,
3840        "landing tree changed during post-repair e2e"
3841    );
3842    anyhow::ensure!(
3843        crate::graph::verify_current_head(state, &winner, &wait).await?,
3844        "current head e2e has not passed; nothing published"
3845    );
3846    anyhow::ensure!(
3847        state.gate_allows_progress(),
3848        "landing gate failed; nothing published"
3849    );
3850    crate::pr_head::ensure_owner(state)?;
3851    if owner_requested_stop(state)? {
3852        bail!("owner requested stop before publication");
3853    }
3854    let current = forge.view(&state.repo, pr_url).await?;
3855    anyhow::ensure!(
3856        current.pr.state == PrLifecycle::Open,
3857        "PR closed or merged while gating; nothing published"
3858    );
3859    anyhow::ensure!(
3860        current.head == observed || current.head == checked,
3861        "PR head changed while gating; nothing published"
3862    );
3863    anyhow::ensure!(
3864        crate::foreign_checkout::write_back(state, &checked).await?,
3865        "foreign checkout delivery is blocked; see the run events"
3866    );
3867    let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3868    let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3869    let refspec = format!("{checked}:refs/heads/{public}");
3870    let lock = repo_merge_lock(&state.repo);
3871    let _slot = lock.lock().await;
3872    let pushed = git::git_raw(
3873        &winner.worktree,
3874        &["push", &lease, &state.config.merge.remote, &refspec],
3875    )
3876    .await?;
3877    anyhow::ensure!(
3878        pushed.ok(),
3879        "publishing gated landing head failed: {}",
3880        pushed.stderr
3881    );
3882    state.pr_pushed_head = Some(checked.clone());
3883    state.land_sync_pending = false;
3884    state.save()?;
3885    Ok(checked)
3886}
3887
3888/// A scratch rebase moves the winner ref before its index and files. Recover
3889/// only a tree proven unchanged since the saved start; never discard edits.
3890async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
3891    let winner = state.winner().cloned().context("landing has no winner")?;
3892    let inherited = crate::repo_lock::WaitPolicy::current();
3893    let wait = crate::repo_lock::WaitPolicy::new(
3894        Duration::from_secs(state.config.graph.timeout_resource_wait),
3895        move || inherited.cancelled(),
3896    );
3897    let _coordination = crate::repo_lock::SeatLease::operation_with(
3898        &winner.worktree,
3899        state.config.coordination.as_ref(),
3900        &wait,
3901    )
3902    .await?;
3903    if !git::is_clean(&winner.worktree).await? {
3904        let from = state
3905            .land_rebase_from
3906            .as_deref()
3907            .context("landing worktree is dirty")?;
3908        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3909        anyhow::ensure!(head != from, "landing worktree is dirty");
3910        for args in [
3911            vec!["diff", "--cached", "--quiet", from],
3912            vec!["diff", "--quiet"],
3913        ] {
3914            anyhow::ensure!(
3915                git::git_raw(&winner.worktree, &args).await?.ok(),
3916                "landing worktree has edits beyond the interrupted rebase"
3917            );
3918        }
3919        // Preserve build artifacts and local files, including ignored ones.
3920        // read-tree can overwrite ignored paths, so reject collisions with
3921        // the new tree (including file/directory conflicts) before updating.
3922        let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
3923        let tracked = git::git(
3924            &winner.worktree,
3925            &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
3926        )
3927        .await?;
3928        for local in untracked.split('\0').filter(|p| !p.is_empty()) {
3929            anyhow::ensure!(
3930                !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
3931                    path == local
3932                        || path
3933                            .strip_prefix(local)
3934                            .is_some_and(|rest| rest.starts_with('/'))
3935                        || local
3936                            .strip_prefix(path)
3937                            .is_some_and(|rest| rest.starts_with('/'))
3938                }),
3939                "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
3940            );
3941        }
3942        git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
3943    }
3944    if let (Some(checkout), Some(from)) = (&state.foreign_checkout, &state.land_rebase_from) {
3945        anyhow::ensure!(
3946            git::is_ancestor(&winner.worktree, &checkout.synced_head, from).await,
3947            "landing synchronization started outside the foreign checkout history"
3948        );
3949        if !git::is_ancestor(&winner.worktree, from, "HEAD").await {
3950            // The rebase's tree already includes the base and the fixer's
3951            // resolutions. Retain that exact tree, but join the pre-sync
3952            // history so delivery remains fast-forward. Keep the checkpoint
3953            // until this succeeds: a crash after rebase (or after this merge)
3954            // must recover through this same idempotent path before gating.
3955            git::git(
3956                &winner.worktree,
3957                &[
3958                    "-c",
3959                    "core.hooksPath=/dev/null",
3960                    "merge",
3961                    "--strategy=ours",
3962                    "--no-ff",
3963                    "--no-autostash",
3964                    "--no-edit",
3965                    "-m",
3966                    "Merge pre-sync history into the repaired landing tree",
3967                    from,
3968                ],
3969            )
3970            .await?;
3971        }
3972    }
3973    crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
3974    state.land_rebase_from = None;
3975    state.save()?;
3976    Ok(())
3977}
3978
3979async fn rebase_landing_worktree(
3980    state: &mut RunState,
3981    onto: &str,
3982) -> Result<crate::rebase::Rebased> {
3983    restore_landing_worktree(state).await?;
3984    let winner = state.winner().cloned().context("landing has no winner")?;
3985    state.land_sync_pending = true;
3986    state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3987    state.save()?;
3988    let scratch = state.dir().join("rebase");
3989    let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
3990    if result == crate::rebase::Rebased::Applied {
3991        restore_landing_worktree(state).await?;
3992    }
3993    Ok(result)
3994}
3995
3996/// Reconcile a stopped landing before changing its branch. This also handles
3997/// old records whose graph status or merge detail no longer identifies land.
3998async fn resume_landing_head<F: Forge>(
3999    state: &mut RunState,
4000    observed: &str,
4001    pr_url: &str,
4002    forge: &F,
4003) -> Result<String> {
4004    crate::pr_head::ensure_owner(state)?;
4005    let winner = state.winner().cloned().context("landing has no winner")?;
4006    let local = git::rev_parse(&winner.worktree, "HEAD").await?;
4007    let expected = state
4008        .pr_pushed_head
4009        .as_deref()
4010        .or(state.gate_head.as_deref())
4011        .context("missing public head lease")?;
4012    anyhow::ensure!(
4013        observed == expected
4014            || (observed == local
4015                && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
4016                    || git::is_ancestor(&winner.worktree, expected, &local).await)),
4017        "public head moved outside this run; refusing to sync"
4018    );
4019    // An operator may have extended and published the winner while approval
4020    // was pending. Matching local/public history can be adopted as the lease,
4021    // but publish_landing_head must gate its new SHA before proceeding. A
4022    // remote-only or divergent update still cannot be overwritten.
4023    // Save reconciliation before rebasing again; a prior push may have reached
4024    // the remote immediately before the previous driver's last save.
4025    state.pr_pushed_head = Some(observed.to_owned());
4026    state.save()?;
4027    restore_landing_worktree(state).await?;
4028    let remote = state.config.merge.remote.clone();
4029    let base = state.base_branch.clone();
4030    let fetched = git::fetch(&state.repo, &remote, &base).await?;
4031    anyhow::ensure!(
4032        fetched.ok(),
4033        "cannot fetch landing base: {}",
4034        fetched.stderr
4035    );
4036    let onto = format!("{remote}/{base}");
4037    let tip = git::rev_parse(&state.repo, &onto).await?;
4038    if !git::is_ancestor(&state.repo, &tip, &local).await {
4039        match rebase_landing_worktree(state, &onto).await? {
4040            crate::rebase::Rebased::Applied => {}
4041            crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4042        }
4043    }
4044    publish_landing_head(state, observed, pr_url, forge).await
4045}
4046
4047async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4048    crate::pr_head::ensure_owner(state)?;
4049    state.land_unrelated = None;
4050    let reopening_hold = crate::merge_decision::resumable_hold(state);
4051    let repo = state.repo.clone();
4052    let budget = state.config.graph.land_rounds;
4053    let mut round = 0usize;
4054    // Counted apart from `round`: a rebase is not a fix, and a base that
4055    // moved is not the change's fault.
4056    let mut rebases = 0usize;
4057    let ci = state.config.merge.ci.expectation();
4058    if ci == CiExpectation::Absent {
4059        state.event(
4060            "land",
4061            "CI not expected ([merge] ci = \"none\"); check status ignored",
4062        );
4063    }
4064    let mut waited = Duration::ZERO;
4065    // Comment bodies the fixer has already been shown. A comment is
4066    // outstanding until it has been handed over once; after that it is a
4067    // recorded decision, not an open question, and re-feeding it would loop the
4068    // budget away on a comment the fixer already declined with an argument.
4069    let mut shown: BTreeSet<String> = BTreeSet::new();
4070    // Await the last successfully published head, including across resumes.
4071    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4072    // Whether a settled-checks refusal has already been given its one re-look.
4073    let mut rechecked = false;
4074
4075    // Marks the run resumable through exactly this function, not through a
4076    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
4077    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
4078    // status specifically to know a resumed run belongs back in `land`
4079    // rather than at a second `gh pr create`. Set on every entry - fresh or
4080    // resumed - because a resume that parked here again must keep reading
4081    // `Landing`, not whatever a first pass through `merge` left behind.
4082    state.status = RunStatus::Landing;
4083    state.event("land", format!("watching {pr_url}"));
4084    state.save()?;
4085
4086    // An armed merge recorded by an earlier pass may or may not have reached
4087    // the forge (the record is written before the call). It is taken back on
4088    // the first observation, where a pull request is known to stop with.
4089    let mut resumed_armed = state.land_armed_head.is_some();
4090
4091    loop {
4092        let seen = forge.view(&repo, pr_url).await?;
4093        let mut pr = seen.pr.clone();
4094        pr.review_comments.retain(|c| !shown.contains(&c.body));
4095        state.pr = Some(crate::run::PrRecord {
4096            url: pr.url.clone(),
4097            number: pr.number,
4098            state: pr.state.as_str().to_owned(),
4099            checks: pr.checks.as_str().to_owned(),
4100            round,
4101            rounds: budget,
4102            red_at_merge: Vec::new(),
4103        });
4104        state.save()?;
4105
4106        if pr.state == PrLifecycle::Open {
4107            state.observe_branch_owner_holds(&seen.head).await;
4108            state.save()?;
4109        }
4110        crate::pr_head::ensure_owner(state)?;
4111        if reopening_hold {
4112            anyhow::ensure!(
4113                pr.state == PrLifecycle::Open,
4114                "held PR is no longer open; cannot request a fresh decision"
4115            );
4116            // An explicit resume authorizes a fresh question only. Preserve the
4117            // answered card, and never enter repair/rebase/merge on this pass.
4118            disarm(forge, state, &repo, pr.number)
4119                .await
4120                .map_err(anyhow::Error::msg)?;
4121            let previous = state.merge_decision.take();
4122            let binding = state.land_approval.take();
4123            state.config.graph.land_approval = true;
4124            let subject = merge_subject(
4125                crate::graph::landing_title(state, &seen.title),
4126                &crate::graph::landing_subject_source(state),
4127            );
4128            let contested = contested_to_ask(state);
4129            if let Err(error) =
4130                approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4131            {
4132                state.merge_decision = previous;
4133                state.land_approval = binding;
4134                return Err(error);
4135            }
4136            state.end_pass_parked(crate::run::ParkCause::Question);
4137            state.parked = true;
4138            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()));
4139            state.save()?;
4140            return Ok(pr);
4141        }
4142        if owner_stop(state, &mut pr, forge).await? {
4143            return Ok(pr);
4144        }
4145
4146        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4147            // An approval already given for the same head is reused, so
4148            // nothing is asked twice. A failed disable
4149            // stops the run with the record kept: pushing on could change the
4150            // head under an arm that is still live.
4151            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4152                let why = format!(
4153                    "a previous pass may have armed auto-merge and it could not be disabled \
4154                     on resume: {e}"
4155                );
4156                stop(state, &repo, &pr, &why).await?;
4157                return Ok(pr);
4158            }
4159        }
4160
4161        // The head moved away from the one auto-merge was armed on, by
4162        // someone other than this loop. The arm is pinned and would refuse to
4163        // merge the new commit, but the approval was for the old one: take it
4164        // back and go through the normal path again.
4165        if pr.state == PrLifecycle::Open
4166            && !seen.head.is_empty()
4167            && state
4168                .land_armed_head
4169                .as_deref()
4170                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4171        {
4172            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4173                let why = format!(
4174                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
4175                );
4176                stop(state, &repo, &pr, &why).await?;
4177                return Ok(pr);
4178            }
4179        }
4180
4181        if state.land_sync_pending && pr.state == PrLifecycle::Open {
4182            // Disarming can outlive an owner answer. Consume it before our
4183            // own rebase invalidates the head shown on the decision card.
4184            if owner_stop(state, &mut pr, forge).await? {
4185                return Ok(pr);
4186            }
4187            let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4188                binding.head.eq_ignore_ascii_case(&seen.head)
4189                    && ask::Questions::open()
4190                        .get(&binding.question)
4191                        .is_ok_and(|q| {
4192                            q.run == state.id
4193                                && q.node == APPROVAL_NODE
4194                                && q.status == ask::QuestionStatus::Answered
4195                                && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4196                        })
4197            });
4198            if repair_answer {
4199                let subject = merge_subject(
4200                    crate::graph::landing_title(state, &seen.title),
4201                    &crate::graph::landing_subject_source(state),
4202                );
4203                let contested = contested_to_ask(state);
4204                if matches!(
4205                    approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4206                    ApprovalGate::Repair
4207                ) {
4208                    if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4209                        state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4210                        state.save()?;
4211                    }
4212                    return Ok(pr);
4213                }
4214            }
4215            let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4216            // A stop detected before publication must apply its durable
4217            // decision, even when verification returned an error meanwhile.
4218            if owner_stop(state, &mut pr, forge).await? {
4219                return Ok(pr);
4220            }
4221            let head = synced?;
4222            awaiting_head = Some(head);
4223            waited = Duration::ZERO;
4224            rechecked = false;
4225            // Read CI and approvals afresh, never from the pre-push observation.
4226            continue;
4227        }
4228
4229        if pr.state == PrLifecycle::Open {
4230            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4231                if waited >= WAIT_CEILING {
4232                    let want = awaiting_head.as_deref().unwrap_or_default();
4233                    let why = format!(
4234                        "the pull request's checks were still not about one readable head after \
4235                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
4236                         someone may have pushed over it",
4237                        WAIT_CEILING.as_secs() / 60,
4238                        if want.is_empty() { "any" } else { want },
4239                        if seen.head.is_empty() {
4240                            "nothing readable"
4241                        } else {
4242                            &seen.head
4243                        },
4244                        if seen.rollup_head.is_empty() {
4245                            "nothing readable"
4246                        } else {
4247                            &seen.rollup_head
4248                        },
4249                    );
4250                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
4251                    return Ok(pr);
4252                }
4253                waited += POLL;
4254                forge.poll().await;
4255                continue;
4256            }
4257            // Reset once, when the awaited head first shows up; resetting on
4258            // every re-observation would let a standing refusal wait forever.
4259            if awaiting_head.take().is_some() {
4260                // The checks now being read belong to the new head; give them
4261                // the same grace a fresh pull request gets.
4262                waited = Duration::ZERO;
4263            }
4264        }
4265
4266        // The merge-state grace is head-bound and durable. Reruns reset the
4267        // checks clock, but must not extend this deadline across resumes.
4268        let decision_wait = if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid {
4269            let first = state
4270                .land_unsaid
4271                .get_or_insert_with(|| (seen.head.clone(), forge.now()));
4272            if !first.0.eq_ignore_ascii_case(&seen.head) {
4273                *first = (seen.head.clone(), forge.now());
4274            }
4275            let elapsed = Duration::from_secs(forge.now().saturating_sub(first.1).max(0) as u64);
4276            state.save()?;
4277            elapsed
4278        } else {
4279            waited
4280        };
4281        let proposed = decide_with(&pr, round, budget, decision_wait, ci);
4282        if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid && proposed == Step::Wait {
4283            if waited >= WAIT_CEILING {
4284                stop_disarmed(
4285                    forge,
4286                    state,
4287                    &repo,
4288                    &pr,
4289                    "merge state remained unreadable; refusing to merge on a guess",
4290                )
4291                .await?;
4292                return Ok(pr);
4293            }
4294            waited += POLL;
4295            forge.poll().await;
4296            continue;
4297        }
4298
4299        // A failed workflow run is rerun once, judged by its own run rather
4300        // than by the rollup's aggregate: another run still pending, or a fix
4301        // budget already spent, must not keep a flaky failure from its one
4302        // rerun. It spends no round and no attempt.
4303        if ci == CiExpectation::Expected
4304            && pr.state == PrLifecycle::Open
4305            && state.config.graph.rerun_flaky_checks
4306        {
4307            let include_optional = pr.checks == Checks::Red
4308                && matches!(proposed, Step::Fix { .. } | Step::GiveUp { .. });
4309            let failing = rerunnable_with(&seen.contexts, include_optional);
4310            let now = forge.now();
4311            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4312                RerunStep::Rerun(runs) => {
4313                    let rec = state.land_reruns.get_or_insert_with(Default::default);
4314                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
4315                        *rec = LandReruns {
4316                            head: seen.head.clone(),
4317                            runs: BTreeMap::new(),
4318                        };
4319                    }
4320                    for run in &runs {
4321                        rec.runs.insert(run.clone(), now);
4322                    }
4323                    // Durable before the call: a restart must not
4324                    // rerun the same run twice. A save that fails
4325                    // means no rerun.
4326                    state.save()?;
4327                    let mut any = false;
4328                    for run in &runs {
4329                        let names = failing
4330                            .iter()
4331                            .filter(|(_, r)| r.as_deref() == Some(run))
4332                            .map(|(l, _)| l.as_str())
4333                            .collect::<Vec<_>>()
4334                            .join(", ");
4335                        match forge.rerun(&repo, run).await {
4336                            Ok(()) => {
4337                                any = true;
4338                                state.event(
4339                                    "land",
4340                                    format!("reran failed check(s) {names} (run {run})"),
4341                                );
4342                            }
4343                            // Stays recorded: never a retry loop.
4344                            Err(e) => state.event(
4345                                "land",
4346                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4347                            ),
4348                        }
4349                    }
4350                    state.save()?;
4351                    if any {
4352                        waited = Duration::ZERO;
4353                        forge.poll().await;
4354                        continue;
4355                    }
4356                }
4357                RerunStep::Grace => {
4358                    if waited >= WAIT_CEILING {
4359                        let why = format!(
4360                            "checks were still failing after a rerun and {} minutes",
4361                            WAIT_CEILING.as_secs() / 60
4362                        );
4363                        stop(state, &repo, &pr, &why).await?;
4364                        return Ok(pr);
4365                    }
4366                    waited += POLL;
4367                    forge.poll().await;
4368                    continue;
4369                }
4370                RerunStep::Fix => {}
4371            }
4372        }
4373
4374        let step = proposed;
4375        // Armed on exactly this head: the forge is doing the waiting. A
4376        // decision to merge (or keep waiting) is only watched, never re-armed
4377        // and never re-approved; anything else - a red check, a comment, a
4378        // conflict - falls through to its own arm, which disarms first.
4379        let armed_here = state
4380            .land_armed_head
4381            .as_deref()
4382            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4383        if armed_here && matches!(step, Step::Merge | Step::Wait) {
4384            if waited >= WAIT_CEILING {
4385                let required = if seen.base.is_empty() {
4386                    None
4387                } else {
4388                    forge.required_contexts(&repo, &seen.base).await
4389                };
4390                let why = format!(
4391                    "auto-merge was armed on {} but the pull request did not merge within {} \
4392                     minutes ({})",
4393                    seen.head,
4394                    WAIT_CEILING.as_secs() / 60,
4395                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4396                );
4397                stop_disarmed(forge, state, &repo, &pr, &why).await?;
4398                return Ok(pr);
4399            }
4400            waited += POLL;
4401            forge.poll().await;
4402            continue;
4403        }
4404        if armed_here && !matches!(step, Step::Done { .. }) {
4405            // Not merging or waiting any more: whatever is next may change the
4406            // head or give up, and neither may leave the arm standing.
4407            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4408                let why = format!("auto-merge could not be disabled: {e}");
4409                stop(state, &repo, &pr, &why).await?;
4410                return Ok(pr);
4411            }
4412        }
4413        if pr.checks == Checks::Red
4414            && matches!(step, Step::Fix { .. } | Step::GiveUp { .. })
4415            && pr.review_comments.is_empty()
4416            && let Some(evidence) = forge.attribution(state, &seen).await
4417            && evidence.unrelated(&seen.head, &pr.failing)
4418        {
4419            if owner_stop(state, &mut pr, forge).await? {
4420                return Ok(pr);
4421            }
4422            // Attribution does not authorize action against an unseen push.
4423            let fresh = forge.view(&repo, pr_url).await?;
4424            if fresh.head != seen.head
4425                || fresh.rollup_head != seen.rollup_head
4426                || fresh.pr.state != PrLifecycle::Open
4427                || fresh.pr.checks != Checks::Red
4428                || fresh.pr.failing != pr.failing
4429                || fresh
4430                    .pr
4431                    .review_comments
4432                    .iter()
4433                    .any(|c| !shown.contains(&c.body))
4434            {
4435                continue;
4436            }
4437            hold_unrelated(state, &pr, evidence).await?;
4438            return Ok(pr);
4439        }
4440        match step {
4441            Step::Wait => {
4442                if waited >= WAIT_CEILING {
4443                    let why = format!(
4444                        "checks were still running after {} minutes",
4445                        WAIT_CEILING.as_secs() / 60
4446                    );
4447                    stop(state, &repo, &pr, &why).await?;
4448                    return Ok(pr);
4449                }
4450                waited += POLL;
4451                forge.poll().await;
4452            }
4453            Step::Done { merged } => {
4454                if merged {
4455                    state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4456                }
4457                // Auto-merge is pinned to the approved head, but the forge's
4458                // own handling of a later push is not something magi can
4459                // see: if the pull request merged on another commit, say so
4460                // loudly rather than record a clean landing.
4461                if let Some(armed) = state.land_armed_head.take() {
4462                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4463                        let msg = format!(
4464                            "{} merged on {} but the owner approved {armed}; review what landed",
4465                            pr.url, seen.head
4466                        );
4467                        tracing::warn!("{msg}");
4468                        state.event("land", msg);
4469                        // Only an arm left by an older build can get here.
4470                        // The wording is fixed so a repeat does not relight
4471                        // the notice.
4472                        crate::notices::raise_with(
4473                            crate::notices::Notice::warn(
4474                                &format!("merged-unapproved-head:{}", state.id),
4475                                "A pull request merged on a commit the owner did not approve; \
4476                                 review what landed",
4477                            )
4478                            .link(crate::notices::Link::Run {
4479                                id: state.id.clone(),
4480                            }),
4481                            &state.config.notify,
4482                        );
4483                    }
4484                }
4485                state.status = if merged {
4486                    RunStatus::Merged
4487                } else {
4488                    RunStatus::Ready
4489                };
4490                let detail = if merged {
4491                    format!("{} was merged", pr.url)
4492                } else {
4493                    format!("{} was closed without merging", pr.url)
4494                };
4495                state.merge = Some(MergeOutcome {
4496                    mode: MergeMode::Pr,
4497                    ok: merged,
4498                    detail: detail.clone(),
4499                    empty: false,
4500                });
4501                state.event("land", detail);
4502                state.save()?;
4503                write_pr_state_through(state, pr.state);
4504                return Ok(pr);
4505            }
4506            Step::Merge => {
4507                state.check_delivery_evidence(&seen.head)?;
4508                let subject = merge_subject(
4509                    crate::graph::landing_title(state, &seen.title),
4510                    &crate::graph::landing_subject_source(state),
4511                );
4512                // The owner sees the panel before the one irreversible step,
4513                // and an unanswered question is a hold: silence never merges.
4514                //
4515                // The dedicated policy includes existing consent and post-repair
4516                // decisions; repair choice availability never grants merge authority.
4517                let contested = contested_to_ask(state);
4518                if !approval_reasons(state).is_empty() {
4519                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4520                        .await?
4521                    {
4522                        ApprovalGate::Approved => {}
4523                        ApprovalGate::Repair => {
4524                            // The driver consumes the saved request under the existing
4525                            // run/task claim. No merge, no second run or task.
4526                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4527                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4528                                state.save()?;
4529                            }
4530                            return Ok(pr);
4531                        }
4532                        ApprovalGate::Held => {
4533                            // Another entry point may have saved a stop after
4534                            // the first read above. Apply its durable action.
4535                            if owner_stop(state, &mut pr, forge).await? {
4536                                return Ok(pr);
4537                            }
4538                            stop(
4539                                state,
4540                                &repo,
4541                                &pr,
4542                                "the owner did not approve the merge (held or unanswered)",
4543                            )
4544                            .await?;
4545                            return Ok(pr);
4546                        }
4547                        // Filed (or still standing from an earlier visit) and
4548                        // not yet answered. Park here rather than wait: the
4549                        // question survives on disk, the daemon hands this
4550                        // run's slot to something else, and a later resume
4551                        // re-enters `land`, finds the same question, and
4552                        // either merges or stops depending on what it says
4553                        // by then.
4554                        ApprovalGate::Pending => {
4555                            state.end_pass_parked(crate::run::ParkCause::Question);
4556                            state.parked = true;
4557                            state.event(
4558                                "land",
4559                                "parked awaiting merge approval - resumes once answered",
4560                            );
4561                            state.save()?;
4562                            return Ok(pr);
4563                        }
4564                    }
4565                }
4566                // Open and past the gate above, so `seen.head` is the commit
4567                // the checks were bound to and the owner approved.
4568                //
4569                // Merge directly, bound to that commit. Auto-merge is not
4570                // armed any more: the forge checks `--match-head-commit` only
4571                // when the request is made, so an arm outlives the head it
4572                // was made for, and a push of another commit whose
4573                // requirements are met first would merge without approval.
4574                // A direct merge is checked by the forge at the moment it
4575                // happens, so only the approved head can land.
4576                let observed_head = seen.head.clone();
4577                // Read the pull request again just before: the state seen
4578                // above can be a moment old.
4579                {
4580                    let fresh = forge.view(&repo, pr_url).await.ok();
4581                    if !direct_merge_is_safe(
4582                        fresh.as_ref(),
4583                        &observed_head,
4584                        &shown,
4585                        round,
4586                        budget,
4587                        waited,
4588                        ci,
4589                    ) {
4590                        if waited >= WAIT_CEILING {
4591                            let why = "the pull request did not settle on the approved head \
4592                                       before it could be merged";
4593                            stop(state, &repo, &pr, why).await?;
4594                            return Ok(pr);
4595                        }
4596                        state.event(
4597                            "land",
4598                            "the pull request changed before merging; looking again",
4599                        );
4600                        waited += POLL;
4601                        forge.poll().await;
4602                        continue;
4603                    }
4604                }
4605                let argv = merge_argv_at(pr.number, &subject, &observed_head);
4606                let out = {
4607                    let merge_lock = repo_merge_lock(&repo);
4608                    let _merge_slot = merge_lock.lock().await;
4609                    crate::pr_head::ensure_owner(state)?;
4610                    if let Some(task_id) = state
4611                        .origin
4612                        .as_ref()
4613                        .and_then(|origin| origin.task.as_ref())
4614                    {
4615                        let task = crate::queue::Queue::open()
4616                            .get(task_id)
4617                            .context("cannot verify the task owner immediately before merge")?;
4618                        if task.runs.last() != Some(&state.id)
4619                            || task.operator_held()
4620                            || task.status == crate::queue::TaskStatus::Done
4621                        {
4622                            bail!(
4623                                "task ownership or an operator hold superseded this merge approval"
4624                            );
4625                        }
4626                    }
4627                    if let Some(binding) = &state.land_approval {
4628                        let current = ask::Questions::open().get(&binding.question)?;
4629                        if merge_consent_superseded(state, &current)?
4630                            || current.run != state.id
4631                            || !binding.head.eq_ignore_ascii_case(&observed_head)
4632                            || approval(current.resolution().as_deref()) != Approval::Merge
4633                            || current.merge_target.as_ref().is_some_and(|t| {
4634                                t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4635                            })
4636                        {
4637                            bail!(
4638                                "merge decision is stale or no longer authorizes the reviewed head"
4639                            );
4640                        }
4641                    }
4642                    forge.merge(&repo, &argv).await?
4643                };
4644                if out.0 {
4645                    // Exit 0 is not proof of a merge: with a merge queue `gh`
4646                    // enqueues (or reports the pull request already queued)
4647                    // and succeeds while it is still open. Ask the forge; an
4648                    // unreadable answer is not a confirmation either, so it
4649                    // is looked at again rather than recorded as merged.
4650                    let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4651                        if c.pr.state == PrLifecycle::Merged {
4652                            state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4653                            true
4654                        } else {
4655                            false
4656                        }
4657                    });
4658                    if !confirmed {
4659                        if waited >= WAIT_CEILING {
4660                            let why = "the merge request succeeded but the pull request \
4661                                       could not be confirmed merged after waiting";
4662                            stop(state, &repo, &pr, why).await?;
4663                            return Ok(pr);
4664                        }
4665                        state.event(
4666                            "land",
4667                            "merge accepted but the pull request is not confirmed merged yet; waiting",
4668                        );
4669                        state.save()?;
4670                        waited += POLL;
4671                        forge.poll().await;
4672                        continue;
4673                    }
4674                    pr.state = PrLifecycle::Merged;
4675                    state.status = RunStatus::Merged;
4676                    state.merge = Some(MergeOutcome {
4677                        mode: MergeMode::Pr,
4678                        ok: true,
4679                        detail: format!("gh {}", argv.join(" ")),
4680                        empty: false,
4681                    });
4682                    // The last `state.pr` snapshot is whatever the poll before
4683                    // this merge observed - still `open` - and nothing below
4684                    // refreshes it from GitHub again, so the UI's round rail
4685                    // would otherwise keep animating a merged run forever.
4686                    if let Some(pr_record) = state.pr.as_mut() {
4687                        pr_record.state = pr.state.as_str().to_owned();
4688                    }
4689                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4690                    announce_red_merge(state, &pr).await;
4691                    state.save()?;
4692                    write_pr_state_through(state, pr.state);
4693                    return Ok(pr);
4694                }
4695                let after_seen = forge.view(&repo, pr_url).await.ok();
4696                let after = after_seen.as_ref().map(|s| s.pr.state);
4697                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4698                    state.pr_merged_head = after_seen
4699                        .as_ref()
4700                        .map(|s| s.head.clone())
4701                        .filter(|h| !h.is_empty());
4702                    pr.state = PrLifecycle::Merged;
4703                    state.status = RunStatus::Merged;
4704                    state.merge = Some(outcome);
4705                    if let Some(pr_record) = state.pr.as_mut() {
4706                        pr_record.state = pr.state.as_str().to_owned();
4707                    }
4708                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4709                    announce_red_merge(state, &pr).await;
4710                    state.save()?;
4711                    write_pr_state_through(state, pr.state);
4712                    return Ok(pr);
4713                }
4714                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head, ci);
4715                match verdict {
4716                    Refused::Final => {
4717                        let merge_state = after_seen
4718                            .as_ref()
4719                            .map(|s| s.merge_state.as_str())
4720                            .filter(|m| !m.is_empty())
4721                            .unwrap_or("unknown");
4722                        // Which refusal this was decides what a person has to
4723                        // do about it, so the two are worded apart; both carry
4724                        // the forge's own message.
4725                        let why = format!(
4726                            "the merge was refused: {} (merge state: {merge_state})",
4727                            out.1
4728                        );
4729                        stop(state, &repo, &pr, &why).await?;
4730                        return Ok(pr);
4731                    }
4732                    verdict => {
4733                        // Back to the top, which re-decides and passes the
4734                        // approval gate again, a poll later. `waited` is not
4735                        // reset here: only a pushed fix restarts it, or a
4736                        // standing refusal would wait forever.
4737                        if waited >= WAIT_CEILING {
4738                            let why = format!(
4739                                "the merge was still refused after {} minutes: {}",
4740                                WAIT_CEILING.as_secs() / 60,
4741                                out.1
4742                            );
4743                            stop(state, &repo, &pr, &why).await?;
4744                            return Ok(pr);
4745                        }
4746                        if verdict == Refused::Recheck {
4747                            rechecked = true;
4748                        }
4749                        state.event(
4750                            "land",
4751                            "merge refused while the branch policy is not satisfied yet; waiting",
4752                        );
4753                        state.save()?;
4754                        waited += POLL;
4755                        forge.poll().await;
4756                    }
4757                }
4758            }
4759            Step::Rebase => {
4760                // Bounded by the same budget as a fix, because a rebase that
4761                // keeps being needed means the base moves faster than this
4762                // run can land and a person should decide what to do. It
4763                // spends none of that budget: the change is not what is
4764                // wrong.
4765                if rebases >= budget {
4766                    let why = format!(
4767                        "the base moved under this branch {budget} time(s) and it still does \
4768                         not merge; rebasing again would only race it"
4769                    );
4770                    stop(state, &repo, &pr, &why).await?;
4771                    return Ok(pr);
4772                }
4773                rebases += 1;
4774                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4775                    stop(
4776                        state,
4777                        &repo,
4778                        &pr,
4779                        "the pull request conflicts and this run has no winning branch to rebase",
4780                    )
4781                    .await?;
4782                    return Ok(pr);
4783                };
4784                let base = state.base_branch.clone();
4785                state.event(
4786                    "land",
4787                    format!("{} no longer merges; rebasing onto {base}", pr.url),
4788                );
4789                state.save()?;
4790
4791                // Onto the base as the *remote* has it: the local ref may be
4792                // behind, and rebasing onto a stale base produces a branch
4793                // that conflicts all over again.
4794                let remote = state.config.merge.remote.clone();
4795                git::fetch(&repo, &remote, &base).await.ok();
4796                if owner_stop(state, &mut pr, forge).await? {
4797                    return Ok(pr);
4798                }
4799                let onto = format!("{remote}/{base}");
4800                let rebased = match rebase_landing_worktree(state, &onto).await {
4801                    Ok(crate::rebase::Rebased::Applied) => Ok(None),
4802                    Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4803                    Err(e) => Err(e),
4804                };
4805                if owner_stop(state, &mut pr, forge).await? {
4806                    return Ok(pr);
4807                }
4808                match rebased {
4809                    Ok(None) => {
4810                        let published =
4811                            publish_landing_head(state, &seen.head, pr_url, forge).await;
4812                        if owner_stop(state, &mut pr, forge).await? {
4813                            return Ok(pr);
4814                        }
4815                        let head = published?;
4816                        crate::graph::refresh_reviewed_commits(state, &branch).await;
4817                        state.pr_pushed_head = Some(head.clone());
4818                        awaiting_head = Some(head);
4819                        rechecked = false;
4820                        state.event("land", format!("rebased {branch} onto {base}"));
4821                        state.save()?;
4822                        // The forge has to re-run its checks against the
4823                        // rebased head before anything else can be decided.
4824                        waited = Duration::ZERO;
4825                        tokio::time::sleep(POLL).await;
4826                    }
4827                    // The fixer's rounds are spent (or it could not finish):
4828                    // that is a decision for a person.
4829                    Ok(Some(conflict)) => {
4830                        let why = format!(
4831                            "{} conflicts with {base} and the rebase did not apply: {}",
4832                            pr.url,
4833                            conflict.chars().take(600).collect::<String>()
4834                        );
4835                        stop(state, &repo, &pr, &why).await?;
4836                        return Ok(pr);
4837                    }
4838                    Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4839                    Err(e) => {
4840                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
4841                        stop(state, &repo, &pr, &why).await?;
4842                        return Ok(pr);
4843                    }
4844                }
4845            }
4846            Step::GiveUp { reason } => {
4847                stop(state, &repo, &pr, &reason).await?;
4848                return Ok(pr);
4849            }
4850            Step::Fix { reason } => {
4851                round += 1;
4852                waited = Duration::ZERO;
4853                for c in &pr.review_comments {
4854                    shown.insert(c.body.clone());
4855                }
4856                state.event("land", format!("round {round}: {reason}"));
4857                state.save()?;
4858
4859                let logs = failing_logs(&repo, &seen.failing_urls).await;
4860                let was_red = pr.checks == Checks::Red;
4861                if owner_stop(state, &mut pr, forge).await? {
4862                    return Ok(pr);
4863                }
4864                let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4865                if owner_stop(state, &mut pr, forge).await? {
4866                    return Ok(pr);
4867                }
4868                match fixed {
4869                    Fixed::Committed { head } => {
4870                        // Whatever the next look shows may still be the
4871                        // previous head; see `awaiting_new_head`.
4872                        state.pr_pushed_head = Some(head.clone());
4873                        awaiting_head = Some(head);
4874                        rechecked = false;
4875                        waited = Duration::ZERO;
4876                        forge.poll().await;
4877                    }
4878                    Fixed::Declined if was_red => {
4879                        // Review comments may have required this round even
4880                        // when CI was unrelated. Once the fixer has read them,
4881                        // use the same structural attribution before charging
4882                        // a no-commit stop to the task.
4883                        if let Some(evidence) = forge.attribution(state, &seen).await
4884                            && evidence.unrelated(&seen.head, &pr.failing)
4885                        {
4886                            let fresh = forge.view(&repo, pr_url).await?;
4887                            if fresh.head != seen.head
4888                                || fresh.rollup_head != seen.rollup_head
4889                                || fresh.pr.state != PrLifecycle::Open
4890                                || fresh.pr.checks != Checks::Red
4891                                || fresh.pr.failing != pr.failing
4892                                || fresh
4893                                    .pr
4894                                    .review_comments
4895                                    .iter()
4896                                    .any(|c| !shown.contains(&c.body))
4897                            {
4898                                continue;
4899                            }
4900                            if owner_stop(state, &mut pr, forge).await? {
4901                                return Ok(pr);
4902                            }
4903                            hold_unrelated(state, &pr, evidence).await?;
4904                            return Ok(pr);
4905                        }
4906                        let why = format!(
4907                            "the fixer produced no commit while {} check(s) were failing \
4908                             ({}); stopping instead of looping on an unchanged tree",
4909                            pr.failing.len(),
4910                            pr.failing.join(", ")
4911                        );
4912                        stop(state, &repo, &pr, &why).await?;
4913                        return Ok(pr);
4914                    }
4915                    // Comment-driven round with no commit: the fixer read the
4916                    // comments and changed nothing, which is a decision it is
4917                    // allowed to make. The comments are recorded as shown, so
4918                    // the next observation sees a clean pull request.
4919                    Fixed::Declined => state.event(
4920                        "land",
4921                        format!("round {round}: fixer declined the comments, nothing committed"),
4922                    ),
4923                    Fixed::Deferred => {
4924                        // No fixer was available to judge these comments or
4925                        // checks. Keep the previous completed-round count and
4926                        // return without handing the PR off as a task failure.
4927                        state.status = RunStatus::Blocked;
4928                        state.save()?;
4929                        return Ok(pr);
4930                    }
4931                    Fixed::Failed(why) => {
4932                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4933                        return Ok(pr);
4934                    }
4935                }
4936                state.save()?;
4937            }
4938        }
4939    }
4940}
4941
4942/// Owner decision for a CI failure independently reproduced on the base.
4943pub const CI_HOLD_NODE: &str = "land-ci-unrelated";
4944
4945/// Durable machine-hold evidence, bound to one observed PR head.
4946#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4947pub struct CiHold {
4948    /// Head that exhibited the independently reproduced failures.
4949    pub head: String,
4950    /// Typed owner question controlling resumption.
4951    pub question: String,
4952    /// Complete structured facts used to attribute the failures.
4953    pub evidence: CiEvidence,
4954}
4955
4956/// Only structured forge annotations are compared, never log prose.
4957#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
4958struct CiFailure {
4959    path: String,
4960    start_line: u64,
4961    end_line: u64,
4962    title: String,
4963    message: String,
4964}
4965
4966#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4967struct CiCheckEvidence {
4968    label: String,
4969    head_check: u64,
4970    base_check: u64,
4971    app: u64,
4972    run: String,
4973    base_run: String,
4974    head_failures: Vec<CiFailure>,
4975    base_failures: Vec<CiFailure>,
4976    rerun_exhausted: bool,
4977}
4978
4979/// Pinned Git diff and forge failure annotations used for CI attribution.
4980#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4981pub struct CiEvidence {
4982    head: String,
4983    base: String,
4984    changed: Vec<String>,
4985    checks: Vec<CiCheckEvidence>,
4986}
4987
4988impl CiEvidence {
4989    /// Every red check must have the same located failure on the base, outside
4990    /// the diff. Changes to CI/build configuration invalidate this inference.
4991    fn unrelated(&self, head: &str, failing: &[String]) -> bool {
4992        if self.head != head
4993            || self.base.is_empty()
4994            || failing.is_empty()
4995            || self.changed.iter().any(|p| {
4996                p.starts_with(".github/")
4997                    || p.starts_with(".config/")
4998                    || p.starts_with(".cargo/")
4999                    || matches!(
5000                        p.rsplit('/').next().unwrap_or(p),
5001                        "Cargo.toml"
5002                            | "Cargo.lock"
5003                            | "Makefile.toml"
5004                            | "build.rs"
5005                            | "rust-toolchain.toml"
5006                    )
5007            })
5008        {
5009            return false;
5010        }
5011        failing.iter().all(|label| {
5012            let matches: Vec<_> = self.checks.iter().filter(|c| &c.label == label).collect();
5013            matches.len() == 1
5014                && matches[0].rerun_exhausted
5015                && !matches[0].head_failures.is_empty()
5016                && matches[0].head_failures.iter().all(|f| {
5017                    // Workflow-level exit-code annotations do not identify a
5018                    // failing test or source file, and prove nothing here.
5019                    !f.path.is_empty()
5020                        && !f.path.starts_with('.')
5021                        && !f.path.contains("..")
5022                        && !f.path.starts_with('/')
5023                        && !self.changed.contains(&f.path)
5024                        && matches[0].base_failures.contains(f)
5025                })
5026        })
5027    }
5028}
5029
5030async fn ci_json(repo: &Path, endpoint: String) -> Option<serde_json::Value> {
5031    let (ok, body) = gh(repo, &["api".into(), endpoint]).await.ok()?;
5032    if !ok {
5033        return None;
5034    }
5035    serde_json::from_str(&body).ok()
5036}
5037
5038async fn ci_annotations(repo: &Path, check: &serde_json::Value) -> Option<Vec<CiFailure>> {
5039    let count = check.pointer("/output/annotations_count")?.as_u64()?;
5040    if count == 0 || count >= 100 {
5041        return None;
5042    }
5043    let id = check.get("id")?.as_u64()?;
5044    let value = ci_json(
5045        repo,
5046        format!("repos/{{owner}}/{{repo}}/check-runs/{id}/annotations?per_page=100"),
5047    )
5048    .await?;
5049    let annotations = value.as_array()?;
5050    if annotations.len() != count as usize {
5051        return None;
5052    }
5053    annotations
5054        .iter()
5055        .filter(|a| a["annotation_level"] == "failure")
5056        .map(|a| {
5057            Some(CiFailure {
5058                path: a["path"].as_str()?.into(),
5059                start_line: a["start_line"].as_u64()?,
5060                end_line: a["end_line"].as_u64()?,
5061                title: a["title"].as_str()?.into(),
5062                message: a["message"].as_str()?.into(),
5063            })
5064        })
5065        .collect()
5066}
5067
5068async fn ci_runs(repo: &Path, sha: &str) -> Option<Vec<serde_json::Value>> {
5069    let value = ci_json(
5070        repo,
5071        format!("repos/{{owner}}/{{repo}}/commits/{sha}/check-runs?per_page=100&filter=latest"),
5072    )
5073    .await?;
5074    let runs = value["check_runs"].as_array()?;
5075    if value["total_count"].as_u64()? != runs.len() as u64 || runs.len() >= 100 {
5076        return None;
5077    }
5078    Some(runs.clone())
5079}
5080
5081async fn collect_ci_evidence(state: &RunState, seen: &Seen) -> Option<CiEvidence> {
5082    let repo = &state.repo;
5083    let base_ref = format!("refs/remotes/{}/{}", state.config.merge.remote, seen.base);
5084    let base = git::git_raw(repo, &["rev-parse", "--verify", &base_ref])
5085        .await
5086        .ok()?;
5087    if !base.ok() {
5088        return None;
5089    }
5090    let range = format!("{}...{}", base.stdout, seen.head);
5091    // No rename detection: both old and new paths must count as changed.
5092    let diff = git::git_raw(
5093        repo,
5094        &["diff", "--no-renames", "--name-only", "-z", &range, "--"],
5095    )
5096    .await
5097    .ok()?;
5098    if !diff.ok() {
5099        return None;
5100    }
5101    let head_runs = ci_runs(repo, &seen.head).await?;
5102    let base_runs = ci_runs(repo, &base.stdout).await?;
5103    let mut checks = Vec::new();
5104    for label in &seen.pr.failing {
5105        let matching = |runs: &[serde_json::Value]| {
5106            runs.iter()
5107                .filter(|r| r["name"].as_str() == Some(label))
5108                .cloned()
5109                .collect::<Vec<_>>()
5110        };
5111        let heads = matching(&head_runs);
5112        let bases = matching(&base_runs);
5113        if heads.len() != 1 || bases.len() != 1 {
5114            return None;
5115        }
5116        let h = &heads[0];
5117        let b = &bases[0];
5118        if h["conclusion"] != "failure"
5119            || b["conclusion"] != "failure"
5120            || h["status"] != "completed"
5121            || b["status"] != "completed"
5122            || h["head_sha"].as_str()? != seen.head
5123            || b["head_sha"].as_str()? != base.stdout
5124            || h.pointer("/app/id")?.as_u64()? != b.pointer("/app/id")?.as_u64()?
5125        {
5126            return None;
5127        }
5128        let run = run_of(h["details_url"].as_str()?)?;
5129        let contexts: Vec<_> = seen.contexts.iter().filter(|c| &c.label == label).collect();
5130        if contexts.len() != 1
5131            || contexts[0].run.as_ref() != Some(&run)
5132            || contexts[0].verdict != Verdict::Fail
5133        {
5134            return None;
5135        }
5136        // A persisted request alone is not a completed rerun: require the
5137        // workflow's structured attempt number as well.
5138        let workflow =
5139            ci_json(repo, format!("repos/{{owner}}/{{repo}}/actions/runs/{run}")).await?;
5140        let base_run = run_of(b["details_url"].as_str()?)?;
5141        let base_workflow = ci_json(
5142            repo,
5143            format!("repos/{{owner}}/{{repo}}/actions/runs/{base_run}"),
5144        )
5145        .await?;
5146        if base_workflow["head_sha"].as_str()? != base.stdout
5147            || base_workflow["head_branch"].as_str()? != seen.base
5148            || base_workflow["status"] != "completed"
5149            || base_workflow["conclusion"] != "failure"
5150            || base_workflow["workflow_id"].as_u64()? != workflow["workflow_id"].as_u64()?
5151        {
5152            return None;
5153        }
5154        let exhausted = state
5155            .land_reruns
5156            .as_ref()
5157            .is_some_and(|r| r.head == seen.head && r.runs.contains_key(&run))
5158            && workflow["head_sha"].as_str()? == seen.head
5159            && workflow["run_attempt"].as_u64()? > 1
5160            && workflow["status"] == "completed";
5161        checks.push(CiCheckEvidence {
5162            label: label.clone(),
5163            head_check: h["id"].as_u64()?,
5164            base_check: b["id"].as_u64()?,
5165            app: h.pointer("/app/id")?.as_u64()?,
5166            run,
5167            base_run,
5168            head_failures: ci_annotations(repo, h).await?,
5169            base_failures: ci_annotations(repo, b).await?,
5170            rerun_exhausted: exhausted,
5171        });
5172    }
5173    Some(CiEvidence {
5174        head: seen.head.clone(),
5175        base: base.stdout,
5176        changed: diff
5177            .stdout
5178            .split('\0')
5179            .filter(|p| !p.is_empty())
5180            .map(str::to_owned)
5181            .collect(),
5182        checks,
5183    })
5184}
5185
5186async fn hold_unrelated(state: &mut RunState, pr: &PrState, evidence: CiEvidence) -> Result<()> {
5187    let detail = serde_json::to_string(&evidence)?;
5188    let resume = "daemon: CI を確認して同じ run を再開する".to_owned();
5189    let hold = "operator: 保留して後で判断する".to_owned();
5190    let mut q = ask::Question::new(
5191        state.id.clone(),
5192        CI_HOLD_NODE.into(),
5193        "land".into(),
5194        "base でも再現する CI の失敗を検出したため、試行を消費せず保留しました".into(),
5195        format!(
5196            "必須チェックや承認は省略しません。再開時にも同じ workflow run は自動再実行しません。\n構造化された判定根拠:\n{detail}"
5197        ),
5198        vec![resume.clone(), hold.clone()],
5199    );
5200    q.actions.insert(
5201        resume,
5202        ask::ChoiceAction::Resume {
5203            run: state.id.clone(),
5204        },
5205    );
5206    q.actions.insert(hold, ask::ChoiceAction::Hold);
5207    ask::Questions::open().put_executable(&mut q)?;
5208    state.land_unrelated = Some(CiHold {
5209        head: evidence.head.clone(),
5210        question: q.id.clone(),
5211        evidence,
5212    });
5213    state.event("land", format!("[ci-unrelated] base reproduces every located failure after rerun; owner question {}: {detail}", q.id));
5214    state.end_pass_parked(crate::run::ParkCause::Question);
5215    state.parked = true;
5216    state.save()?;
5217    if let Err(e) = ask::notify(&state.config.notify, &q).await {
5218        tracing::warn!("could not notify about CI hold: {e:#}");
5219    }
5220    // A durable question and evidence are sufficient even if commenting fails.
5221    let body = stop_comment(
5222        &state.id,
5223        "CI failures independently reproduced on the base; awaiting an owner decision without charging the task attempt",
5224    );
5225    let (_, body) = crate::github_text::prepare(state, "", &body);
5226    gh(
5227        &state.repo,
5228        &[
5229            "pr".into(),
5230            "comment".into(),
5231            pr.number.to_string(),
5232            "--body".into(),
5233            body,
5234        ],
5235    )
5236    .await
5237    .ok();
5238    Ok(())
5239}
5240
5241/// One observation, plus the two things [`PrState`] deliberately does not carry:
5242/// the title (needed for the squash subject) and where the failing checks'
5243/// logs live.
5244#[derive(Clone)]
5245struct Seen {
5246    pr: PrState,
5247    title: String,
5248    failing_urls: Vec<(String, String)>,
5249    /// `headRefOid`: the commit this observation, checks included, is about.
5250    head: String,
5251    /// The commit the checks belong to, read from the same GraphQL node as
5252    /// the checks themselves. Empty when unreadable.
5253    rollup_head: String,
5254    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
5255    /// BEHIND and DRAFT together, and a stop reason has to say which.
5256    merge_state: String,
5257    /// Every check of the rollup, for naming what an armed merge waits on.
5258    contexts: Vec<CheckInfo>,
5259    /// `baseRefName`, to look up which contexts the base requires.
5260    base: String,
5261}
5262
5263/// One check of the rollup as far as a stop reason needs it.
5264#[derive(Clone)]
5265struct CheckInfo {
5266    label: String,
5267    verdict: Verdict,
5268    required: Option<bool>,
5269    /// The workflow run the check belongs to; `None` for an external status.
5270    run: Option<String>,
5271}
5272
5273/// What to do with a red head before a fix round is spent on it.
5274#[derive(Debug, Clone, PartialEq, Eq)]
5275enum RerunStep {
5276    /// Rerun these workflow runs (each not yet rerun for this head).
5277    Rerun(Vec<String>),
5278    /// Reran already and the forge may still report the old failure: wait.
5279    Grace,
5280    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
5281    Fix,
5282}
5283
5284/// Failed checks a rerun may help: a failure of a check that blocks (`required`
5285/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
5286/// pending checks in another run do not delay this run's rerun.
5287#[cfg(test)]
5288fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
5289    rerunnable_with(contexts, false)
5290}
5291
5292fn rerunnable_with(
5293    contexts: &[CheckInfo],
5294    include_optional: bool,
5295) -> Vec<(String, Option<String>)> {
5296    contexts
5297        .iter()
5298        .filter(|c| c.verdict == Verdict::Fail && (include_optional || c.required != Some(false)))
5299        // `gh run rerun --failed` refuses a run with a job still going.
5300        .filter(|c| {
5301            c.run.as_ref().is_none_or(|r| {
5302                !contexts.iter().any(|o| {
5303                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
5304                        && o.run.as_ref() == Some(r)
5305                })
5306            })
5307        })
5308        .map(|c| (c.label.clone(), c.run.clone()))
5309        .collect()
5310}
5311
5312/// Pure: rerun once per run id, wait out the grace, then hand over.
5313fn rerun_step(
5314    failing: &[(String, Option<String>)],
5315    record: Option<&LandReruns>,
5316    head: &str,
5317    now: i64,
5318) -> RerunStep {
5319    // A failure with no workflow run (an external status) cannot be rerun, and
5320    // the fixer's push re-runs everything anyway.
5321    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
5322        return RerunStep::Fix;
5323    }
5324    // Reruns belong to the head they were made on.
5325    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
5326    let mut fresh: Vec<String> = Vec::new();
5327    let mut recent = false;
5328    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
5329        match record.and_then(|r| r.runs.get(run)) {
5330            None => {
5331                if !fresh.iter().any(|f| f == run) {
5332                    fresh.push(run.to_owned());
5333                }
5334            }
5335            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
5336            Some(_) => {}
5337        }
5338    }
5339    if !fresh.is_empty() {
5340        RerunStep::Rerun(fresh)
5341    } else if recent {
5342        RerunStep::Grace
5343    } else {
5344        RerunStep::Fix
5345    }
5346}
5347
5348/// Read the pull request: `gh pr view` for the top-level thread and merge
5349/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
5350/// inline review comments `gh pr view` does not report.
5351async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
5352    let view = gh(
5353        repo,
5354        &[
5355            "pr".to_owned(),
5356            "view".to_owned(),
5357            pr_url.to_owned(),
5358            "--json".to_owned(),
5359            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
5360                .to_owned(),
5361        ],
5362    )
5363    .await?;
5364    if !view.0 {
5365        bail!("gh pr view {pr_url}: {}", view.1);
5366    }
5367    let number = parse_pr(&view.1)?.number;
5368    let node = last_commit_node(repo, number).await;
5369    let mut seen = seen_from(&view.1, node.as_deref())?;
5370
5371    let inline = gh(
5372        repo,
5373        &[
5374            "api".to_owned(),
5375            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
5376        ],
5377    )
5378    .await?;
5379    if inline.0 {
5380        match parse_inline_comments(&inline.1) {
5381            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5382            // An unreadable inline thread must not end a landing: the rollup
5383            // and the top-level thread are still real signal.
5384            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5385        }
5386    } else {
5387        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5388    }
5389    Ok(seen)
5390}
5391
5392/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
5393/// response. No I/O.
5394///
5395/// The commit oid and the checks are read from the *same* node, so the rollup
5396/// is bound to the commit it belongs to by construction; two reads that agree
5397/// before and after another response prove nothing about what that response
5398/// held. The view's own rollup is never used. A node that is missing,
5399/// unreadable, carries GraphQL errors, or whose checks run past the page
5400/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
5401/// as "look again" and never as a reason to merge.
5402fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5403    let mut pr = parse_pr(view_json)?;
5404    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5405
5406    let mut rollup_head = String::new();
5407    let mut failing_urls = Vec::new();
5408    let mut contexts = Vec::new();
5409    let mut checks = Checks::Unknown;
5410    let mut failing = Vec::new();
5411    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5412        (checks, failing) = rollup_verdict(&rollup);
5413        failing_urls = rollup
5414            .iter()
5415            .filter(|c| c.verdict() == Verdict::Fail)
5416            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5417            .collect();
5418        contexts = rollup
5419            .iter()
5420            .map(|c| CheckInfo {
5421                label: c.label(),
5422                verdict: c.verdict(),
5423                required: c.is_required,
5424                run: c.url().and_then(run_of),
5425            })
5426            .collect();
5427        rollup_head = oid;
5428    }
5429    pr.checks = checks;
5430    pr.failing = failing;
5431
5432    Ok(Seen {
5433        pr,
5434        title: raw.title,
5435        failing_urls,
5436        head: raw.head_ref_oid,
5437        rollup_head,
5438        merge_state: raw.merge_state_status,
5439        contexts,
5440        base: raw.base_ref_name,
5441    })
5442}
5443
5444/// The last commit's oid and its checks from one GraphQL response, `None`
5445/// when anything about it cannot be trusted.
5446fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5447    let v: serde_json::Value = serde_json::from_str(json).ok()?;
5448    if v.get("errors").is_some_and(|e| !e.is_null()) {
5449        return None;
5450    }
5451    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5452    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5453    let contexts = commit.pointer("/statusCheckRollup/contexts");
5454    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5455        // No rollup at all: the commit has no checks.
5456        return Some((oid.to_owned(), Vec::new()));
5457    };
5458    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5459        return None;
5460    }
5461    let nodes = contexts.get("nodes")?.as_array()?;
5462    let rollup = nodes
5463        .iter()
5464        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5465        .collect::<Result<Vec<_>, _>>()
5466        .ok()?;
5467    Some((oid.to_owned(), rollup))
5468}
5469
5470/// The pull request's last commit and its checks, in one response. `None`
5471/// when the forge could not be asked.
5472async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5473    let out = gh(
5474        repo,
5475        &[
5476            "api".to_owned(),
5477            "graphql".to_owned(),
5478            "-F".to_owned(),
5479            "owner={owner}".to_owned(),
5480            "-F".to_owned(),
5481            "repo={repo}".to_owned(),
5482            "-F".to_owned(),
5483            format!("number={number}"),
5484            "-f".to_owned(),
5485            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5486             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5487             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5488             ... on CheckRun{name status conclusion detailsUrl \
5489             isRequired(pullRequestNumber:$number)} \
5490             ... on StatusContext{context state targetUrl \
5491             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5492                .to_owned(),
5493        ],
5494    )
5495    .await
5496    .ok()?;
5497    out.0.then_some(out.1)
5498}
5499
5500/// What a fix round did.
5501#[doc(hidden)]
5502#[derive(Debug, PartialEq)]
5503pub enum Fixed {
5504    /// The fixer committed something, and this is the head that was pushed.
5505    Committed {
5506        /// The commit now at the tip of the pushed branch.
5507        head: String,
5508    },
5509    /// The fixer ran and chose to change nothing.
5510    Declined,
5511    /// Machine availability prevented a result; retry this round on resume.
5512    Deferred,
5513    /// The fixer could not run, or said nothing usable.
5514    Failed(String),
5515}
5516
5517/// Hand the failures and the comments to the fixer, then commit and push.
5518///
5519/// The fixer works in the winner's own worktree so its commits land on the
5520/// branch the pull request is built from, and it runs with `allow_write` for
5521/// the same reason.
5522#[doc(hidden)]
5523pub async fn fix_round(
5524    state: &mut RunState,
5525    pr: &PrState,
5526    round: usize,
5527    budget: usize,
5528    reason: &str,
5529    logs: &str,
5530) -> Result<Fixed> {
5531    let winner = state
5532        .winner()
5533        .cloned()
5534        .context("landing needs a winning candidate; none is recorded on this run")?;
5535    let roles = state
5536        .config
5537        .resolve_roles()
5538        .context("resolve the roster for the fix round")?;
5539    // Same chain as the review loop (see `crate::fixer`): the configured
5540    // fixers in order, otherwise the winner's own author continuing its own
5541    // conversation - the competition is over, so its context is pure benefit.
5542    let attempts = crate::fixer::attempts(state, &roles, &winner);
5543    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5544
5545    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5546    let mut artifacts = agent::artifacts_dir(&state.dir());
5547    if let Some(request) = state
5548        .merge_repairs
5549        .last()
5550        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5551    {
5552        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5553    }
5554    let prompt = if state.config.cache_dir().is_some() {
5555        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5556    } else {
5557        prompt
5558    };
5559    // Read before the fixer runs: it normally commits for itself, so HEAD has
5560    // already moved by the time it returns and a later read would see no
5561    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
5562    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5563    // Each id at most once, forward only: the next one is asked only when the
5564    // call advances, and only the last attempt's result is judged below, so an
5565    // exhausted chain ends as a single failed fixer does.
5566    let mut last = None;
5567    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5568        if owner_requested_stop(state)? {
5569            return Ok(Fixed::Declined);
5570        }
5571        let mut seat = seat_of(state, &seat_key, &spec.id);
5572        let stem = if i == 0 {
5573            format!("land-{round}")
5574        } else {
5575            format!("land-{round}-{}", spec.id)
5576        };
5577        let out = agent::invoke(
5578            &spec,
5579            &mut seat,
5580            &Invocation {
5581                coordination: state.config.coordination.as_ref(),
5582                fetched_base: state.fetched_base.as_ref(),
5583                cwd: &winner.worktree,
5584                prompt: &prompt,
5585                timeout: Duration::from_secs(state.config.graph.timeout_fix),
5586                allow_write: true,
5587                sessions: state.config.graph.sessions,
5588                artifacts: &artifacts,
5589                stem: &stem,
5590                run: &state.id,
5591                node: "land",
5592                cache_dir: state.config.cache_dir().as_deref(),
5593                attachments: &[],
5594                writable: &[],
5595            },
5596        )
5597        .await;
5598        state.seats.insert(seat.key.clone(), seat);
5599        if let Some(next) = ids.get(i + 1)
5600            && agent::chain_advances(&out)
5601        {
5602            let (class, why) = crate::fixer::failure_of(&out);
5603            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5604            continue;
5605        }
5606        last = Some((seat_key, out));
5607        break;
5608    }
5609    let (seat_key, out) = last.expect("the fixer chain always has an entry");
5610
5611    match out {
5612        Ok(o) if o.quota_exhausted() => {
5613            let loss = crate::run::QuotaLoss::from_report(seat_key, "land", o.quota.as_ref());
5614            crate::graph::block_machine_outage(state, "land", std::slice::from_ref(&loss));
5615            state.quota.push(loss);
5616            state.status = RunStatus::Blocked;
5617            state.save()?;
5618            // Keep partial edits reachable on the winner branch and leave a
5619            // clean tree for resume_landing_head. The resumed landing gates
5620            // this new commit before it can publish it; no push happens here.
5621            let inherited = crate::repo_lock::WaitPolicy::current();
5622            let wait = crate::repo_lock::WaitPolicy::new(
5623                Duration::from_secs(state.config.graph.timeout_resource_wait),
5624                move || inherited.cancelled(),
5625            );
5626            let _coordination = crate::repo_lock::SeatLease::operation_with(
5627                &winner.worktree,
5628                state.config.coordination.as_ref(),
5629                &wait,
5630            )
5631            .await?;
5632            let rescue = git::rescue_commit(
5633                &winner.worktree,
5634                &format!("magi: deferred land round {round} (uncommitted work)"),
5635            )
5636            .await?;
5637            state.note_withheld("land", &rescue.withheld);
5638            state.save()?;
5639            return Ok(Fixed::Deferred);
5640        }
5641        Ok(o) if !o.usable() => {
5642            return Ok(Fixed::Failed(format!(
5643                "the fixer produced nothing usable ({})",
5644                crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5645            )));
5646        }
5647        Ok(_) => {}
5648        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5649        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5650    }
5651
5652    if owner_requested_stop(state)? {
5653        return Ok(Fixed::Declined);
5654    }
5655
5656    // An agent that edited files but never committed would otherwise push
5657    // nothing and look like a refusal.
5658    let wait = crate::repo_lock::WaitPolicy::new(
5659        Duration::from_secs(state.config.graph.timeout_resource_wait),
5660        || false,
5661    );
5662    let coordination = crate::repo_lock::SeatLease::operation_with(
5663        &winner.worktree,
5664        state.config.coordination.as_ref(),
5665        &wait,
5666    )
5667    .await?;
5668    match git::rescue_commit(
5669        &winner.worktree,
5670        &format!("magi: land round {round} fixes (uncommitted work)"),
5671    )
5672    .await
5673    {
5674        Ok(r) => state.note_withheld("land", &r.withheld),
5675        Err(e)
5676            if state
5677                .merge_repairs
5678                .last()
5679                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5680        {
5681            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5682        }
5683        Err(_) => {}
5684    }
5685    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5686    drop(coordination);
5687    if after == before {
5688        return Ok(Fixed::Declined);
5689    }
5690
5691    if owner_requested_stop(state)? {
5692        return Ok(Fixed::Declined);
5693    }
5694    crate::pr_head::ensure_owner(state)?;
5695    let remote = state.config.merge.remote.clone();
5696    anyhow::ensure!(
5697        crate::foreign_checkout::write_back(state, &after).await?,
5698        "foreign checkout delivery is blocked; see the run events"
5699    );
5700    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5701    let push = if state.pr_head.is_some() {
5702        let expected = state
5703            .pr_pushed_head
5704            .as_deref()
5705            .context("missing public head lease")?;
5706        git::push_as(
5707            &winner.worktree,
5708            &remote,
5709            &winner.branch,
5710            &public_head,
5711            expected,
5712        )
5713        .await?
5714    } else {
5715        git::push(&winner.worktree, &remote, &winner.branch).await?
5716    };
5717    if !push.ok() {
5718        return Ok(Fixed::Failed(format!(
5719            "pushing {} to {remote} failed: {}",
5720            public_head, push.stderr
5721        )));
5722    }
5723    state.event(
5724        "land",
5725        format!("round {round}: pushed a fix to {public_head}"),
5726    );
5727    state.pr_pushed_head = Some(after.clone());
5728    state.save()?;
5729    Ok(Fixed::Committed { head: after })
5730}
5731
5732/// Fetch or create a seat, keeping its conversation across nodes.
5733pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5734    crate::fixer::seat_for(state, key, agent)
5735}
5736
5737/// Recheck the live PR and local worktree before spending the requested fix.
5738/// A moved/closed PR is held, never repaired using obsolete evidence.
5739pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5740    let seen = observe(&state.repo, url).await?;
5741    let winner = state.winner().context("no winning branch to repair")?;
5742    if seen.pr.state != PrLifecycle::Open || seen.head != head {
5743        anyhow::bail!(
5744            "PR is closed or its head changed; review the current head before requesting repair again"
5745        );
5746    }
5747    if git::rev_parse(&winner.worktree, "HEAD").await? != head
5748        || !git::is_clean(&winner.worktree).await?
5749    {
5750        anyhow::bail!(
5751            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5752        );
5753    }
5754    // Always disable any forge-side reservation, including one not recorded
5755    // by this run. A repair request is never merge authorization.
5756    let view = gh(
5757        &state.repo,
5758        &[
5759            "pr".into(),
5760            "view".into(),
5761            url.into(),
5762            "--json".into(),
5763            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5764        ],
5765    )
5766    .await?;
5767    if !view.0 {
5768        bail!("cannot check auto-merge: {}", view.1);
5769    }
5770    let live: serde_json::Value = serde_json::from_str(&view.1)?;
5771    let public = state.pr_branch().unwrap_or(&winner.branch);
5772    if live["state"] != "OPEN"
5773        || live["headRefOid"] != head
5774        || live["headRefName"] != public
5775        || live["baseRefName"] != state.base_branch
5776        || live["isCrossRepository"] != false
5777    {
5778        bail!("PR changed during repair preflight; review its current head explicitly");
5779    }
5780    let auto_merge = live
5781        .get("autoMergeRequest")
5782        .context("cannot read the PR auto-merge reservation; repair held")?;
5783    if !auto_merge.is_null() {
5784        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5785        if !out.0 {
5786            bail!("cannot disable auto-merge; repair held: {}", out.1);
5787        }
5788    }
5789    Ok(seen.pr)
5790}
5791
5792/// What the fixer is told.
5793fn fix_prompt(
5794    state: &RunState,
5795    pr: &PrState,
5796    round: usize,
5797    budget: usize,
5798    reason: &str,
5799    logs: &str,
5800) -> String {
5801    let mut s = format!(
5802        "Your patch is open as a pull request and it is not landing. Land round \
5803         {round} of {budget}.\n\n\
5804         Pull request: {}\n\n\
5805         What is holding it: {reason}\n\n\
5806         # The task\n\n{}\n",
5807        pr.url, state.instruction
5808    );
5809
5810    if pr.failing.is_empty() {
5811        s.push_str("\n# Failing checks\n\n(none)\n");
5812    } else {
5813        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5814        if logs.trim().is_empty() {
5815            s.push_str(
5816                "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5817            );
5818        } else {
5819            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5820        }
5821    }
5822
5823    if pr.review_comments.is_empty() {
5824        s.push_str("\n# Review comments\n\n(none)\n");
5825    } else {
5826        s.push_str("\n# Review comments\n");
5827        for c in &pr.review_comments {
5828            let where_ = match (&c.path, c.line) {
5829                (Some(p), Some(l)) => format!(" ({p}:{l})"),
5830                (Some(p), None) => format!(" ({p})"),
5831                _ => String::new(),
5832            };
5833            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5834        }
5835    }
5836
5837    s.push_str(
5838        "\n# Rules\n\n\
5839         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5840            failing test; do not silence a lint with an allow attribute; do not \
5841            stretch a timeout to hide a race. If the check is right, the code is \
5842            wrong.\n\
5843         2. Change nothing the checks and the comments did not raise. A \
5844            drive-by refactor turns a one-line fix into a pull request that \
5845            needs reviewing again.\n\
5846         3. If a comment is wrong, say so with a checkable argument and change \
5847            nothing for it. A declined comment with a reason is a correct \
5848            outcome; a change made to appease a reviewer is not.\n\
5849         4. Commit in this worktree. magi pushes to the pull request's branch \
5850            for you; do not push, merge, or close anything yourself.\n\
5851         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5852         # Output\n\n\
5853         Say what you changed and why, and what you declined and why.",
5854    );
5855
5856    let language = &state.config.graph.language;
5857    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5858        let _ = write!(s, "\n\nWrite all prose in {language}.");
5859    }
5860    // After the language line, so the exception is the last word on it.
5861    s.push_str(&crate::prompt::github_rule(
5862        language,
5863        &state.config.graph.github_language,
5864    ));
5865    if let Some(overlay) = state.config.prompts.overlay("fix") {
5866        let _ = write!(s, "\n\n{overlay}");
5867    }
5868    s.push('\n');
5869    s.push_str(crate::prompt::child_run_ownership());
5870    s
5871}
5872
5873/// Failing log tails, the way the operator collects them by hand:
5874/// `gh run view --log-failed`.
5875async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5876    let mut out = String::new();
5877    for (name, url) in failing.iter().take(MAX_LOGS) {
5878        let args = match (job_of(url), run_of(url)) {
5879            (Some(job), _) => vec![
5880                "run".to_owned(),
5881                "view".to_owned(),
5882                "--log-failed".to_owned(),
5883                "--job".to_owned(),
5884                job,
5885            ],
5886            (None, Some(run)) => vec![
5887                "run".to_owned(),
5888                "view".to_owned(),
5889                run,
5890                "--log-failed".to_owned(),
5891            ],
5892            // Not a GitHub Actions check - an external status has no log here.
5893            (None, None) => continue,
5894        };
5895        let (ok, body) = match gh(repo, &args).await {
5896            Ok(v) => v,
5897            Err(e) => (false, format!("{e:#}")),
5898        };
5899        if !ok && body.trim().is_empty() {
5900            continue;
5901        }
5902        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5903    }
5904    out
5905}
5906
5907/// Job id out of a check's `detailsUrl`
5908/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
5909fn job_of(details_url: &str) -> Option<String> {
5910    let after = details_url.split("/job/").nth(1)?;
5911    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5912    (!id.is_empty()).then_some(id)
5913}
5914
5915/// Workflow run id out of a check's `detailsUrl`.
5916pub(crate) fn run_of(details_url: &str) -> Option<String> {
5917    let after = details_url.split("/actions/runs/").nth(1)?;
5918    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5919    (!id.is_empty()).then_some(id)
5920}
5921
5922/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
5923/// it lands on GitHub, not in front of the operator. Pure so a test can hold
5924/// it to that.
5925fn stop_comment(run_id: &str, why: &str) -> String {
5926    format!(
5927        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5928         The branch is untouched and the run is `{run_id}`. Nothing was merged."
5929    )
5930}
5931
5932/// Leave the pull request open, say why on it, and mark the run blocked.
5933///
5934/// The comment is what makes an unattended stop actionable: the operator wakes
5935/// up to a pull request that explains itself rather than to a silent queue.
5936async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5937    let body = stop_comment(&state.id, why);
5938    let (_, mut body) = crate::github_text::prepare(state, "", &body);
5939    if !body.contains(MARKER) {
5940        body = format!("{MARKER}\n{body}");
5941    }
5942    let posted = gh(
5943        repo,
5944        &[
5945            "pr".to_owned(),
5946            "comment".to_owned(),
5947            pr.number.to_string(),
5948            "--body".to_owned(),
5949            body,
5950        ],
5951    )
5952    .await;
5953    match posted {
5954        Ok((true, _)) => {}
5955        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5956        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5957    }
5958    state.status = RunStatus::Blocked;
5959    state.merge = Some(MergeOutcome {
5960        mode: MergeMode::Pr,
5961        ok: false,
5962        detail: why.to_owned(),
5963        empty: false,
5964    });
5965    state.event("land", format!("stopped: {why}"));
5966    state.save()?;
5967    Ok(())
5968}
5969
5970/// Run `gh` in `repo`, returning success and the combined output.
5971///
5972/// Combined because `gh` reports a refused merge on stderr and the pull request
5973/// json on stdout, and both are evidence.
5974///
5975/// `GH_REPO` is stripped from the child's environment: every call site here
5976/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
5977/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
5978/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
5979/// `GH_REPO` the operator happens to have exported for an unrelated script
5980/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
5981/// below) to a different repository than the one actually on disk - which
5982/// for the same-repo guard in [`correct_manual_merge`] would mean the check
5983/// could be made to agree with whatever repository a forged `--merged` URL
5984/// claims, defeating it entirely.
5985pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5986    let out = tokio::process::Command::new("gh")
5987        .args(args)
5988        .current_dir(cwd)
5989        .env_remove("GH_REPO")
5990        .quiet()
5991        .stdin(std::process::Stdio::null())
5992        .output()
5993        .await
5994        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5995    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5996    let err = String::from_utf8_lossy(&out.stderr);
5997    if body.trim().is_empty() {
5998        body = err.into_owned();
5999    } else if !err.trim().is_empty() {
6000        body.push_str(&err);
6001    }
6002    Ok((out.status.success(), body.trim().to_owned()))
6003}
6004
6005/// Verdict of one entry in the status rollup.
6006#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6007pub(crate) enum Verdict {
6008    Pass,
6009    Fail,
6010    Pending,
6011    Unknown,
6012}
6013
6014impl Verdict {
6015    /// Short operator-facing check result.
6016    pub(crate) fn label(self) -> &'static str {
6017        match self {
6018            Self::Pass => "passed",
6019            Self::Fail => "failed",
6020            Self::Pending => "pending",
6021            Self::Unknown => "unknown",
6022        }
6023    }
6024}
6025
6026#[derive(Debug, Deserialize)]
6027#[serde(rename_all = "camelCase")]
6028struct GhPr {
6029    #[serde(default)]
6030    url: String,
6031    #[serde(default)]
6032    number: u64,
6033    #[serde(default)]
6034    state: String,
6035    #[serde(default)]
6036    title: String,
6037    #[serde(default)]
6038    status_check_rollup: Vec<GhCheck>,
6039    /// GitHub's own verdict on whether the pull request can be merged.
6040    ///
6041    /// Worth asking for because it is the only place the *required* check set
6042    /// is applied: the rollup lists every check equally, so a repository that
6043    /// deliberately does not require `coverage` still looks red here. See
6044    /// [`Blocking`].
6045    #[serde(default)]
6046    merge_state_status: String,
6047    /// The commit the pull request currently points at. Compared with the
6048    /// commit a fix round pushed, it is how the loop knows the forge has moved
6049    /// on and the rollup belongs to the new head.
6050    #[serde(default)]
6051    head_ref_oid: String,
6052    #[serde(default)]
6053    base_ref_name: String,
6054    #[serde(default)]
6055    reviews: Vec<GhReview>,
6056    #[serde(default)]
6057    comments: Vec<GhComment>,
6058}
6059
6060/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
6061/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
6062/// status API, which is how CodeRabbit reports - has `context`/`state` and no
6063/// conclusion at all.
6064#[derive(Debug, Deserialize)]
6065#[serde(rename_all = "camelCase")]
6066struct GhCheck {
6067    #[serde(default)]
6068    name: Option<String>,
6069    #[serde(default)]
6070    context: Option<String>,
6071    #[serde(default)]
6072    status: Option<String>,
6073    #[serde(default)]
6074    conclusion: Option<String>,
6075    #[serde(default)]
6076    state: Option<String>,
6077    #[serde(default)]
6078    details_url: Option<String>,
6079    #[serde(default)]
6080    target_url: Option<String>,
6081    /// Whether the base branch requires this check. Only the GraphQL node
6082    /// carries it; `None` is "not read", never "not required".
6083    #[serde(default)]
6084    is_required: Option<bool>,
6085}
6086
6087impl GhCheck {
6088    /// Name to show a human and hand to the fixer.
6089    fn label(&self) -> String {
6090        self.name
6091            .clone()
6092            .or_else(|| self.context.clone())
6093            .unwrap_or_else(|| "(unnamed check)".to_owned())
6094    }
6095
6096    /// Where this check's logs live, when it has any.
6097    fn url(&self) -> Option<&str> {
6098        self.details_url
6099            .as_deref()
6100            .or(self.target_url.as_deref())
6101            .filter(|u| !u.is_empty())
6102    }
6103
6104    /// Did it pass?
6105    ///
6106    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
6107    /// skips release and bot pull requests by design, and a skip that blocked
6108    /// landing would block exactly the pull requests that need no review.
6109    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
6110    /// merging over one is merging over a check that never ran.
6111    fn verdict(&self) -> Verdict {
6112        if let Some(status) = self.status.as_deref() {
6113            if !status.eq_ignore_ascii_case("COMPLETED") {
6114                return Verdict::Pending;
6115            }
6116        }
6117        let outcome = self
6118            .conclusion
6119            .as_deref()
6120            .or(self.state.as_deref())
6121            .unwrap_or("");
6122        match outcome.to_ascii_uppercase().as_str() {
6123            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
6124            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
6125            | "ACTION_REQUIRED" => Verdict::Fail,
6126            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
6127                Verdict::Pending
6128            }
6129            _ => Verdict::Unknown,
6130        }
6131    }
6132}
6133
6134#[derive(Debug, Deserialize)]
6135struct GhAuthor {
6136    #[serde(default)]
6137    login: String,
6138}
6139
6140#[derive(Debug, Deserialize)]
6141struct GhReview {
6142    #[serde(default)]
6143    author: GhAuthor,
6144    #[serde(default)]
6145    body: String,
6146}
6147
6148#[derive(Debug, Deserialize)]
6149struct GhComment {
6150    #[serde(default)]
6151    author: GhAuthor,
6152    #[serde(default)]
6153    body: String,
6154}
6155
6156#[derive(Debug, Deserialize)]
6157struct GhUser {
6158    #[serde(default)]
6159    login: String,
6160}
6161
6162#[derive(Debug, Deserialize)]
6163struct GhInline {
6164    #[serde(default)]
6165    user: GhUser,
6166    #[serde(default)]
6167    path: Option<String>,
6168    #[serde(default)]
6169    line: Option<u64>,
6170    #[serde(default)]
6171    body: String,
6172}
6173
6174impl Default for GhAuthor {
6175    fn default() -> Self {
6176        Self {
6177            login: "(unknown)".to_owned(),
6178        }
6179    }
6180}
6181
6182impl Default for GhUser {
6183    fn default() -> Self {
6184        Self {
6185            login: "(unknown)".to_owned(),
6186        }
6187    }
6188}
6189
6190#[cfg(test)]
6191pub(crate) mod tests {
6192    use super::*;
6193    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
6194
6195    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
6196        format!(
6197            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
6198        )
6199    }
6200
6201    #[test]
6202    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
6203        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
6204        assert_eq!(
6205            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
6206            Ok(())
6207        );
6208        for (json, why) in [
6209            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
6210            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
6211            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
6212            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
6213            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
6214            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
6215        ] {
6216            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
6217                .unwrap_err()
6218                .why;
6219            assert!(err.contains(why), "{json}: {err}");
6220        }
6221        // A head that moved on past what was verified is left alone.
6222        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
6223        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
6224        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
6225        assert!(
6226            !closable(
6227                &head_json("x", "main", "OPEN", false),
6228                "magi/27b2/A",
6229                "main",
6230                &[]
6231            )
6232            .unwrap_err()
6233            .retry
6234        );
6235        assert!(is_forge_url("https://github.com/o/r.git"));
6236        assert!(is_forge_url("git@github.com:o/r.git"));
6237        assert!(!is_forge_url("/tmp/origin.git"));
6238        assert!(!is_forge_url("C:\\work\\origin.git"));
6239        assert!(!is_forge_url("file:///tmp/origin.git"));
6240        assert!(forge_unavailable(
6241            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
6242        ));
6243        assert!(!forge_unavailable(
6244            "gh pr list failed: error connecting to api.github.com"
6245        ));
6246        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
6247        // A record that does not say whether it is a fork is not trusted.
6248        assert!(
6249            closable(
6250                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
6251                "b",
6252                "main",
6253                &["aaa".to_owned()]
6254            )
6255            .is_err()
6256        );
6257    }
6258
6259    #[test]
6260    fn the_close_comment_names_the_commit_on_the_base() {
6261        let e = crate::already::Evidence {
6262            proof: crate::already::Proof::PatchId,
6263            tip: "1234567890".to_owned(),
6264            commits: vec!["0e368de0000".to_owned()],
6265        };
6266        let c = superseded_comment("main", &e);
6267        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
6268    }
6269
6270    /// 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.
6271    const GREEN_OPEN: &str = r####"{
6272  "url": "https://github.com/yukimemi/magi/pull/10",
6273  "number": 10,
6274  "state": "OPEN",
6275  "mergeStateStatus": "CLEAN",
6276  "statusCheckRollup": [
6277    {
6278      "__typename": "CheckRun",
6279      "conclusion": "SKIPPED",
6280      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
6281      "name": "review",
6282      "status": "COMPLETED",
6283      "workflowName": "claude-review"
6284    },
6285    {
6286      "__typename": "CheckRun",
6287      "conclusion": "SUCCESS",
6288      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
6289      "name": "check (ubuntu-latest)",
6290      "status": "COMPLETED",
6291      "workflowName": "CI"
6292    },
6293    {
6294      "__typename": "CheckRun",
6295      "conclusion": "SUCCESS",
6296      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
6297      "name": "rustfmt",
6298      "status": "COMPLETED",
6299      "workflowName": "CI"
6300    },
6301    {
6302      "__typename": "StatusContext",
6303      "context": "CodeRabbit",
6304      "state": "SUCCESS",
6305      "targetUrl": ""
6306    }
6307  ],
6308  "reviews": [],
6309  "comments": [
6310    {
6311      "author": {
6312        "login": "coderabbitai"
6313      },
6314      "authorAssociation": "NONE",
6315      "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"
6316    }
6317  ]
6318}"####;
6319
6320    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
6321    const RED_OPEN: &str = r####"{
6322  "url": "https://github.com/yukimemi/magi/pull/9",
6323  "number": 9,
6324  "state": "OPEN",
6325  "mergeStateStatus": "UNSTABLE",
6326  "statusCheckRollup": [
6327    {
6328      "__typename": "CheckRun",
6329      "conclusion": "SUCCESS",
6330      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6331      "name": "check (ubuntu-latest)",
6332      "status": "COMPLETED",
6333      "workflowName": "CI"
6334    },
6335    {
6336      "__typename": "CheckRun",
6337      "conclusion": "SUCCESS",
6338      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6339      "name": "rustfmt",
6340      "status": "COMPLETED",
6341      "workflowName": "CI"
6342    },
6343    {
6344      "__typename": "CheckRun",
6345      "conclusion": "FAILURE",
6346      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6347      "name": "editorconfig",
6348      "status": "COMPLETED",
6349      "workflowName": "CI"
6350    },
6351    {
6352      "__typename": "StatusContext",
6353      "context": "CodeRabbit",
6354      "state": "SUCCESS",
6355      "targetUrl": ""
6356    }
6357  ],
6358  "reviews": [],
6359  "comments": [
6360    {
6361      "author": {
6362        "login": "coderabbitai"
6363      },
6364      "authorAssociation": "NONE",
6365      "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"
6366    }
6367  ]
6368}"####;
6369
6370    /// 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.
6371    const PENDING_OPEN: &str = r####"{
6372  "url": "https://github.com/yukimemi/magi/pull/9",
6373  "number": 9,
6374  "state": "OPEN",
6375  "statusCheckRollup": [
6376    {
6377      "__typename": "CheckRun",
6378      "conclusion": "SUCCESS",
6379      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6380      "name": "check (ubuntu-latest)",
6381      "status": "COMPLETED",
6382      "workflowName": "CI"
6383    },
6384    {
6385      "__typename": "CheckRun",
6386      "conclusion": "SUCCESS",
6387      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6388      "name": "rustfmt",
6389      "status": "COMPLETED",
6390      "workflowName": "CI"
6391    },
6392    {
6393      "__typename": "CheckRun",
6394      "conclusion": null,
6395      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6396      "name": "editorconfig",
6397      "status": "IN_PROGRESS",
6398      "workflowName": "CI"
6399    },
6400    {
6401      "__typename": "StatusContext",
6402      "context": "CodeRabbit",
6403      "state": "SUCCESS",
6404      "targetUrl": ""
6405    }
6406  ],
6407  "reviews": [],
6408  "comments": []
6409}"####;
6410
6411    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
6412    const MERGED: &str = r####"{
6413  "url": "https://github.com/yukimemi/magi/pull/16",
6414  "number": 16,
6415  "state": "MERGED",
6416  "statusCheckRollup": [
6417    {
6418      "__typename": "CheckRun",
6419      "conclusion": "SUCCESS",
6420      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6421      "name": "check (ubuntu-latest)",
6422      "status": "COMPLETED",
6423      "workflowName": "CI"
6424    },
6425    {
6426      "__typename": "CheckRun",
6427      "conclusion": "SUCCESS",
6428      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6429      "name": "review",
6430      "status": "COMPLETED",
6431      "workflowName": "claude-review"
6432    }
6433  ],
6434  "reviews": [],
6435  "comments": []
6436}"####;
6437
6438    /// 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.
6439    const REVIEWED_OPEN: &str = r####"{
6440  "url": "https://github.com/yukimemi/magi/pull/12",
6441  "number": 12,
6442  "state": "OPEN",
6443  "statusCheckRollup": [
6444    {
6445      "__typename": "CheckRun",
6446      "conclusion": "SUCCESS",
6447      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6448      "name": "check (ubuntu-latest)",
6449      "status": "COMPLETED",
6450      "workflowName": "CI"
6451    },
6452    {
6453      "__typename": "CheckRun",
6454      "conclusion": "SUCCESS",
6455      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6456      "name": "review",
6457      "status": "COMPLETED",
6458      "workflowName": "claude-review"
6459    }
6460  ],
6461  "reviews": [
6462    {
6463      "author": {
6464        "login": "claude"
6465      },
6466      "state": "COMMENTED",
6467      "body": ""
6468    }
6469  ],
6470  "comments": [
6471    {
6472      "author": {
6473        "login": "coderabbitai"
6474      },
6475      "authorAssociation": "NONE",
6476      "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"
6477    },
6478    {
6479      "author": {
6480        "login": "claude"
6481      },
6482      "authorAssociation": "NONE",
6483      "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"
6484    }
6485  ]
6486}"####;
6487
6488    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
6489    const INLINE: &str = r####"[
6490  {
6491    "user": {
6492      "login": "claude[bot]"
6493    },
6494    "path": "src/graph.rs",
6495    "line": 231,
6496    "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"
6497  }
6498]"####;
6499
6500    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
6501    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6502<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6503
6504> [!IMPORTANT]
6505> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6506> 
6507> This repository does not receive automatic reviews because it has fewer than 10 stars.
6508> 
6509> <details>
6510> <summary>⚙️ Run configuration</summary>
6511> 
6512> **Configuration used**: defaults
6513> 
6514> **Review profile**: CHILL
6515> 
6516> **Plan**: Team
6517> 
6518> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6519> 
6520> </details>
6521
6522<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6523
6524<!-- tips_start -->
6525
6526---
6527
6528Thanks 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.
6529
6530<details>
6531<summary>❤️ Share</summary>
6532
6533- [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"####;
6534
6535    /// 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.
6536    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6537
6538---
6539### Reviewing PR #16
6540
6541- [x] Read AGENTS.md conventions
6542- [x] Review `src/daemon.rs` changes
6543- [x] Review `src/main.rs` changes (new `doctor` reporting)
6544- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6545- [x] Check test coverage for new behavior
6546- [x] Run verification commands (blocked — see note)
6547- [x] Post findings"####;
6548
6549    /// The same job's real comment on pull request #12 once it had something to say.
6550    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6551
6552---
6553### Review: `magi review <branch>` — cheap-half-only graph
6554
6555Read 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.
6556
6557**Correctness**
6558
6559- 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"####;
6560
6561    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6562        PrState {
6563            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6564            number: 16,
6565            state: PrLifecycle::Open,
6566            checks,
6567            // These tests are about red-means-fix, so a red here is one the
6568            // forge gates on. Without saying so they would assert the new
6569            // "merge past a check nobody requires" path by accident.
6570            blocking: if matches!(checks, Checks::Red) {
6571                Blocking::Yes
6572            } else {
6573                Blocking::No
6574            },
6575            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6576            review_comments: (0..comments)
6577                .map(|i| ReviewComment {
6578                    author: "coderabbitai".to_owned(),
6579                    path: Some("src/graph.rs".to_owned()),
6580                    line: Some(231),
6581                    body: format!("finding {i}"),
6582                })
6583                .collect(),
6584        }
6585    }
6586
6587    #[test]
6588    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6589        use CiExpectation::{Absent, Expected};
6590        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6591            let p = pr(checks, &["x"], 0);
6592            for waited in [Duration::ZERO, CHECKS_GRACE] {
6593                assert_eq!(
6594                    decide_with(&p, 0, 4, waited, Expected),
6595                    decide(&p, 0, 4, waited)
6596                );
6597            }
6598        }
6599        // Nothing will ever report: no wait, no give-up, no fix round.
6600        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6601            let p = pr(checks, &["x"], 0);
6602            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6603            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6604        }
6605        // Third-party statuses never change the review decision, even at budget.
6606        for comments in [0, 1] {
6607            for round in [0, 4] {
6608                let absent = decide_with(
6609                    &pr(Checks::Unknown, &[], comments),
6610                    round,
6611                    4,
6612                    CHECKS_GRACE,
6613                    Absent,
6614                );
6615                for checks in [Checks::Green, Checks::Red, Checks::Pending] {
6616                    assert_eq!(
6617                        decide_with(
6618                            &pr(checks, &["third-party"], comments),
6619                            round,
6620                            4,
6621                            Duration::ZERO,
6622                            Absent
6623                        ),
6624                        absent
6625                    );
6626                }
6627                if comments != 0 {
6628                    assert!(matches!(absent, Step::Fix { .. } | Step::GiveUp { .. }));
6629                    assert_eq!(matches!(absent, Step::GiveUp { .. }), round == 4);
6630                }
6631            }
6632        }
6633        let empty = pr(Checks::Unknown, &[], 0);
6634        for waited in [Duration::ZERO, CHECKS_GRACE + POLL] {
6635            assert_eq!(decide_with(&empty, 0, 4, waited, Absent), Step::Merge);
6636        }
6637        assert_eq!(
6638            decide_with(&empty, 0, 4, Duration::ZERO, Expected),
6639            Step::Wait
6640        );
6641        assert_eq!(
6642            decide_with(&empty, 0, 4, CHECKS_GRACE, Expected),
6643            Step::GiveUp { reason: "no check status is readable on the pull request after 3 minute(s); refusing to merge on a guess".to_owned() }
6644        );
6645        // A conflict is not a check: it still wants the rebase.
6646        let mut p = pr(Checks::Unknown, &[], 0);
6647        p.blocking = Blocking::Conflict;
6648        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6649        // And a pull request that left our hands is still done.
6650        p.state = PrLifecycle::Merged;
6651        assert_eq!(
6652            decide_with(&p, 0, 4, Duration::ZERO, Absent),
6653            Step::Done { merged: true }
6654        );
6655    }
6656
6657    #[test]
6658    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6659        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6660        assert_eq!(state.number, 10);
6661        assert_eq!(state.state, PrLifecycle::Open);
6662        assert_eq!(state.checks, Checks::Green);
6663        assert!(state.failing.is_empty());
6664        assert!(
6665            state.review_comments.is_empty(),
6666            "the only comment is CodeRabbit's trigger notice: {:?}",
6667            state.review_comments
6668        );
6669        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6670    }
6671
6672    #[test]
6673    fn a_failing_check_parses_as_red_and_is_named() {
6674        let state = parse_pr(RED_OPEN).expect("red fixture parses");
6675        assert_eq!(state.checks, Checks::Red);
6676        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6677        // The captured payload says `UNSTABLE` - mergeable, with a check
6678        // nobody requires red - which is exactly the shape that had to be
6679        // merged by hand. Asserted separately, in
6680        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
6681        // test is about is that a red check is *named*, so the reason a fixer
6682        // is handed says which one; so it asks the blocking question here.
6683        let mut blocking = state.clone();
6684        blocking.blocking = Blocking::Yes;
6685        match decide(&blocking, 0, 4, Duration::ZERO) {
6686            Step::Fix { reason } => {
6687                assert!(reason.contains("editorconfig"), "reason: {reason}");
6688                assert!(reason.contains("failing"), "reason: {reason}");
6689            }
6690            other => panic!("expected a fix round, got {other:?}"),
6691        }
6692    }
6693
6694    #[test]
6695    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6696        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6697        assert_eq!(state.checks, Checks::Pending);
6698        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6699    }
6700
6701    #[test]
6702    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6703        let state = parse_pr(MERGED).expect("merged fixture parses");
6704        assert_eq!(state.state, PrLifecycle::Merged);
6705        assert_eq!(
6706            decide(&state, 0, 4, Duration::ZERO),
6707            Step::Done { merged: true }
6708        );
6709    }
6710
6711    #[test]
6712    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6713        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6714        assert_eq!(state.checks, Checks::Green);
6715        let authors: Vec<&str> = state
6716            .review_comments
6717            .iter()
6718            .map(|c| c.author.as_str())
6719            .collect();
6720        assert_eq!(
6721            authors,
6722            vec!["claude"],
6723            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6724        );
6725        match decide(&state, 0, 4, Duration::ZERO) {
6726            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6727            other => panic!("expected a fix round, got {other:?}"),
6728        }
6729    }
6730
6731    #[test]
6732    fn inline_review_comments_keep_their_file_and_line() {
6733        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6734        assert_eq!(comments.len(), 1);
6735        assert_eq!(comments[0].author, "claude[bot]");
6736        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6737        assert_eq!(comments[0].line, Some(231));
6738        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6739    }
6740
6741    #[test]
6742    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6743        assert!(
6744            is_noise(CODERABBIT_TRIGGER),
6745            "CodeRabbit's trigger notice declares itself not a review"
6746        );
6747        assert!(
6748            is_noise(CLAUDE_CHECKLIST),
6749            "a progress checklist asks for nothing"
6750        );
6751        assert!(
6752            !is_noise(CLAUDE_FINDING),
6753            "a review that names a bug is input, not noise"
6754        );
6755
6756        let mut clean = pr(Checks::Green, &[], 0);
6757        clean.review_comments.push(ReviewComment {
6758            author: "coderabbitai".to_owned(),
6759            path: None,
6760            line: None,
6761            body: CODERABBIT_TRIGGER.to_owned(),
6762        });
6763        clean.review_comments.retain(|c| !is_noise(&c.body));
6764        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6765
6766        let mut found = pr(Checks::Green, &[], 0);
6767        found.review_comments.push(ReviewComment {
6768            author: "claude".to_owned(),
6769            path: None,
6770            line: None,
6771            body: CLAUDE_FINDING.to_owned(),
6772        });
6773        found.review_comments.retain(|c| !is_noise(&c.body));
6774        assert!(matches!(
6775            decide(&found, 0, 4, Duration::ZERO),
6776            Step::Fix { .. }
6777        ));
6778    }
6779
6780    #[test]
6781    fn the_policy_table_holds_for_every_combination_that_matters() {
6782        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6783            (
6784                "pending checks are waited for, even on the last round",
6785                pr(Checks::Pending, &[], 0),
6786                4,
6787                4,
6788                Duration::ZERO,
6789                Step::Wait,
6790            ),
6791            (
6792                "red checks are fixed",
6793                pr(Checks::Red, &["editorconfig"], 0),
6794                0,
6795                4,
6796                Duration::ZERO,
6797                Step::Fix {
6798                    reason: "1 check(s) failing: editorconfig".to_owned(),
6799                },
6800            ),
6801            (
6802                "green with comments is fixed, not merged",
6803                pr(Checks::Green, &[], 2),
6804                1,
6805                4,
6806                Duration::ZERO,
6807                Step::Fix {
6808                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6809                        .to_owned(),
6810                },
6811            ),
6812            (
6813                "green and clean merges",
6814                pr(Checks::Green, &[], 0),
6815                3,
6816                4,
6817                Duration::ZERO,
6818                Step::Merge,
6819            ),
6820            (
6821                "an unreadable rollup is waited on while the grace lasts",
6822                pr(Checks::Unknown, &[], 0),
6823                0,
6824                4,
6825                Duration::ZERO,
6826                Step::Wait,
6827            ),
6828            (
6829                "an unreadable rollup is never merged once the grace is spent",
6830                pr(Checks::Unknown, &[], 0),
6831                0,
6832                4,
6833                CHECKS_GRACE,
6834                Step::GiveUp {
6835                    reason: "no check status is readable on the pull request after 3 minute(s); \
6836                             refusing to merge on a guess"
6837                        .to_owned(),
6838                },
6839            ),
6840        ];
6841        for (what, state, round, budget, waited, want) in cases {
6842            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6843        }
6844    }
6845
6846    #[test]
6847    fn the_forge_verdict_survives_the_round_trip_from_gh() {
6848        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
6849        // requested but never parsed is the kind of thing that looks wired up
6850        // and answers `Unsaid` forever.
6851        let green = parse_pr(GREEN_OPEN).expect("parse");
6852        assert_eq!(green.blocking, Blocking::No);
6853        let red = parse_pr(RED_OPEN).expect("parse");
6854        assert_eq!(
6855            red.blocking,
6856            Blocking::No,
6857            "`UNSTABLE` is mergeable: the red check is one nobody requires"
6858        );
6859        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6860        // A payload from an older `gh` has no such field at all.
6861        let quiet =
6862            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6863        assert_eq!(quiet.blocking, Blocking::Unsaid);
6864    }
6865
6866    #[test]
6867    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6868        // Pull request 37's only red check was `editorconfig`, failing
6869        // because the action could not fetch its own binary after
6870        // editorconfig-checker v4 renamed its release assets. The repository
6871        // does not require it. magi answered by asking a fixer to repair a
6872        // change that was fine, and the pull request had to be merged by hand.
6873        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6874        nonblocking.blocking = Blocking::No;
6875        assert_eq!(
6876            decide(&nonblocking, 0, 4, Duration::ZERO),
6877            Step::Merge,
6878            "the forge says nothing is in the way, so nothing is"
6879        );
6880
6881        // The same red, gated on: that is a fix round, as before.
6882        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6883        blocking.blocking = Blocking::Yes;
6884        assert!(matches!(
6885            decide(&blocking, 0, 4, Duration::ZERO),
6886            Step::Fix { .. }
6887        ));
6888
6889        // A review comment still outranks green-enough: a non-required red
6890        // must not become a way to merge past an unanswered reviewer.
6891        let mut commented = pr(Checks::Red, &["coverage"], 1);
6892        commented.blocking = Blocking::No;
6893        assert!(matches!(
6894            decide(&commented, 0, 4, Duration::ZERO),
6895            Step::Fix { .. }
6896        ));
6897
6898        // And silence from the forge is not consent.
6899        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6900        unsaid.blocking = Blocking::Unsaid;
6901        assert_eq!(decide(&unsaid, 0, 4, Duration::ZERO), Step::Wait);
6902        assert!(matches!(
6903            decide(&unsaid, 0, 4, CHECKS_GRACE),
6904            Step::Fix { .. }
6905        ));
6906    }
6907
6908    #[test]
6909    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6910        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6911        red.blocking = Blocking::No;
6912        assert_eq!(
6913            decide(&red, 0, 4, Duration::ZERO),
6914            Step::Merge,
6915            "announcing must not change the decision"
6916        );
6917        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6918        assert!(said.contains("yukimemi/magi"), "{said}");
6919        assert!(said.contains("#16"), "{said}");
6920        assert!(
6921            said.contains("https://github.com/yukimemi/magi/pull/16"),
6922            "{said}"
6923        );
6924        assert!(
6925            said.contains("test (windows-latest)") && said.contains("coverage"),
6926            "{said}"
6927        );
6928
6929        // `failing` can be left over on a green observation; only `checks` counts.
6930        let green = pr(Checks::Green, &["stale"], 0);
6931        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6932    }
6933
6934    #[test]
6935    fn red_merge_notices_omit_empty_check_details() {
6936        for checks in [vec![], vec!["", "  "]] {
6937            let red = pr(Checks::Red, &checks, 0);
6938            let summary = red_merge_summary("owner/repo", &red).unwrap();
6939            let notice = crate::notices::merged_red("run-1", &summary);
6940            assert_eq!(
6941                notice.message,
6942                format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6943            );
6944            assert_eq!(notice.severity, crate::notices::Severity::Warn);
6945        }
6946        let red = pr(Checks::Red, &["", " test ", " "], 0);
6947        let summary = red_merge_summary("owner/repo", &red).unwrap();
6948        assert!(summary.contains("with red checks: test ("), "{summary}");
6949    }
6950
6951    #[test]
6952    fn the_repo_label_comes_from_the_pull_request_url() {
6953        let p = Path::new("/tmp/checkout");
6954        assert_eq!(
6955            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6956            "yukimemi/magi"
6957        );
6958        assert_eq!(repo_label(p, "not a url"), "checkout");
6959    }
6960
6961    #[test]
6962    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6963        // Pull requests 35 and 37 were both rebased by hand: a competition
6964        // that runs for two hours against a repository merging pull requests
6965        // all day conflicts on the way in, and that is arithmetic rather
6966        // than a defect in the change.
6967        let mut conflicted = pr(Checks::Green, &[], 0);
6968        conflicted.blocking = Blocking::Conflict;
6969        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6970
6971        // Decided before the checks, and even with the rounds spent: every
6972        // check on a branch that cannot land is an answer about a state that
6973        // cannot land, and a conflict is not the change's fault.
6974        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6975        red.blocking = Blocking::Conflict;
6976        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6977
6978        // The lifecycle still wins over everything, conflict included.
6979        let mut merged = pr(Checks::Red, &[], 0);
6980        merged.blocking = Blocking::Conflict;
6981        merged.state = PrLifecycle::Merged;
6982        assert_eq!(
6983            decide(&merged, 0, 4, Duration::ZERO),
6984            Step::Done { merged: true }
6985        );
6986    }
6987
6988    #[test]
6989    fn the_forge_verdict_is_read_off_merge_state_status() {
6990        // The spellings that mean "mergeable". `UNSTABLE` is the one that
6991        // matters: mergeable, with a non-required check red or still running.
6992        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6993            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6994            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6995        }
6996        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6997        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6998        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6999        // An older `gh`, or a token without the scope, says nothing - and
7000        // refusing to guess is the rule everywhere else in this module.
7001        for quiet in ["", "UNKNOWN"] {
7002            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
7003            assert!(Blocking::of(quiet).stops_a_merge());
7004        }
7005    }
7006
7007    #[test]
7008    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
7009        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
7010        // The exact stderr from run ec12, in a jj-colocated repository.
7011        let jj = "could not determine current branch: failed to run git: not on any branch";
7012
7013        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
7014            .expect("the forge says merged, so it merged");
7015        assert!(landed.ok);
7016        assert!(
7017            landed.detail.contains("but the pull request is merged"),
7018            "the record must not read as a clean success: {}",
7019            landed.detail
7020        );
7021        assert!(
7022            landed.detail.contains("not on any branch"),
7023            "and it must keep what the command actually said: {}",
7024            landed.detail
7025        );
7026
7027        // A pull request still open means the merge really failed.
7028        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
7029        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
7030        // And an unreadable answer is not evidence of success.
7031        assert!(merged_after_all(&argv, jj, None).is_none());
7032    }
7033
7034    #[test]
7035    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
7036        let mut state = pr(Checks::Red, &["editorconfig"], 3);
7037        state.state = PrLifecycle::Closed;
7038        assert_eq!(
7039            decide(&state, 0, 4, Duration::ZERO),
7040            Step::Done { merged: false },
7041            "a human closing the pull request ends the loop, whatever CI says"
7042        );
7043    }
7044
7045    #[test]
7046    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
7047        let red = decide(
7048            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
7049            4,
7050            4,
7051            Duration::ZERO,
7052        );
7053        match red {
7054            Step::GiveUp { reason } => {
7055                assert!(reason.contains("editorconfig"), "reason: {reason}");
7056                assert!(reason.contains("test (macos)"), "reason: {reason}");
7057                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
7058            }
7059            other => panic!("expected a give-up, got {other:?}"),
7060        }
7061
7062        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
7063        match commented {
7064            Step::GiveUp { reason } => {
7065                assert!(reason.contains("unresolved"), "reason: {reason}");
7066                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
7067            }
7068            other => panic!("expected a give-up, got {other:?}"),
7069        }
7070    }
7071
7072    #[test]
7073    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
7074        let candidate_commit = "magi: candidate A (uncommitted work)";
7075        let subject = merge_subject(candidate_commit, "add retries to the uploader");
7076        let argv = merge_argv(16, &subject);
7077
7078        assert!(argv.contains(&"--squash".to_owned()));
7079        assert!(argv.contains(&"--delete-branch".to_owned()));
7080        assert!(argv.contains(&"--subject".to_owned()));
7081        assert_eq!(
7082            argv.last().map(String::as_str),
7083            Some("add retries to the uploader"),
7084            "the subject must not be the candidate commit message"
7085        );
7086        assert_ne!(subject, candidate_commit);
7087    }
7088
7089    #[test]
7090    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
7091        assert_eq!(
7092            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
7093            "feat: a queue, an unattended loop, and a phone UI"
7094        );
7095        assert_eq!(
7096            merge_subject("", "# port the retry logic\n\ndetails"),
7097            "port the retry logic",
7098            "an empty title falls back to the task's first line, heading marks stripped"
7099        );
7100    }
7101
7102    #[test]
7103    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
7104        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
7105        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
7106        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
7107        assert_eq!(job_of("https://coderabbit.ai/status"), None);
7108        assert_eq!(run_of(""), None);
7109    }
7110
7111    #[test]
7112    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
7113        let mut out = Vec::new();
7114        push_if_outstanding(
7115            &mut out,
7116            ReviewComment {
7117                author: "yukimemi".to_owned(),
7118                path: None,
7119                line: None,
7120                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
7121            },
7122        );
7123        assert!(out.is_empty());
7124    }
7125
7126    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
7127    /// falls back to the repository - which keeps these tests free of a
7128    /// worktree, a `git` invocation and a network.
7129    fn run_state() -> RunState {
7130        let mut state = RunState::new(
7131            std::path::PathBuf::from("/repo/magi"),
7132            "main".to_owned(),
7133            "abcdef1234".to_owned(),
7134            "add retries to the uploader".to_owned(),
7135            crate::config::Config::default(),
7136        );
7137        // Tests run in parallel against one persistent home and the ids
7138        // `RunState::new` draws from the clock can repeat, so two tests would
7139        // share a run's questions. The home also outlives the process, so the
7140        // counter alone would reuse an earlier run's ids.
7141        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7142        let nanos = std::time::SystemTime::now()
7143            .duration_since(std::time::UNIX_EPOCH)
7144            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
7145        state.id = format!(
7146            "20261004-{nanos:06}-{:04x}",
7147            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
7148        );
7149        state
7150    }
7151
7152    fn green_pr() -> PrState {
7153        PrState {
7154            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
7155            number: 42,
7156            state: PrLifecycle::Open,
7157            checks: Checks::Green,
7158            // The forge sees nothing in the way unless a test says otherwise.
7159            blocking: Blocking::No,
7160            failing: Vec::new(),
7161            review_comments: vec![ReviewComment {
7162                author: "coderabbitai".to_owned(),
7163                path: Some("src/land.rs".to_owned()),
7164                line: Some(212),
7165                body: "this branch never checks the exit code".to_owned(),
7166            }],
7167        }
7168    }
7169
7170    #[test]
7171    fn land_fixer_defers_review_and_verification_without_a_cache() {
7172        let state = run_state();
7173        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7174        assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
7175        assert!(p.contains("Commit in this worktree"), "{p}");
7176    }
7177
7178    #[test]
7179    fn github_facing_land_text_is_english_whatever_the_language() {
7180        let mut state = run_state();
7181        state.config.graph.language = "ja".to_owned();
7182        let comment = stop_comment(&state.id, "checks are still red");
7183        assert!(crate::github_text::check("", &comment).is_empty());
7184        assert!(comment.is_ascii(), "{comment}");
7185        assert!(comment.starts_with(MARKER));
7186
7187        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7188        let ja_at = p.find("Write all prose in ja").unwrap();
7189        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
7190        assert!(ja_at < rule_at, "{p}");
7191        assert!(p.contains("stays in Japanese"), "{p}");
7192
7193        state.config.graph.language = "en".to_owned();
7194        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7195        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
7196        assert!(!p.contains("does not apply"), "{p}");
7197    }
7198
7199    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
7200
7201    fn panel() -> String {
7202        approval_panel(
7203            &run_state(),
7204            &green_pr(),
7205            NUMSTAT,
7206            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
7207            &[
7208                "land: ask before merging".to_owned(),
7209                "land: colour the diff".to_owned(),
7210            ],
7211            "feat: merge approval from the phone",
7212        )
7213    }
7214
7215    #[test]
7216    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
7217        let html = panel();
7218        for needle in [
7219            "42",
7220            "main",
7221            "src/land.rs",
7222            "src/web.rs",
7223            "assets/logo.png",
7224            "feat: merge approval from the phone",
7225            "land: ask before merging",
7226            "land: colour the diff",
7227            "coderabbitai",
7228            "this branch never checks the exit code",
7229            "green",
7230        ] {
7231            assert!(html.contains(needle), "the panel must state `{needle}`");
7232        }
7233    }
7234
7235    async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
7236        crate::run::pin_test_home();
7237        let tmp = tempfile::tempdir().unwrap();
7238        let repo = tmp.path().join("repo");
7239        let remote = tmp.path().join("remote.git");
7240        std::fs::create_dir(&repo).unwrap();
7241        git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
7242            .await
7243            .unwrap();
7244        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
7245        git::git(&repo, &["config", "user.name", "Test"])
7246            .await
7247            .unwrap();
7248        git::git(&repo, &["config", "user.email", "test@example.test"])
7249            .await
7250            .unwrap();
7251        std::fs::write(repo.join("base"), "base").unwrap();
7252        git::git(&repo, &["add", "."]).await.unwrap();
7253        git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
7254        git::git(
7255            &repo,
7256            &["remote", "add", "origin", remote.to_str().unwrap()],
7257        )
7258        .await
7259        .unwrap();
7260        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
7261        git::git(&repo, &["checkout", "-b", "winner"])
7262            .await
7263            .unwrap();
7264        std::fs::write(repo.join("change"), "change").unwrap();
7265        git::git(&repo, &["add", "."]).await.unwrap();
7266        git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
7267        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
7268        git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
7269            .await
7270            .unwrap();
7271        let mut state = run_state();
7272        state.repo = repo.clone();
7273        state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
7274        state.config.graph.land = true;
7275        state.config.graph.land_approval = true;
7276        state.pr_head = Some("task-head".into());
7277        state.pr_pushed_head = Some(head.clone());
7278        state.candidates = vec![winning_candidate("change")];
7279        state.candidates[0].worktree = repo;
7280        state.candidates[0].branch = "winner".into();
7281        state.tally = Some(uncontested_tally());
7282        state.gate_ran = true;
7283        state.gate_head = Some(head.clone());
7284        state.land_sync_pending = true;
7285        state.save().unwrap();
7286        (tmp, state, head)
7287    }
7288
7289    async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
7290        let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
7291        resume_landing_head(state, head, &green_pr().url, &forge).await
7292    }
7293
7294    async fn advance_landing_base(state: &RunState) {
7295        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7296        std::fs::write(state.repo.join("advance"), "advance").unwrap();
7297        git::git(&state.repo, &["add", "."]).await.unwrap();
7298        git::git(&state.repo, &["commit", "-m", "advance base"])
7299            .await
7300            .unwrap();
7301        git::git(&state.repo, &["push", "origin", "main"])
7302            .await
7303            .unwrap();
7304        git::git(&state.repo, &["checkout", "winner"])
7305            .await
7306            .unwrap();
7307    }
7308
7309    async fn foreign_landing_fixture() -> (tempfile::TempDir, RunState, String, std::path::PathBuf)
7310    {
7311        let (tmp, mut state, old) = landing_git_fixture().await;
7312        let path = tmp.path().join("foreign");
7313        git::worktree_add_branch(&state.repo, &path, "task-head", &old)
7314            .await
7315            .unwrap();
7316        state.foreign_checkout = Some(
7317            crate::foreign_checkout::inspect(&state.repo, "task-head", &path)
7318                .await
7319                .unwrap(),
7320        );
7321        state.config.merge.mode = crate::config::MergeMode::Pr;
7322        state.save().unwrap();
7323        (tmp, state, old, path)
7324    }
7325
7326    #[tokio::test]
7327    async fn foreign_landing_sync_and_interrupted_resumes_preserve_delivery_ancestry() {
7328        for phase in 0..4 {
7329            let (_tmp, mut state, old, path) = foreign_landing_fixture().await;
7330            advance_landing_base(&state).await;
7331            state.config.verify.gate = vec!["test -f advance && test -f change".into()];
7332            if phase == 0 {
7333                // The live Step::Rebase path uses this same synchronization
7334                // and publication pair, without going through resume.
7335                assert_eq!(
7336                    rebase_landing_worktree(&mut state, "origin/main")
7337                        .await
7338                        .unwrap(),
7339                    crate::rebase::Rebased::Applied
7340                );
7341            } else {
7342                interrupt_landing_rebase(&mut state).await;
7343                let repaired_tree = git::tree_of(&state.repo, "HEAD").await.unwrap();
7344                if phase == 2 {
7345                    git::sync_to_head(&state.repo).await.unwrap();
7346                } else if phase == 3 {
7347                    // Crash after joining history, before clearing its saved
7348                    // start. Repeating restoration must not add another merge.
7349                    restore_landing_worktree(&mut state).await.unwrap();
7350                    let joined = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7351                    state.land_rebase_from = Some(old.clone());
7352                    state.save().unwrap();
7353                    restore_landing_worktree(&mut state).await.unwrap();
7354                    assert_eq!(git::rev_parse(&state.repo, "HEAD").await.unwrap(), joined);
7355                }
7356                state.save().unwrap();
7357                state = RunState::load(&state.id).unwrap();
7358                restore_landing_worktree(&mut state).await.unwrap();
7359                assert_eq!(
7360                    git::tree_of(&state.repo, "HEAD").await.unwrap(),
7361                    repaired_tree
7362                );
7363            }
7364            let head = if phase == 0 {
7365                let forge = Scripted::new(vec![seen(&old, Checks::Green, "CLEAN", false)], vec![]);
7366                publish_landing_head(&mut state, &old, &green_pr().url, &forge)
7367                    .await
7368                    .unwrap()
7369            } else {
7370                resume_observed_head(&mut state, &old).await.unwrap()
7371            };
7372            assert!(git::is_ancestor(&state.repo, &old, &head).await);
7373            assert!(git::is_ancestor(&state.repo, "origin/main", &head).await);
7374            assert_eq!(git::rev_parse(&path, "HEAD").await.unwrap(), head);
7375            assert_eq!(
7376                git::remote_head(&state.repo, "origin", "task-head")
7377                    .await
7378                    .unwrap(),
7379                Some(head.clone())
7380            );
7381            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7382            assert_eq!(state.foreign_checkout.as_ref().unwrap().synced_head, head);
7383            assert!(state.gate_allows_progress());
7384            assert!(state.land_rebase_from.is_none());
7385            assert!(!state.land_sync_pending);
7386            assert!(git::is_clean(&path).await.unwrap());
7387        }
7388    }
7389
7390    #[tokio::test]
7391    async fn foreign_landing_conflict_repair_can_resume_and_publish_without_losing_ancestry() {
7392        let (_tmp, mut state, old, path) = foreign_landing_fixture().await;
7393        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7394        std::fs::write(state.repo.join("change"), "conflicting base change").unwrap();
7395        git::commit_all(&state.repo, "conflicting base")
7396            .await
7397            .unwrap();
7398        git::git(&state.repo, &["push", "origin", "main"])
7399            .await
7400            .unwrap();
7401        git::git(&state.repo, &["checkout", "winner"])
7402            .await
7403            .unwrap();
7404        landing_gate_fixer(
7405            &mut state,
7406            "cat >/dev/null; printf resolved > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"resolved\"}'",
7407        );
7408        state.config.graph.gate_fix_rounds = 0;
7409        state.config.verify.gate = vec!["exit 1".into()];
7410        assert!(resume_observed_head(&mut state, &old).await.is_err());
7411        assert!(state.rebase_fixes.iter().any(|fix| fix.finished));
7412        let repaired = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7413        assert!(git::is_ancestor(&state.repo, &old, &repaired).await);
7414        assert_eq!(
7415            std::fs::read_to_string(state.repo.join("change")).unwrap(),
7416            "resolved"
7417        );
7418        // A red gate must neither deliver nor publish the repaired tree.
7419        assert_eq!(git::rev_parse(&path, "HEAD").await.unwrap(), old);
7420        assert_eq!(
7421            git::remote_head(&state.repo, "origin", "task-head")
7422                .await
7423                .unwrap(),
7424            Some(old.clone())
7425        );
7426        state = RunState::load(&state.id).unwrap();
7427        state.config.verify.gate = vec!["grep -q resolved change".into()];
7428        let head = resume_observed_head(&mut state, &old).await.unwrap();
7429        assert_eq!(head, repaired);
7430        assert_eq!(git::rev_parse(&path, "HEAD").await.unwrap(), head);
7431        assert_eq!(
7432            std::fs::read_to_string(path.join("change")).unwrap(),
7433            "resolved"
7434        );
7435        assert_eq!(
7436            git::remote_head(&state.repo, "origin", "task-head")
7437                .await
7438                .unwrap(),
7439            Some(head.clone())
7440        );
7441        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7442        assert!(git::is_ancestor(&state.repo, "origin/main", &head).await);
7443        assert!(git::is_clean(&path).await.unwrap());
7444    }
7445
7446    #[tokio::test]
7447    async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
7448        let (_tmp, mut state, old) = landing_git_fixture().await;
7449        advance_landing_base(&state).await;
7450        state.config.verify.gate = vec!["test -f advance".into()];
7451        let head = resume_observed_head(&mut state, &old).await.unwrap();
7452        assert_ne!(head, old);
7453        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7454        assert!(state.gate_allows_progress());
7455        assert_eq!(
7456            git::remote_head(&state.repo, "origin", "task-head")
7457                .await
7458                .unwrap(),
7459            Some(head.clone())
7460        );
7461        assert_eq!(state.pr_pushed_head, Some(head));
7462        assert!(!state.land_sync_pending);
7463        assert!(state.config.graph.land_approval);
7464    }
7465
7466    fn landing_gate_fixer(state: &mut RunState, script: &str) {
7467        state.config.disk.min_free_bytes = 0;
7468        state.config.graph.gate_fix_rounds = 1;
7469        state.config.agents = vec![crate::config::AgentSpec {
7470            id: "mock".into(),
7471            kind: crate::config::AgentKind::Command,
7472            model: None,
7473            command: vec!["sh".into(), "-c".into(), script.into()],
7474            extra_args: Vec::new(),
7475            env: Default::default(),
7476            prompt_delivery: None,
7477            max_turn_secs: None,
7478        }];
7479        state.candidates[0].agent = "mock".into();
7480    }
7481
7482    #[tokio::test]
7483    async fn landing_resume_revalidates_an_operator_published_winner_extension() {
7484        let (_tmp, mut state, old) = landing_git_fixture().await;
7485        std::fs::write(state.repo.join("change"), "operator update").unwrap();
7486        git::commit_all(&state.repo, "extend winner").await.unwrap();
7487        let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7488        git::git(
7489            &state.repo,
7490            &["push", "origin", "HEAD:refs/heads/task-head"],
7491        )
7492        .await
7493        .unwrap();
7494        state.config.verify.gate = vec!["exit 1".into()];
7495        state.config.graph.gate_fix_rounds = 0;
7496        assert!(resume_observed_head(&mut state, &updated).await.is_err());
7497        assert_ne!(old, updated);
7498        assert_eq!(state.gate_head.as_ref(), Some(&updated));
7499        assert!(!state.gate_allows_progress());
7500        assert!(state.land_sync_pending);
7501        state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
7502        let head = resume_observed_head(&mut state, &updated).await.unwrap();
7503        assert_eq!(head, updated);
7504        assert!(state.gate_allows_progress());
7505        assert!(!state.land_sync_pending);
7506        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7507        land_with(&mut state, &green_pr().url, &forge)
7508            .await
7509            .unwrap();
7510        assert!(state.parked);
7511        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7512        assert!(forge.argvs().is_empty());
7513    }
7514
7515    #[tokio::test]
7516    async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
7517        for divergent in [false, true] {
7518            let (_tmp, mut state, old) = landing_git_fixture().await;
7519            if divergent {
7520                git::git(&state.repo, &["reset", "--hard", "origin/main"])
7521                    .await
7522                    .unwrap();
7523            }
7524            std::fs::write(state.repo.join("external"), "external").unwrap();
7525            git::commit_all(&state.repo, "external update")
7526                .await
7527                .unwrap();
7528            let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7529            git::git(
7530                &state.repo,
7531                &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
7532            )
7533            .await
7534            .unwrap();
7535            if !divergent {
7536                git::git(&state.repo, &["reset", "--hard", &old])
7537                    .await
7538                    .unwrap();
7539            }
7540            let error = resume_observed_head(&mut state, &external)
7541                .await
7542                .unwrap_err();
7543            assert!(
7544                error
7545                    .to_string()
7546                    .contains("public head moved outside this run")
7547            );
7548            assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
7549            assert_eq!(
7550                git::remote_head(&state.repo, "origin", "task-head")
7551                    .await
7552                    .unwrap(),
7553                Some(external)
7554            );
7555        }
7556    }
7557
7558    #[tokio::test]
7559    async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
7560        let (_tmp, mut state, old) = landing_git_fixture().await;
7561        advance_landing_base(&state).await;
7562        landing_gate_fixer(
7563            &mut state,
7564            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7565        );
7566        state.config.verify.gate =
7567            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7568        state.config.verify.e2e = vec!["test -f advance".into()];
7569        // First save a red gate without spending a fixer round. Resume must
7570        // still offer the remaining budget for this exact same failed head.
7571        state.config.graph.gate_fix_rounds = 0;
7572        assert!(resume_observed_head(&mut state, &old).await.is_err());
7573        assert_eq!(
7574            git::remote_head(&state.repo, "origin", "task-head")
7575                .await
7576                .unwrap(),
7577            Some(old.clone())
7578        );
7579        state.config.graph.gate_fix_rounds = 1;
7580        let head = resume_observed_head(&mut state, &old).await.unwrap();
7581        assert_eq!(state.gate_fixes.len(), 1);
7582        assert!(state.gate_fixes[0].committed);
7583        assert!(state.gate_allows_progress());
7584        assert_eq!(state.gate_head.as_ref(), Some(&head));
7585        assert_eq!(
7586            git::remote_head(&state.repo, "origin", "task-head")
7587                .await
7588                .unwrap(),
7589            Some(head.clone())
7590        );
7591        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7592        land_with(&mut state, &green_pr().url, &forge)
7593            .await
7594            .unwrap();
7595        assert!(state.parked);
7596        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7597        assert!(forge.argvs().is_empty());
7598    }
7599
7600    #[tokio::test]
7601    async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7602        let (_tmp, mut state, old) = landing_git_fixture().await;
7603        advance_landing_base(&state).await;
7604        landing_gate_fixer(
7605            &mut state,
7606            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7607        );
7608        state.config.verify.gate = vec!["echo still red; exit 1".into()];
7609        for _ in 0..2 {
7610            assert!(resume_observed_head(&mut state, &old).await.is_err());
7611            assert_eq!(state.gate_fixes.len(), 1);
7612            assert_eq!(
7613                git::remote_head(&state.repo, "origin", "task-head")
7614                    .await
7615                    .unwrap(),
7616                Some(old.clone())
7617            );
7618        }
7619    }
7620
7621    #[tokio::test]
7622    async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7623        let (_tmp, mut state, old) = landing_git_fixture().await;
7624        advance_landing_base(&state).await;
7625        landing_gate_fixer(
7626            &mut state,
7627            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7628        );
7629        state.config.verify.gate =
7630            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7631        // The repair makes the gate green while breaking an e2e invariant.
7632        state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7633        for resume in 0..3 {
7634            assert!(resume_observed_head(&mut state, &old).await.is_err());
7635            state = RunState::load(&state.id).unwrap();
7636            let evidence = state.gate_repair_e2e.as_ref().unwrap();
7637            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7638            assert_eq!(evidence.head, head);
7639            assert!(!evidence.passed);
7640            assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7641            assert_eq!(state.gate_fixes.len(), 1);
7642            assert_eq!(
7643                git::remote_head(&state.repo, "origin", "task-head")
7644                    .await
7645                    .unwrap(),
7646                Some(old.clone())
7647            );
7648            if resume > 0 {
7649                assert!(
7650                    state.gate_allows_progress(),
7651                    "a green gate must not bypass red e2e"
7652                );
7653            }
7654        }
7655        // Once e2e genuinely succeeds on the same SHA, the saved requirement
7656        // can be satisfied without another fixer or approval bypass.
7657        state.config.verify.e2e = vec!["grep -q fixed change".into()];
7658        let head = resume_observed_head(&mut state, &old).await.unwrap();
7659        assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7660        assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7661        assert_eq!(state.gate_fixes.len(), 1);
7662        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7663        land_with(&mut state, &green_pr().url, &forge)
7664            .await
7665            .unwrap();
7666        assert!(state.parked);
7667        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7668        assert!(forge.argvs().is_empty());
7669    }
7670
7671    #[tokio::test]
7672    async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7673        for phase in 0..3 {
7674            let (_tmp, mut state, old) = landing_git_fixture().await;
7675            advance_landing_base(&state).await;
7676            let head = resume_observed_head(&mut state, &old).await.unwrap();
7677            // Persist the shapes left by interruption, resource deferral,
7678            // and a successful check for a different (older) SHA.
7679            state.land_sync_pending = true;
7680            state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7681            state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7682                head: if phase == 2 {
7683                    old.clone()
7684                } else {
7685                    head.clone()
7686                },
7687                passed: phase == 2,
7688                outcomes: if phase == 1 {
7689                    vec![crate::run::CommandOutcome {
7690                        command: state.config.verify.e2e[0].clone(),
7691                        code: None,
7692                        output_tail: "verification resource unavailable".into(),
7693                        duration_ms: 0,
7694                        resource_blocked: true,
7695                    }]
7696                } else {
7697                    Vec::new()
7698                },
7699            });
7700            state.save().unwrap();
7701            for _ in 0..2 {
7702                state = RunState::load(&state.id).unwrap();
7703                assert!(resume_observed_head(&mut state, &head).await.is_err());
7704                let evidence = state.gate_repair_e2e.as_ref().unwrap();
7705                assert_eq!(evidence.head, head);
7706                assert!(!evidence.passed);
7707                assert!(!evidence.outcomes.is_empty());
7708                assert_eq!(
7709                    git::remote_head(&state.repo, "origin", "task-head")
7710                        .await
7711                        .unwrap(),
7712                    Some(head.clone())
7713                );
7714                assert!(state.land_sync_pending);
7715            }
7716        }
7717    }
7718
7719    async fn interrupt_landing_rebase(state: &mut RunState) {
7720        state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7721        state.save().unwrap();
7722        let scratch = state.dir().join("rebase");
7723        assert_eq!(
7724            crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7725                .await
7726                .unwrap(),
7727            crate::rebase::Rebased::Applied
7728        );
7729        assert!(!git::is_clean(&state.repo).await.unwrap());
7730    }
7731
7732    async fn landing_setup(state: &mut RunState) {
7733        git::git(
7734            &state.repo,
7735            &["config", "extensions.worktreeConfig", "true"],
7736        )
7737        .await
7738        .unwrap();
7739        state.config.worktree.setup =
7740            vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7741        crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7742            .await
7743            .unwrap();
7744        state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7745    }
7746
7747    #[tokio::test]
7748    async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7749        for phase in 0..3 {
7750            let (_tmp, mut state, old) = landing_git_fixture().await;
7751            advance_landing_base(&state).await;
7752            landing_setup(&mut state).await;
7753            if phase > 0 {
7754                interrupt_landing_rebase(&mut state).await;
7755                if phase == 2 {
7756                    // Crash after clean removed setup products, before setup.
7757                    git::sync_to_head(&state.repo).await.unwrap();
7758                    assert!(!state.repo.join(".env").exists());
7759                }
7760                state = RunState::load(&state.id).unwrap();
7761            }
7762            let head = resume_observed_head(&mut state, &old).await.unwrap();
7763            assert_ne!(head, old);
7764            assert!(state.gate_allows_progress());
7765            assert!(state.land_rebase_from.is_none());
7766            assert_eq!(
7767                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7768                "secret"
7769            );
7770            assert!(git::is_clean(&state.repo).await.unwrap());
7771            assert!(
7772                !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7773                    .await
7774                    .unwrap()
7775                    .contains(".env")
7776            );
7777        }
7778    }
7779
7780    #[tokio::test]
7781    async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7782        let (_tmp, mut state, _) = landing_git_fixture().await;
7783        advance_landing_base(&state).await;
7784        landing_setup(&mut state).await;
7785        interrupt_landing_rebase(&mut state).await;
7786        // Exercise unusual names without asking Windows to create an invalid
7787        // control-character filename. Unix also covers embedded newlines.
7788        let locals = [
7789            "local notes",
7790            #[cfg(unix)]
7791            "local\nnotes",
7792        ];
7793        let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7794            .await
7795            .unwrap();
7796        std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7797        for local in locals {
7798            std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7799            assert!(
7800                git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7801                    .await
7802                    .unwrap()
7803                    .ok()
7804            );
7805        }
7806        restore_landing_worktree(&mut state).await.unwrap();
7807        for local in locals {
7808            assert_eq!(
7809                std::fs::read_to_string(state.repo.join(local)).unwrap(),
7810                "keep these notes"
7811            );
7812        }
7813        assert_eq!(
7814            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7815            "secret"
7816        );
7817        assert!(git::is_clean(&state.repo).await.unwrap());
7818        assert!(state.land_rebase_from.is_none());
7819    }
7820
7821    #[tokio::test]
7822    async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7823        for local in ["advance", "advance/notes", "new-directory"] {
7824            let (_tmp, mut state, _) = landing_git_fixture().await;
7825            advance_landing_base(&state).await;
7826            // Also cover a local file blocking a new tracked directory.
7827            git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7828            std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7829            std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7830            git::git(&state.repo, &["add", "."]).await.unwrap();
7831            git::git(&state.repo, &["commit", "-m", "add directory"])
7832                .await
7833                .unwrap();
7834            git::git(&state.repo, &["push", "origin", "main"])
7835                .await
7836                .unwrap();
7837            git::git(&state.repo, &["checkout", "winner"])
7838                .await
7839                .unwrap();
7840            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7841                .await
7842                .unwrap();
7843            std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7844            let path = state.repo.join(local);
7845            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7846            std::fs::write(&path, "local notes").unwrap();
7847            interrupt_landing_rebase(&mut state).await;
7848            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7849            let checkpoint = state.land_rebase_from.clone();
7850            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7851            assert!(
7852                error.to_string().contains("conflicting with HEAD"),
7853                "{error:#}"
7854            );
7855            assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7856            assert_eq!(
7857                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7858                before
7859            );
7860            assert_eq!(state.land_rebase_from, checkpoint);
7861        }
7862    }
7863
7864    #[tokio::test]
7865    async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7866        for interrupted in [false, true] {
7867            let (_tmp, mut state, old) = landing_git_fixture().await;
7868            assert!(state.config.worktree.setup.is_empty());
7869            // A gate product exists before the base moves, without setup metadata.
7870            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7871                .await
7872                .unwrap();
7873            std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7874            state.config.verify.gate =
7875                vec!["mkdir -p target && printf artifact > target/probe".into()];
7876            state.gate_ran = false;
7877            resume_observed_head(&mut state, &old).await.unwrap();
7878            assert_eq!(
7879                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7880                "artifact"
7881            );
7882            advance_landing_base(&state).await;
7883            if interrupted {
7884                interrupt_landing_rebase(&mut state).await;
7885                state = RunState::load(&state.id).unwrap();
7886            }
7887            state.config.verify.gate =
7888                vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7889            let head = resume_observed_head(&mut state, &old).await.unwrap();
7890            assert_ne!(head, old);
7891            assert!(state.gate_allows_progress());
7892            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7893            assert_eq!(
7894                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7895                "artifact"
7896            );
7897            assert!(state.land_rebase_from.is_none());
7898            assert_eq!(
7899                git::remote_head(&state.repo, "origin", "task-head")
7900                    .await
7901                    .unwrap(),
7902                Some(head.clone())
7903            );
7904            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7905            land_with(&mut state, &green_pr().url, &forge)
7906                .await
7907                .unwrap();
7908            assert!(state.parked);
7909            assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7910            assert!(forge.argvs().is_empty());
7911        }
7912    }
7913
7914    #[tokio::test]
7915    #[cfg(unix)]
7916    async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7917        use std::os::fd::AsRawFd as _;
7918
7919        for interrupted in [false, true] {
7920            let (_tmp, mut state, _) = landing_git_fixture().await;
7921            advance_landing_base(&state).await;
7922            landing_setup(&mut state).await;
7923            if interrupted {
7924                interrupt_landing_rebase(&mut state).await;
7925            }
7926            state.config.worktree.setup =
7927                vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7928            let common = crate::repo_lock::common_dir(&state.repo)
7929                .await
7930                .unwrap()
7931                .unwrap();
7932            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7933            state.config.coordination = crate::config::test_coordination();
7934            state.config.graph.timeout_resource_wait = 0;
7935            let external = std::fs::OpenOptions::new()
7936                .read(true)
7937                .write(true)
7938                .create(true)
7939                .truncate(false)
7940                .open(common.join("updater.lock"))
7941                .unwrap();
7942            // SAFETY: the descriptor belongs to `external` and remains open.
7943            let locked =
7944                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7945            assert_eq!(locked, 0);
7946            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7947            let checkpoint = state.land_rebase_from.clone();
7948            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7949            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7950            assert_eq!(
7951                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7952                before
7953            );
7954            assert_eq!(state.land_rebase_from, checkpoint);
7955            assert_eq!(
7956                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7957                "secret"
7958            );
7959
7960            drop(external);
7961            state.config.graph.timeout_resource_wait = 2;
7962            restore_landing_worktree(&mut state).await.unwrap();
7963            assert!(git::is_clean(&state.repo).await.unwrap());
7964            assert!(state.land_rebase_from.is_none());
7965            assert_eq!(
7966                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7967                "restored"
7968            );
7969        }
7970    }
7971
7972    #[tokio::test]
7973    #[cfg(unix)]
7974    async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7975        use std::sync::atomic::{AtomicBool, Ordering};
7976
7977        let (_tmp, mut state, _) = landing_git_fixture().await;
7978        advance_landing_base(&state).await;
7979        landing_setup(&mut state).await;
7980        interrupt_landing_rebase(&mut state).await;
7981        let common = crate::repo_lock::common_dir(&state.repo)
7982            .await
7983            .unwrap()
7984            .unwrap();
7985        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7986        state.config.coordination = crate::config::test_coordination();
7987        let holder = crate::repo_lock::SeatLease::for_checkout(
7988            &state.repo,
7989            true,
7990            state.config.coordination.as_ref(),
7991        )
7992        .await
7993        .unwrap();
7994        assert!(holder.is_some());
7995        let cancelled = Arc::new(AtomicBool::new(false));
7996        let signal = cancelled.clone();
7997        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7998            signal.load(Ordering::SeqCst)
7999        });
8000        let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
8001        let checkpoint = state.land_rebase_from.clone();
8002        let (result, ()) = tokio::join!(
8003            tokio::time::timeout(
8004                Duration::from_secs(2),
8005                wait.scope(restore_landing_worktree(&mut state)),
8006            ),
8007            async {
8008                tokio::time::sleep(Duration::from_millis(100)).await;
8009                cancelled.store(true, Ordering::SeqCst);
8010            }
8011        );
8012        let error = result
8013            .expect("cancellation must release the waiting driver")
8014            .unwrap_err();
8015        assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
8016        assert_eq!(
8017            git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
8018            before
8019        );
8020        assert_eq!(state.land_rebase_from, checkpoint);
8021        assert_eq!(
8022            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
8023            "secret"
8024        );
8025        drop(holder);
8026        restore_landing_worktree(&mut state).await.unwrap();
8027        assert!(state.land_rebase_from.is_none());
8028        assert!(git::is_clean(&state.repo).await.unwrap());
8029    }
8030
8031    #[tokio::test]
8032    #[cfg(unix)]
8033    async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
8034        use std::sync::atomic::{AtomicBool, Ordering};
8035
8036        for e2e in [false, true] {
8037            let (_tmp, mut state, head) = landing_git_fixture().await;
8038            let common = crate::repo_lock::common_dir(&state.repo)
8039                .await
8040                .unwrap()
8041                .unwrap();
8042            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
8043            state.config.coordination = crate::config::test_coordination();
8044            if e2e {
8045                state.config.verify.e2e = vec!["true".into()];
8046                state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
8047                    head: head.clone(),
8048                    passed: false,
8049                    outcomes: Vec::new(),
8050                });
8051            } else {
8052                state.config.verify.gate = vec!["true".into()];
8053                state.gate_ran = false;
8054            }
8055            let holder = crate::repo_lock::SeatLease::for_checkout(
8056                &state.repo,
8057                true,
8058                state.config.coordination.as_ref(),
8059            )
8060            .await
8061            .unwrap();
8062            assert!(holder.is_some());
8063            let cancelled = Arc::new(AtomicBool::new(false));
8064            let signal = cancelled.clone();
8065            let waiting = Arc::new(tokio::sync::Notify::new());
8066            let observed = waiting.clone();
8067            let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
8068                observed.notify_one();
8069                signal.load(Ordering::SeqCst)
8070            });
8071            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
8072            let url = green_pr().url;
8073            let result = {
8074                let mut publishing =
8075                    Box::pin(wait.scope(publish_landing_head(&mut state, &head, &url, &forge)));
8076                // Git and fixture I/O precede local coordination. Start the
8077                // cancellation deadline only once the inherited wait policy
8078                // is polled, rather than timing those unrelated subprocesses.
8079                tokio::time::timeout(Duration::from_secs(30), async {
8080                    tokio::select! {
8081                        result = &mut publishing => panic!(
8082                            "verification must wait behind the held seat: {result:?}"
8083                        ),
8084                        () = waiting.notified() => {}
8085                    }
8086                })
8087                .await
8088                .expect("verification must reach the inherited coordination wait");
8089                cancelled.store(true, Ordering::SeqCst);
8090                tokio::time::timeout(Duration::from_secs(2), publishing).await
8091            };
8092            let error = result
8093                .expect("cancellation must release the verification waiter")
8094                .unwrap_err();
8095            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
8096            assert!(error.to_string().contains("cancelled"), "{error:#}");
8097            assert!(forge.argvs().is_empty());
8098            assert_eq!(
8099                git::remote_head(&state.repo, "origin", "task-head")
8100                    .await
8101                    .unwrap(),
8102                Some(head.clone())
8103            );
8104            drop(holder);
8105            // Cancellation must release its reservation so another attempt can verify.
8106            assert_eq!(
8107                publish_landing_head(&mut state, &head, &url, &forge)
8108                    .await
8109                    .unwrap(),
8110                head
8111            );
8112        }
8113    }
8114
8115    #[tokio::test]
8116    async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
8117        for staged in [false, true] {
8118            let (_tmp, mut state, old) = landing_git_fixture().await;
8119            advance_landing_base(&state).await;
8120            interrupt_landing_rebase(&mut state).await;
8121            std::fs::write(state.repo.join("change"), "user edit").unwrap();
8122            if staged {
8123                git::git(&state.repo, &["add", "change"]).await.unwrap();
8124            }
8125            assert!(resume_observed_head(&mut state, &old).await.is_err());
8126            assert_eq!(
8127                std::fs::read_to_string(state.repo.join("change")).unwrap(),
8128                "user edit"
8129            );
8130            assert_eq!(
8131                git::remote_head(&state.repo, "origin", "task-head")
8132                    .await
8133                    .unwrap(),
8134                Some(old)
8135            );
8136        }
8137    }
8138
8139    #[tokio::test]
8140    async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
8141        let (_tmp, mut state, head) = landing_git_fixture().await;
8142        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
8143        let id = state.id.clone();
8144        let pr = land_with(&mut state, &green_pr().url, &forge)
8145            .await
8146            .unwrap();
8147        assert_eq!(pr.state, PrLifecycle::Open);
8148        assert_eq!(state.id, id);
8149        assert!(state.parked);
8150        assert_eq!(state.status, RunStatus::Landing);
8151        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
8152        assert!(forge.argvs().is_empty());
8153
8154        state.land_sync_pending = true;
8155        std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
8156        let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
8157        closed.pr.state = PrLifecycle::Closed;
8158        let forge = Scripted::new(vec![closed], vec![]);
8159        assert_eq!(
8160            land_with(&mut state, &green_pr().url, &forge)
8161                .await
8162                .unwrap()
8163                .state,
8164            PrLifecycle::Closed
8165        );
8166        assert_eq!(
8167            std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
8168            "do not touch"
8169        );
8170        assert!(forge.argvs().is_empty());
8171    }
8172
8173    #[tokio::test]
8174    async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
8175        let (_tmp, mut state, old) = landing_git_fixture().await;
8176        advance_landing_base(&state).await;
8177        state.config.verify.gate = vec!["exit 1".into()];
8178        assert!(resume_observed_head(&mut state, &old).await.is_err());
8179        assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
8180        assert!(!state.gate_allows_progress());
8181        assert_eq!(
8182            git::remote_head(&state.repo, "origin", "task-head")
8183                .await
8184                .unwrap(),
8185            Some(old)
8186        );
8187        assert!(state.land_sync_pending);
8188    }
8189
8190    #[tokio::test]
8191    async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
8192        let (_tmp, mut state, old) = landing_git_fixture().await;
8193        advance_landing_base(&state).await;
8194        let pushed = resume_observed_head(&mut state, &old).await.unwrap();
8195        // Model a crash after the push but before saving its published SHA.
8196        state.pr_pushed_head = Some(old.clone());
8197        state.land_sync_pending = true;
8198        assert_eq!(
8199            resume_observed_head(&mut state, &pushed).await.unwrap(),
8200            pushed
8201        );
8202        // A writer races the observed PR snapshot: the explicit remote lease
8203        // must reject the push even though preflight saw our expected head.
8204        git::git(
8205            &state.repo,
8206            &["push", "--force", "origin", "main:refs/heads/task-head"],
8207        )
8208        .await
8209        .unwrap();
8210        assert!(
8211            publish_landing_head(
8212                &mut state,
8213                &pushed,
8214                &green_pr().url,
8215                &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
8216            )
8217            .await
8218            .unwrap_err()
8219            .to_string()
8220            .contains("publishing gated")
8221        );
8222        assert!(
8223            resume_observed_head(&mut state, "foreign-head")
8224                .await
8225                .is_err()
8226        );
8227    }
8228    #[tokio::test]
8229    async fn landing_publication_refuses_a_pr_closed_during_gate() {
8230        let (_tmp, mut state, head) = landing_git_fixture().await;
8231        let mut closed = seen(&head, Checks::Green, "CLEAN", false);
8232        closed.pr.state = PrLifecycle::Closed;
8233        let forge = Scripted::new(vec![closed], vec![]);
8234        assert!(
8235            publish_landing_head(&mut state, &head, &green_pr().url, &forge)
8236                .await
8237                .unwrap_err()
8238                .to_string()
8239                .contains("PR closed or merged")
8240        );
8241    }
8242
8243    /// A candidate selected by the uncontested tally.
8244    fn winning_candidate(summary: &str) -> Candidate {
8245        Candidate {
8246            index: 0,
8247            label: 'A',
8248            agent: "opus".to_owned(),
8249            branch: "magi/x/A".to_owned(),
8250            worktree: PathBuf::from("/wt/A"),
8251            summary: summary.to_owned(),
8252            stat: String::new(),
8253            files: 1,
8254            commits: 1,
8255            empty: false,
8256            failed: None,
8257            verified_noop: None,
8258            duration_ms: 0,
8259            folded: false,
8260        }
8261    }
8262
8263    fn uncontested_tally() -> Tally {
8264        Tally {
8265            first_choice: BTreeMap::from([('A', 1)]),
8266            borda: BTreeMap::new(),
8267            winner: 'A',
8268            rankings: 1,
8269            unanimous_initial: true,
8270            deliberated: false,
8271            changed_votes: 0,
8272            unanimous_final: true,
8273            tie_break: None,
8274            judges: 1,
8275            present: 1,
8276            quorum: 1,
8277            met_quorum: true,
8278            uncontested: None,
8279        }
8280    }
8281
8282    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
8283        ReviewRecord {
8284            attempts: 0,
8285            reviewer,
8286            agent: agent.to_owned(),
8287            summary: summary.to_owned(),
8288            findings: Vec::new(),
8289            vote: None,
8290            failed: None,
8291            duration_ms: 0,
8292        }
8293    }
8294
8295    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
8296        let answered = reviews.len();
8297        ReviewRound {
8298            round,
8299            head: "abc1234".to_owned(),
8300            verified_head: None,
8301            verified_at: None,
8302            reviews,
8303            e2e: Vec::new(),
8304            verify_retried: false,
8305            e2e_deferred: false,
8306            e2e_defer_reason: None,
8307            fix: None,
8308            blocking: 0,
8309            answered,
8310            expected: answered,
8311            clean: true,
8312            progressed: false,
8313            vote_split: false,
8314            reconsideration: Vec::new(),
8315            verdict: None,
8316        }
8317    }
8318
8319    #[test]
8320    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
8321        let en = panel();
8322        assert!(en.contains("Task"), "{en}");
8323        assert!(en.contains("add retries to the uploader"), "{en}");
8324
8325        let mut state = run_state();
8326        state.config.graph.language = "ja".to_owned();
8327        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8328        assert!(ja.contains("タスク"), "{ja}");
8329        assert!(
8330            ja.contains("add retries to the uploader"),
8331            "the task itself is not translated: {ja}"
8332        );
8333    }
8334
8335    #[test]
8336    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
8337        // `run_state()` has no candidates, no tally and no reviews - exactly
8338        // the shape a run has before anything has judged or reviewed it, and
8339        // the panel must not print an empty box for either.
8340        let html = panel();
8341        assert!(!html.contains("What changed"), "{html}");
8342        assert!(!html.contains("Review verdict"), "{html}");
8343    }
8344
8345    #[test]
8346    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
8347        let mut state = run_state();
8348        state.candidates = vec![winning_candidate("")];
8349        state.tally = Some(uncontested_tally());
8350        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8351        assert!(
8352            !html.contains("What changed"),
8353            "an empty summary must not render an empty box: {html}"
8354        );
8355    }
8356
8357    #[test]
8358    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
8359        let mut state = run_state();
8360        state.candidates = vec![winning_candidate(
8361            "Added a retry loop around the uploader PUT call.",
8362        )];
8363        state.tally = Some(uncontested_tally());
8364        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8365        assert!(en.contains("What changed"), "{en}");
8366        assert!(
8367            en.contains("Added a retry loop around the uploader PUT call."),
8368            "{en}"
8369        );
8370
8371        state.config.graph.language = "ja".to_owned();
8372        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8373        assert!(ja.contains("変更内容"), "{ja}");
8374        assert!(
8375            ja.contains("Added a retry loop around the uploader PUT call."),
8376            "{ja}"
8377        );
8378    }
8379
8380    #[test]
8381    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
8382        let mut state = run_state();
8383        state.reviews = vec![
8384            review_round(
8385                1,
8386                vec![review_record(1, "alpha", "found a race, sent back")],
8387            ),
8388            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
8389        ];
8390        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8391        assert!(en.contains("Review verdict"), "{en}");
8392        assert!(en.contains("race is fixed, clean"), "{en}");
8393        assert!(
8394            !en.contains("found a race, sent back"),
8395            "only the round that actually cleared the merge should show: {en}"
8396        );
8397
8398        state.config.graph.language = "ja".to_owned();
8399        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8400        assert!(ja.contains("レビューの結論"), "{ja}");
8401        assert!(ja.contains("レビュアー"), "{ja}");
8402        assert!(ja.contains("race is fixed, clean"), "{ja}");
8403    }
8404
8405    /// The `incomplete_review = "warn"` policy (see
8406    /// `graph::Runner::review_loop`) can push a `clean` round to
8407    /// `state.reviews` while one seat's own record still has `failed: Some`
8408    /// and an empty `summary` - a seat that never answered, not one that
8409    /// answered with nothing to say.
8410    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
8411        ReviewRecord {
8412            attempts: 0,
8413            reviewer,
8414            agent: agent.to_owned(),
8415            summary: String::new(),
8416            findings: Vec::new(),
8417            vote: None,
8418            failed: Some(reason.to_owned()),
8419            duration_ms: 0,
8420        }
8421    }
8422
8423    #[test]
8424    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
8425        let mut state = run_state();
8426        state.reviews = vec![review_round(
8427            1,
8428            vec![
8429                review_record(1, "alpha", "clean, nothing to add"),
8430                unanswered_review_record(2, "beta", "timed out"),
8431            ],
8432        )];
8433        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8434        assert!(en.contains("clean, nothing to add"), "{en}");
8435        assert!(
8436            en.contains("produced no answer: timed out"),
8437            "a seat that never answered must say so, not render a blank box: {en}"
8438        );
8439        assert!(
8440            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
8441            "no reviewer box may be left empty: {en}"
8442        );
8443
8444        state.config.graph.language = "ja".to_owned();
8445        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8446        assert!(ja.contains("回答なし: timed out"), "{ja}");
8447    }
8448
8449    #[test]
8450    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
8451        let html = panel();
8452        assert!(!html.contains("<script"), "no script survives the csp");
8453        assert!(!html.contains("<form"), "form-action is 'none'");
8454        let pr = green_pr();
8455        assert_eq!(
8456            html.matches("http").count(),
8457            html.matches(pr.url.as_str()).count(),
8458            "the only http url in the panel is the pull request's own link"
8459        );
8460        assert!(
8461            html.contains("target=\"_blank\""),
8462            "the PR link must open in a new tab so it escapes the iframe"
8463        );
8464        assert!(
8465            html.contains("rel=\"noopener noreferrer\""),
8466            "external links opening in new tabs must carry noopener noreferrer"
8467        );
8468    }
8469
8470    #[test]
8471    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
8472        let html = panel();
8473        assert!(
8474            html.contains(">+</span>"),
8475            "an added line carries a `+` in the gutter, not only a background"
8476        );
8477        assert!(
8478            html.contains(">-</span>"),
8479            "a removed line carries a `-` in the gutter, not only a background"
8480        );
8481        assert!(
8482            html.contains(">new line</span>"),
8483            "the marker is moved to the gutter, so the body is printed once without it"
8484        );
8485    }
8486
8487    #[test]
8488    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
8489        let total = DIFF_MAX_LINES + 100;
8490        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
8491        let html = approval_panel(
8492            &run_state(),
8493            &green_pr(),
8494            NUMSTAT,
8495            &diff,
8496            &[],
8497            "feat: something long",
8498        );
8499        assert!(
8500            html.contains(&format!("100 of {total} diff lines omitted")),
8501            "the note must say exactly how much was cut"
8502        );
8503        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
8504        assert!(
8505            !html.contains(&format!("line {DIFF_MAX_LINES}")),
8506            "nothing past the threshold is rendered"
8507        );
8508        assert!(
8509            html.contains("/repo/magi"),
8510            "the note says where the rest is"
8511        );
8512    }
8513
8514    #[test]
8515    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
8516        let html = approval_panel(
8517            &run_state(),
8518            &green_pr(),
8519            "1\t2\tsrc/<b>&\"x\"'.rs",
8520            "",
8521            &[],
8522            "subject",
8523        );
8524        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
8525        assert!(
8526            !html.contains("<b>"),
8527            "an agent-influenced path must never become markup"
8528        );
8529    }
8530
8531    #[tokio::test]
8532    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
8533        let a = std::path::PathBuf::from("/repo/a");
8534        let b = std::path::PathBuf::from("/repo/b");
8535
8536        let held = repo_merge_lock(&a).lock_owned().await;
8537
8538        // A second, concurrent land run against the *same* repository must
8539        // wait - `try_lock` fails while `held` is alive.
8540        assert!(
8541            repo_merge_lock(&a).try_lock().is_err(),
8542            "a second merge into the same repository must not proceed concurrently"
8543        );
8544
8545        // A run against a *different* repository must not be blocked by it -
8546        // this is what keeps a slow rebase or `gh pr merge` in one
8547        // repository from also stalling a land-approval resume in another.
8548        assert!(
8549            repo_merge_lock(&b).try_lock().is_ok(),
8550            "a different repository's merge lock must be independent"
8551        );
8552
8553        drop(held);
8554        assert!(
8555            repo_merge_lock(&a).try_lock().is_ok(),
8556            "the lock is released once the holder is done"
8557        );
8558    }
8559
8560    pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
8561        let mut state = landing_state();
8562        let mut rec = review_record(1, "alpha", "precise rejection reason");
8563        rec.vote = Some(if reject {
8564            crate::verdict::ReviewVote::Reject
8565        } else {
8566            crate::verdict::ReviewVote::Approve
8567        });
8568        if major {
8569            rec.findings = contested().findings;
8570        }
8571        let mut round = review_round(1, vec![rec]);
8572        round.clean = false;
8573        round.blocking = usize::from(major);
8574        state.reviews.push(round);
8575        state.config.graph.review_rounds = 1;
8576        state
8577    }
8578
8579    #[test]
8580    fn approval_policy_controls_are_independent_and_keep_all_reasons() {
8581        for every in [false, true] {
8582            for contested in [false, true] {
8583                for major in [false, true] {
8584                    for reject in [false, true] {
8585                        let mut state = repair_state(major, reject);
8586                        state.config.graph.land_approval = every;
8587                        state.config.graph.hold_contested_merge = contested;
8588                        let reasons = approval_reasons(&state);
8589                        assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
8590                        assert_eq!(
8591                            reasons.contains(&ApprovalReason::OpenBlockingFindings),
8592                            contested && major
8593                        );
8594                        assert_eq!(
8595                            reasons.contains(&ApprovalReason::FinalReject),
8596                            contested && reject
8597                        );
8598                        assert_eq!(
8599                            reasons.len(),
8600                            usize::from(every)
8601                                + usize::from(contested && major)
8602                                + usize::from(contested && reject)
8603                        );
8604                    }
8605                }
8606            }
8607        }
8608    }
8609
8610    #[test]
8611    fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8612        use crate::verdict::ReviewVote::{Approve, Reject};
8613        for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8614            let mut state = repair_state(false, initial_reject);
8615            state.reviews[0]
8616                .reconsideration
8617                .push(crate::run::ReviewRevoteRecord {
8618                    reviewer: 1,
8619                    agent: "alpha".into(),
8620                    vote: Some(final_vote),
8621                    reason: "changed after reading the panel <evidence>".into(),
8622                    failed: None,
8623                });
8624            assert_eq!(
8625                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8626                final_vote == Reject
8627            );
8628            for language in ["en", "ja"] {
8629                state.config.graph.language = language.into();
8630                let evidence = approval_evidence(&state);
8631                assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8632                assert!(evidence.contains("changed after reading the panel <evidence>"));
8633                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8634                assert!(html.contains("changed after reading the panel &lt;evidence&gt;"));
8635                assert!(!html.contains("<evidence>"));
8636            }
8637            state.reviews[0].reconsideration[0].vote = None;
8638            state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8639            assert_eq!(
8640                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8641                initial_reject
8642            );
8643            assert!(approval_evidence(&state).contains("timed out"));
8644        }
8645    }
8646
8647    #[tokio::test]
8648    async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8649        for every in [false, true] {
8650            for contested in [false, true] {
8651                for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8652                {
8653                    let mut state = repair_state(major, reject);
8654                    state.config.graph.land_approval = every;
8655                    state.config.graph.hold_contested_merge = contested;
8656                    let forge = Scripted::new(
8657                        vec![seen("abc", Checks::Green, "CLEAN", false)],
8658                        vec![(true, "")],
8659                    );
8660                    land_with(&mut state, URL, &forge).await.unwrap();
8661                    let expected = every || (contested && (major || reject));
8662                    assert_eq!(state.land_approval.is_some(), expected);
8663                    assert_eq!(forge.calls().contains(&"merge"), !expected);
8664                    if expected {
8665                        assert!(state.parked);
8666                    }
8667                }
8668            }
8669        }
8670    }
8671
8672    #[tokio::test]
8673    async fn reviewed_branch_hold_is_read_before_landing_decision() {
8674        for (base, control) in [
8675            (false, "land_approval"),
8676            (true, "land_approval"),
8677            (false, "hold_contested_merge"),
8678        ] {
8679            let (_tmp, mut state, _) = landing_git_fixture().await;
8680            state.config.graph.land_approval = base;
8681            state.config.graph.hold_contested_merge = false;
8682            state.reviewed_commits = Some(Vec::new());
8683            if control == "hold_contested_merge" {
8684                state.reviews = repair_state(false, true).reviews;
8685            }
8686            std::fs::write(
8687                state.repo.join("magi.toml"),
8688                format!("[graph]\n{control} = {}\n", !base),
8689            )
8690            .unwrap();
8691            git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8692            git::git(&state.repo, &["commit", "-m", "approval policy"])
8693                .await
8694                .unwrap();
8695            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8696            state.land_sync_pending = false;
8697            state.pr_pushed_head = Some(head.clone());
8698            state.gate_head = Some(head.clone());
8699            let forge = Scripted::new(
8700                vec![seen(&head, Checks::Green, "CLEAN", false)],
8701                vec![(true, "")],
8702            );
8703            land_with(&mut state, URL, &forge).await.unwrap();
8704            assert!(
8705                state.land_approval.is_some(),
8706                "base={base}, control={control}"
8707            );
8708            assert!(!forge.calls().contains(&"merge"));
8709            if !base {
8710                let question = ask::Questions::open()
8711                    .get(&state.land_approval.as_ref().unwrap().question)
8712                    .unwrap();
8713                assert!(question.detail.contains(&format!(
8714                    "{control}=true from the reviewed branch's magi.toml"
8715                )));
8716            }
8717        }
8718    }
8719
8720    #[tokio::test]
8721    async fn reviewed_branch_hold_asks_and_survives_removal_and_resume() {
8722        for file in ["magi.toml", ".magi/config.toml"] {
8723            for control in crate::config::OwnerHold::ALL {
8724                let (_tmp, mut state, _) = landing_git_fixture().await;
8725                state.config.graph.land_approval = false;
8726                state.config.graph.hold_contested_merge = false;
8727                state.reviewed_commits = Some(Vec::new());
8728                if control == crate::config::OwnerHold::HoldContestedMerge {
8729                    state.reviews = repair_state(false, true).reviews;
8730                }
8731                let config = state.repo.join(file);
8732                std::fs::create_dir_all(config.parent().unwrap()).unwrap();
8733                std::fs::write(
8734                    &config,
8735                    format!(
8736                        "[graph]\n{} = true\n[verify]\ngate = ['untrusted']\n",
8737                        control.key()
8738                    ),
8739                )
8740                .unwrap();
8741                git::git(&state.repo, &["add", file]).await.unwrap();
8742                git::git(&state.repo, &["commit", "-m", "require approval"])
8743                    .await
8744                    .unwrap();
8745                let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8746                let gate = state.config.verify.gate.clone();
8747                state.observe_branch_owner_holds(&head).await;
8748                assert_eq!(state.config.verify.gate, gate);
8749                assert!(state.owner_hold(control));
8750                let holds = state.branch_owner_holds.clone();
8751                git::git(&state.repo, &["rm", file]).await.unwrap();
8752                git::git(&state.repo, &["commit", "-m", "remove config"])
8753                    .await
8754                    .unwrap();
8755                let later = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8756                state.save().unwrap();
8757                state = RunState::load(&state.id).unwrap();
8758                state.observe_branch_owner_holds(&later).await;
8759                assert_eq!(state.branch_owner_holds, holds);
8760                assert_eq!(state.branch_owner_holds[0].head, head);
8761                state.land_sync_pending = false;
8762                state.pr_pushed_head = Some(later.clone());
8763                state.gate_head = Some(later.clone());
8764                let forge = Scripted::new(
8765                    vec![seen(&later, Checks::Green, "CLEAN", false)],
8766                    vec![(true, "")],
8767                );
8768                land_with(&mut state, URL, &forge).await.unwrap();
8769                assert!(!forge.calls().contains(&"merge"));
8770                assert!(state.parked);
8771                let question = ask::Questions::open()
8772                    .get(&state.land_approval.as_ref().unwrap().question)
8773                    .unwrap();
8774                assert!(question.detail.contains("Approval required because:"));
8775                assert!(question.detail.contains(&format!(
8776                    "{}=true from the reviewed branch's {file}",
8777                    control.key()
8778                )));
8779                assert_eq!(state.branch_owner_holds, holds);
8780                state.save().unwrap();
8781                let resumed = RunState::load(&state.id).unwrap();
8782                assert!(resumed.owner_hold(control));
8783                assert_eq!(resumed.branch_owner_holds, holds);
8784                assert_eq!(
8785                    resumed.land_approval.unwrap().question,
8786                    state.land_approval.unwrap().question
8787                );
8788            }
8789        }
8790    }
8791
8792    #[tokio::test]
8793    async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8794        let mut state = repair_state(false, true);
8795        state.reviews[0].clean = true;
8796        let mut finding = contested().findings.remove(0);
8797        finding.severity = crate::verdict::Severity::Minor;
8798        state.reviews[0].reviews[0].findings.push(finding);
8799        assert!(state.open_findings().is_empty());
8800        let mut pr = green_pr();
8801        pr.review_comments.clear();
8802        for language in ["en", "ja"] {
8803            state.config.graph.language = language.into();
8804            let html = approval_panel(&state, &pr, "", "", &[], "subject");
8805            assert!(html.contains("[R3-1-1] minor"));
8806            assert!(html.contains(if language == "ja" {
8807                "内部指摘の解決を意味するものではありません"
8808            } else {
8809                "This does not mean internal findings are resolved"
8810            }));
8811            assert!(
8812                html.find(if language == "ja" {
8813                    "承認が必要な理由:"
8814                } else {
8815                    "Approval required because:"
8816                })
8817                .unwrap()
8818                    < html.find("[R3-1-1]").unwrap()
8819            );
8820        }
8821        state.config.graph.language = "en".into();
8822        let observed = seen("abc", Checks::Green, "CLEAN", false);
8823        assert_eq!(
8824            approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8825                .await
8826                .unwrap(),
8827            ApprovalGate::Pending
8828        );
8829        let id = state.land_approval.as_ref().unwrap().question.clone();
8830        let q = ask::Questions::open().get(&id).unwrap();
8831        assert!(q.detail.starts_with("Approval required because:"));
8832        assert!(q.detail.contains("final reviewer reject"));
8833        assert!(q.detail.contains("[R3-1-1] minor"));
8834        assert!(
8835            q.detail
8836                .contains("No adoption or resolution evidence recorded")
8837        );
8838        state.config.graph.land_approval = false;
8839        state.config.graph.hold_contested_merge = false;
8840        state.reviews.clear();
8841        assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8842        assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8843        assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8844        let forge = Scripted::new(vec![observed], vec![]);
8845        land_with(&mut state, URL, &forge).await.unwrap();
8846        assert!(!forge.calls().contains(&"merge"));
8847        assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8848        assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8849    }
8850
8851    #[tokio::test]
8852    async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8853        let mut state = repair_state(false, true);
8854        state.config.graph.land_approval = false;
8855        let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8856        let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8857        let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8858        state.reviews[0].reviews[0].summary = long_reason.clone();
8859        for i in 1..=10 {
8860            let mut finding = contested().findings.remove(0);
8861            finding.id = format!("R1-1-{i}");
8862            finding.severity = if i == 9 {
8863                crate::verdict::Severity::Major
8864            } else {
8865                crate::verdict::Severity::Minor
8866            };
8867            finding.title = long_title.clone();
8868            finding.detail = long_detail.clone();
8869            state.reviews[0].reviews[0].findings.push(finding);
8870        }
8871        state.reviews[0].fix = Some(crate::run::FixRecord {
8872            agent: "fixer".into(),
8873            addressed: vec!["R1-1-9".into()],
8874            rejected: vec![crate::verdict::Rejection {
8875                id: "R1-1-10".into(),
8876                why: long_reason.clone(),
8877            }],
8878            notes: long_detail.clone(),
8879            committed: false,
8880            failed: None,
8881            duration_ms: 0,
8882            continuation: None,
8883        });
8884        assert_eq!(
8885            approval_reasons(&state),
8886            [
8887                ApprovalReason::OpenBlockingFindings,
8888                ApprovalReason::FinalReject
8889            ]
8890        );
8891        let excerpt = approval_excerpt(&long_reason, 300);
8892        assert_eq!(excerpt.chars().count(), 300);
8893        assert!(excerpt.ends_with('…'));
8894        let boundary = "拒".repeat(300);
8895        assert_eq!(approval_excerpt(&boundary, 300), boundary);
8896        for language in ["en", "ja"] {
8897            state.config.graph.language = language.into();
8898            for revote in [
8899                None,
8900                Some(crate::verdict::ReviewVote::Reject),
8901                Some(crate::verdict::ReviewVote::Approve),
8902            ] {
8903                state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8904                state.reviews[0].reconsideration.clear();
8905                if let Some(vote) = revote {
8906                    state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8907                    state.reviews[0]
8908                        .reconsideration
8909                        .push(crate::run::ReviewRevoteRecord {
8910                            reviewer: 1,
8911                            agent: "alpha".into(),
8912                            vote: Some(vote),
8913                            reason: long_reason.clone(),
8914                            failed: None,
8915                        });
8916                }
8917                let evidence = approval_evidence(&state);
8918                let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8919                assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8920                assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8921                assert_eq!(
8922                    approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8923                    final_vote == crate::verdict::ReviewVote::Reject
8924                );
8925                assert!(evidence.contains(&excerpt));
8926                assert!(evidence.contains("[R1-1-8] minor"));
8927                assert!(evidence.contains("[R1-1-9] major"));
8928                assert!(evidence.contains("[R1-1-10] minor"));
8929                for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8930                    assert!(!evidence.contains(tail));
8931                }
8932                assert!(evidence.contains(if language == "ja" {
8933                    "採用申告(解決未確認)"
8934                } else {
8935                    "claimed addressed (resolution unconfirmed)"
8936                }));
8937                assert!(evidence.contains(if language == "ja" {
8938                    "不採用理由"
8939                } else {
8940                    "declined"
8941                }));
8942                let major_evidence = evidence
8943                    .split("[R1-1-9] major")
8944                    .nth(1)
8945                    .unwrap()
8946                    .split("[R1-1-10]")
8947                    .next()
8948                    .unwrap();
8949                assert!(major_evidence.contains(if language == "ja" {
8950                    "採用申告(解決未確認)"
8951                } else {
8952                    "claimed addressed (resolution unconfirmed)"
8953                }));
8954                let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8955                assert!(last_evidence.contains(if language == "ja" {
8956                    "不採用理由"
8957                } else {
8958                    "declined"
8959                }));
8960                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8961                assert!(html.contains(&esc(&evidence)));
8962                assert_eq!(
8963                    approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8964                        .await
8965                        .unwrap(),
8966                    ApprovalGate::Pending
8967                );
8968                let id = &state.land_approval.take().unwrap().question;
8969                let q = ask::Questions::open().get(id).unwrap();
8970                assert!(q.detail.starts_with(&evidence));
8971            }
8972            state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8973        }
8974    }
8975
8976    #[test]
8977    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8978        for (major, reject) in [(true, false), (false, true), (true, true)] {
8979            let state = repair_state(major, reject);
8980            assert!(repair_available(&state));
8981            assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8982        }
8983        assert!(!repair_available(&repair_state(false, false)));
8984        let mut clean = repair_state(true, false);
8985        clean.reviews[0].clean = true;
8986        assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8987        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8988    }
8989
8990    #[tokio::test]
8991    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8992        let mut state = repair_state(true, false);
8993        let pr = green_pr();
8994        approval_gate(&mut state, &pr, "feat: x", None, "abc")
8995            .await
8996            .unwrap();
8997        let store = ask::Questions::open();
8998        let id = state.land_approval.as_ref().unwrap().question.clone();
8999        // Simulate an unanswered two-choice question created by an older build.
9000        store
9001            .update(&id, |q| {
9002                q.choices = vec![APPROVE.into(), HOLD.into()];
9003                Ok(())
9004            })
9005            .unwrap();
9006        let upgraded = store.get(&id).unwrap();
9007        assert_eq!(upgraded.choices, approval_choices(&state));
9008        assert!(!upgraded.actions.contains_key(HOLD));
9009        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
9010        assert!(
9011            store
9012                .list()
9013                .iter()
9014                .any(|q| q.id == id && q.choices == upgraded.choices)
9015        );
9016        assert!(
9017            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
9018        );
9019        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
9020        store
9021            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
9022            .unwrap();
9023        // An earlier build may have recorded the repair answer and stopped
9024        // before any driver consumed it. Reconcile that durable decision too.
9025        store
9026            .update(&id, |q| {
9027                q.actions.clear();
9028                Ok(())
9029            })
9030            .unwrap();
9031        assert_eq!(
9032            store.get(&id).unwrap().chosen_action(),
9033            Some(&ask::ChoiceAction::Resume {
9034                run: state.id.clone()
9035            })
9036        );
9037        let binding = state.land_approval.clone();
9038        assert_eq!(
9039            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9040                .await
9041                .unwrap(),
9042            ApprovalGate::Repair
9043        );
9044        assert!(state.land_approval.is_none());
9045        assert_eq!(state.merge_repairs.len(), 1);
9046        let request = &state.merge_repairs[0];
9047        assert_eq!(request.question, id);
9048        assert_eq!(request.head, "abc");
9049        assert_eq!(request.pr, pr.url);
9050        assert_eq!(
9051            request.reviews[0].reviews[0].summary,
9052            "precise rejection reason"
9053        );
9054        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
9055        assert_eq!(
9056            request.reviews[0].reviews[0].findings[0].detail,
9057            state.reviews[0].reviews[0].findings[0].detail
9058        );
9059        // A repeated consumption of the same answer never dispatches another repair.
9060        state.land_approval = binding;
9061        assert_eq!(
9062            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9063                .await
9064                .unwrap(),
9065            ApprovalGate::Repair
9066        );
9067        assert_eq!(state.merge_repairs.len(), 1);
9068        state.land_approval = None;
9069        state.config.graph.land_approval = false;
9070        state.config.graph.hold_contested_merge = false;
9071        state.reviews.clear();
9072        assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
9073        assert_eq!(
9074            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9075                .await
9076                .unwrap(),
9077            ApprovalGate::Pending
9078        );
9079        let fresh_id = &state.land_approval.as_ref().unwrap().question;
9080        assert_ne!(fresh_id, &id);
9081        let fresh = store.get(fresh_id).unwrap();
9082        assert!(
9083            fresh
9084                .detail
9085                .contains("fresh owner approval after merge repair")
9086        );
9087        assert!(fresh.detail.contains("No review recorded"));
9088        assert!(!fresh.detail.contains("precise rejection reason"));
9089    }
9090
9091    #[tokio::test]
9092    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
9093        for choice in [REPAIR, HOLD] {
9094            let mut state = repair_state(false, true);
9095            let observed = seen("abc", Checks::Green, "CLEAN", false);
9096            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
9097                .await
9098                .unwrap();
9099            let id = state.land_approval.as_ref().unwrap().question.clone();
9100            ask::Questions::open()
9101                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
9102                .unwrap();
9103            let forge = Scripted::new(vec![observed], vec![(true, "")]);
9104            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9105                .await
9106                .unwrap();
9107            assert!(
9108                forge
9109                    .argvs()
9110                    .iter()
9111                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
9112            );
9113            assert!(!forge.calls().contains(&"fix"));
9114            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
9115            assert_ne!(state.status, RunStatus::Merged);
9116        }
9117    }
9118
9119    #[test]
9120    fn only_the_merge_choice_merges_and_silence_holds() {
9121        let table = [
9122            (None, Approval::Hold),
9123            (Some("merge"), Approval::Merge),
9124            (Some(" merge\n"), Approval::Merge),
9125            (Some("hold"), Approval::Hold),
9126            (Some(""), Approval::Hold),
9127            (Some("yes"), Approval::Hold),
9128        ];
9129        for (answer, want) in table {
9130            assert_eq!(
9131                approval(answer),
9132                want,
9133                "answer {answer:?} must resolve to {want:?}"
9134            );
9135        }
9136    }
9137
9138    #[tokio::test]
9139    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
9140        let mut state = landing_state();
9141        state.config.graph.land_approval = true;
9142        let pr = green_pr();
9143
9144        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
9145            .await
9146            .unwrap();
9147        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
9148        assert!(
9149            !state.parked,
9150            "approval_gate itself never sets `parked`; only its caller does"
9151        );
9152
9153        let store = ask::Questions::open();
9154        let filed: Vec<_> = store
9155            .list()
9156            .into_iter()
9157            .filter(|q| q.run == state.id)
9158            .collect();
9159        assert_eq!(filed.len(), 1, "exactly one question is filed");
9160        assert_eq!(filed[0].node, APPROVAL_NODE);
9161        assert_eq!(filed[0].choices, approval_choices(&state));
9162        assert!(filed[0].status.open());
9163
9164        // A second visit - standing in for a resumed run whose slot the
9165        // daemon handed to something else while nobody had answered - must
9166        // find the same question rather than filing a second one.
9167        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
9168            .await
9169            .unwrap();
9170        assert_eq!(again, ApprovalGate::Pending);
9171        let still_one = store
9172            .list()
9173            .into_iter()
9174            .filter(|q| q.run == state.id)
9175            .count();
9176        assert_eq!(
9177            still_one, 1,
9178            "asking twice must not double-file the question"
9179        );
9180    }
9181
9182    #[tokio::test]
9183    async fn approving_the_existing_question_is_read_back_as_approved() {
9184        crate::run::pin_test_home();
9185        let mut state = run_state();
9186        state.config.graph.land_approval = true;
9187        let pr = green_pr();
9188        assert_eq!(
9189            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9190                .await
9191                .unwrap(),
9192            ApprovalGate::Pending
9193        );
9194
9195        let store = ask::Questions::open();
9196        let mut q = store
9197            .list()
9198            .into_iter()
9199            .find(|q| q.run == state.id)
9200            .expect("filed above");
9201        let talks = crate::talk::Talks::open();
9202        let cfg = crate::config::Config {
9203            agents: vec![crate::config::AgentSpec {
9204                id: "mock".into(),
9205                kind: crate::config::AgentKind::Command,
9206                model: None,
9207                command: vec!["true".into()],
9208                extra_args: Vec::new(),
9209                env: Default::default(),
9210                prompt_delivery: None,
9211                max_turn_secs: None,
9212            }],
9213            ..crate::config::Config::default()
9214        };
9215        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
9216        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
9217        crate::talk::drain(&mut talk, &talks).unwrap();
9218        assert_eq!(
9219            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9220                .await
9221                .unwrap(),
9222            ApprovalGate::Pending,
9223            "consultation cannot approve or hold the merge"
9224        );
9225        q = store.get(&q.id).unwrap();
9226        assert!(q.answer.is_none());
9227        // The owner's merge answer (from the chat or the card) is the approval.
9228        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9229        store.put(&mut q).unwrap();
9230
9231        assert_eq!(
9232            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9233                .await
9234                .unwrap(),
9235            ApprovalGate::Approved
9236        );
9237    }
9238
9239    #[tokio::test]
9240    async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
9241        crate::run::pin_test_home();
9242        let store = ask::Questions::open();
9243        for via_list in [false, true] {
9244            let mut state = repair_state(false, true);
9245            let pr = green_pr();
9246            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9247                .await
9248                .unwrap();
9249            let id = state.land_approval.as_ref().unwrap().question.clone();
9250            store
9251                .update(&id, |q| {
9252                    q.actions.insert(
9253                        HOLD.to_owned(),
9254                        ask::ChoiceAction::Resume {
9255                            run: state.id.clone(),
9256                        },
9257                    );
9258                    Ok(())
9259                })
9260                .unwrap();
9261            let q = if via_list {
9262                store.list().into_iter().find(|q| q.id == id).unwrap()
9263            } else {
9264                store.get(&id).unwrap()
9265            };
9266            assert!(!q.actions.contains_key(HOLD));
9267            store
9268                .update(&id, |q| {
9269                    // Check the persisted migration, without another read upgrade.
9270                    assert!(!q.actions.contains_key(HOLD));
9271                    assert!(!upgrade_repair_choice(q, &state));
9272                    q.answer(ask::Answer::Choice(HOLD.to_owned()))
9273                })
9274                .unwrap();
9275            assert!(store.get(&id).unwrap().chosen_action().is_none());
9276            assert_eq!(
9277                approval_gate(&mut state, &pr, "feat: x", None, "abc")
9278                    .await
9279                    .unwrap(),
9280                ApprovalGate::Held
9281            );
9282        }
9283    }
9284
9285    #[tokio::test]
9286    async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
9287        crate::run::pin_test_home();
9288        let store = ask::Questions::open();
9289
9290        let mut held_state = run_state();
9291        held_state.config.graph.land_approval = true;
9292        let pr = green_pr();
9293        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9294            .await
9295            .unwrap();
9296        let mut q = store
9297            .list()
9298            .into_iter()
9299            .find(|q| q.run == held_state.id)
9300            .expect("filed above");
9301        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
9302        store.put(&mut q).unwrap();
9303        assert_eq!(
9304            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9305                .await
9306                .unwrap(),
9307            ApprovalGate::Held
9308        );
9309
9310        let mut abandoned_state = run_state();
9311        abandoned_state.config.graph.land_approval = true;
9312        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9313            .await
9314            .unwrap();
9315        let mut q = store
9316            .list()
9317            .into_iter()
9318            .find(|q| q.run == abandoned_state.id)
9319            .expect("filed above");
9320        q.abandon("no answer within the timeout");
9321        store.put(&mut q).unwrap();
9322        assert_eq!(
9323            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9324                .await
9325                .unwrap(),
9326            ApprovalGate::Pending,
9327            "an expired approval must ask again without merging"
9328        );
9329        assert_eq!(
9330            store.get(&q.id).unwrap().status,
9331            ask::QuestionStatus::Abandoned
9332        );
9333        assert_ne!(
9334            abandoned_state.land_approval.as_ref().unwrap().question,
9335            q.id
9336        );
9337    }
9338
9339    fn contested() -> ContestedHandoff {
9340        let finding = |id: &str, n: u32| crate::verdict::Finding {
9341            id: id.to_owned(),
9342            severity: crate::verdict::Severity::Major,
9343            file: Some("src/a.rs".to_owned()),
9344            line: Some(n),
9345            title: format!("problem {id}"),
9346            detail: String::new(),
9347        };
9348        ContestedHandoff {
9349            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
9350            rejecters: vec![(1, "alpha".to_owned())],
9351        }
9352    }
9353
9354    #[test]
9355    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
9356        let mut state = run_state();
9357        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
9358        state.contested_handoff = Some(contested());
9359        assert!(contested_to_ask(&state).is_some());
9360        state.config.graph.hold_contested_merge = false;
9361        assert!(
9362            contested_to_ask(&state).is_none(),
9363            "the switch restores today"
9364        );
9365    }
9366
9367    #[test]
9368    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
9369        let q = ask::Question::new(
9370            "run-1".to_owned(),
9371            APPROVAL_NODE.to_owned(),
9372            "land".to_owned(),
9373            "Merge?".to_owned(),
9374            String::new(),
9375            vec![APPROVE.to_owned(), HOLD.to_owned()],
9376        );
9377        let none = deputy_brief(&q, None);
9378        assert!(none.contains("could not be read"), "{none}");
9379        assert!(none.contains("Silence is a hold"), "{none}");
9380
9381        let mut state = run_state();
9382        state.pr = Some(crate::run::PrRecord {
9383            url: "https://example.test/pull/7".to_owned(),
9384            number: 7,
9385            state: "open".to_owned(),
9386            checks: "green".to_owned(),
9387            round: 0,
9388            rounds: 3,
9389            red_at_merge: Vec::new(),
9390        });
9391        state.contested_handoff = Some(contested());
9392        let b = deputy_brief(&q, Some(&state));
9393        assert!(b.contains("https://example.test/pull/7"), "{b}");
9394        assert!(b.contains("not recorded in this legacy question"), "{b}");
9395        assert!(!b.contains("This question was filed although"));
9396        state.contested_handoff = None;
9397        assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
9398    }
9399
9400    #[test]
9401    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
9402        for lang in ["en", "ja"] {
9403            let mut cfg = crate::config::Config::default();
9404            cfg.graph.language = lang.to_owned();
9405            let w = words(&cfg.graph.language);
9406            let text = w.approval_detail(
9407                "https://github.com/yukimemi/magi/pull/42",
9408                "main",
9409                "feat: x",
9410                Some(&contested()),
9411            );
9412            assert!(text.contains("pull/42"), "{text}");
9413            assert!(
9414                text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
9415                "{text}"
9416            );
9417            assert!(text.contains("R3-1-5"), "{text}");
9418            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
9419            assert!(text.contains("2"), "the rest are counted: {text}");
9420            assert!(text.contains("#1 (alpha)"), "{text}");
9421        }
9422        let plain = words("en").approval_detail("u", "main", "s", None);
9423        assert!(!plain.contains("reject"), "{plain}");
9424    }
9425
9426    #[tokio::test]
9427    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
9428        crate::run::pin_test_home();
9429        let mut state = run_state();
9430        state.config.graph.land_approval = false;
9431        state.contested_handoff = Some(contested());
9432        let pr = green_pr();
9433        let c = contested_to_ask(&state);
9434        assert_eq!(
9435            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9436                .await
9437                .unwrap(),
9438            ApprovalGate::Pending,
9439            "silence is a hold"
9440        );
9441        let store = ask::Questions::open();
9442        let filed: Vec<_> = store
9443            .list()
9444            .into_iter()
9445            .filter(|q| q.run == state.id)
9446            .collect();
9447        assert_eq!(filed.len(), 1);
9448        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
9449
9450        assert_eq!(
9451            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9452                .await
9453                .unwrap(),
9454            ApprovalGate::Pending
9455        );
9456        let mut q = store
9457            .list()
9458            .into_iter()
9459            .find(|q| q.run == state.id)
9460            .unwrap();
9461        assert_eq!(q.id, filed[0].id, "the same question after a resume");
9462        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9463        store.put(&mut q).unwrap();
9464        assert_eq!(
9465            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9466                .await
9467                .unwrap(),
9468            ApprovalGate::Approved
9469        );
9470    }
9471
9472    #[test]
9473    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
9474        let rows = parse_numstat(NUMSTAT);
9475        assert_eq!(
9476            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
9477            ["src/web.rs", "src/land.rs", "assets/logo.png"]
9478        );
9479        assert_eq!(rows[2].added, None, "a binary file has no line counts");
9480    }
9481    #[test]
9482    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
9483        // Reported from a real run: the merge question arrived in English on a
9484        // repository with `language = "ja"`. magi's own strings have to follow
9485        // that setting too - "it is a literal in Rust" is not an answer.
9486        let mut state = run_state();
9487        state.config.graph.language = "ja".to_owned();
9488        let pr = green_pr();
9489        let commits = ["c1".to_owned()];
9490
9491        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9492        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
9493        assert!(ja.contains("squash されるコミット"), "{ja}");
9494        assert!(ja.contains("レビューコメント"), "{ja}");
9495        assert!(ja.contains("差分"), "{ja}");
9496        assert!(
9497            !ja.contains("Commits being squashed"),
9498            "no English left over"
9499        );
9500
9501        let w = words("ja");
9502        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
9503        assert!(
9504            w.approval_detail("http://x/1", "main", "feat: x", None)
9505                .contains("パネル")
9506        );
9507
9508        // The evidence itself is language-neutral and must survive either way.
9509        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
9510        assert!(ja.contains("feat: x"), "nor is the merge subject");
9511
9512        // English stays the default, and a language magi cannot check falls
9513        // back to it rather than shipping a guess.
9514        state.config.graph.language = "en".to_owned();
9515        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9516        assert!(en.contains("Commits being squashed"), "{en}");
9517        assert_eq!(words("Klingon").html_lang, "en");
9518    }
9519
9520    /// A `gh pr list` result naming exactly one pull request whose base and
9521    /// merge time both fit the run is exactly the case
9522    /// [`find_external_merge`] exists to act on.
9523    #[test]
9524    fn pick_open_pr_classifies_by_count_and_base() {
9525        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
9526        assert_eq!(
9527            pick_open_pr(one, "main").unwrap(),
9528            OpenPr::One {
9529                url: "https://x/pull/58".into(),
9530                title: "t".into()
9531            }
9532        );
9533        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
9534        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
9535        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
9536                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
9537        assert_eq!(
9538            pick_open_pr(two, "main").unwrap(),
9539            OpenPr::Many(vec!["u1".into(), "u2".into()])
9540        );
9541        // A fork's same-named branch is not a pull request for this head.
9542        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
9543        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
9544        assert!(pick_open_pr("not json", "main").is_err());
9545        // An incomplete record is an error, never "nothing open".
9546        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
9547        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
9548    }
9549
9550    #[test]
9551    fn pick_merged_pr_picks_the_unique_match() {
9552        let json = r#"[
9553            {"url": "https://github.com/o/r/pull/42", "number": 42,
9554             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
9555        ]"#;
9556        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9557        let found = pick_merged_pr(json, "main", created_at)
9558            .expect("valid json")
9559            .expect("one unambiguous match");
9560        assert_eq!(found.url, "https://github.com/o/r/pull/42");
9561        assert_eq!(found.number, 42);
9562    }
9563
9564    /// Two candidates surviving the filter is exactly as uninformative as
9565    /// zero — a branch name can be reused across runs — so neither is
9566    /// preferred over the other and nothing is recorded automatically.
9567    #[test]
9568    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
9569        let json = r#"[
9570            {"url": "https://github.com/o/r/pull/42", "number": 42,
9571             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
9572            {"url": "https://github.com/o/r/pull/43", "number": 43,
9573             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
9574        ]"#;
9575        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9576        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9577    }
9578
9579    /// A pull request that targets a different base branch cannot be this
9580    /// run's, whatever its head branch is named — a reused branch name from
9581    /// an unrelated task must not be recorded as this run's merge.
9582    #[test]
9583    fn pick_merged_pr_ignores_a_different_base_branch() {
9584        let json = r#"[
9585            {"url": "https://github.com/o/r/pull/42", "number": 42,
9586             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
9587        ]"#;
9588        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9589        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9590    }
9591
9592    /// A pull request merged before this run was even created cannot be this
9593    /// run's winner, no matter how its head branch is spelled.
9594    #[test]
9595    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
9596        let json = r#"[
9597            {"url": "https://github.com/o/r/pull/42", "number": 42,
9598             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
9599        ]"#;
9600        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9601        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9602    }
9603
9604    #[test]
9605    fn slug_of_pr_url_reads_host_owner_and_repo() {
9606        assert_eq!(
9607            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
9608            Some("github.com/yukimemi/shun")
9609        );
9610    }
9611
9612    #[test]
9613    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
9614        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
9615        assert_eq!(slug_of_pr_url("not a url at all"), None);
9616        assert_eq!(slug_of_pr_url("https://github.com"), None);
9617    }
9618
9619    #[test]
9620    fn slug_of_repo_url_reads_host_owner_and_repo() {
9621        assert_eq!(
9622            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
9623            Some("github.com/yukimemi/magi")
9624        );
9625        assert_eq!(slug_of_repo_url("https://github.com"), None);
9626    }
9627
9628    #[test]
9629    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
9630        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
9631            .expect("same repo, different case");
9632    }
9633
9634    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
9635    /// own in-progress run and rewrote its status from a pull request in a
9636    /// completely different repository. This is the guard that must catch
9637    /// that even when an explicit (but wrong) id is given.
9638    #[test]
9639    fn ensure_same_repo_refuses_a_different_repo() {
9640        let err =
9641            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
9642        let msg = format!("{err:#}");
9643        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9644        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
9645    }
9646
9647    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
9648    /// instance mirroring a `github.com` repository's name, say) must not be
9649    /// treated as the same repository just because the trailing path
9650    /// matches.
9651    #[test]
9652    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
9653        let err = ensure_same_repo(
9654            "github.com/yukimemi/magi",
9655            "github.example.com/yukimemi/magi",
9656        )
9657        .unwrap_err();
9658        let msg = format!("{err:#}");
9659        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9660        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
9661    }
9662
9663    /// No winner decided yet means there is no branch to ask GitHub about at
9664    /// all — `find_external_merge` must return `None` without ever spawning
9665    /// `gh`, which this proves by never providing a real repository to spawn
9666    /// it in.
9667    #[tokio::test]
9668    async fn find_external_merge_returns_none_without_a_winner() {
9669        let state = RunState::new(
9670            PathBuf::from("/no/such/repo"),
9671            "main".to_owned(),
9672            "0000000000000000000000000000000000000000".to_owned(),
9673            "irrelevant".to_owned(),
9674            crate::config::Config::default(),
9675        );
9676        assert_eq!(find_external_merge(&state).await.unwrap(), None);
9677    }
9678
9679    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
9680        let mut run = RunState::new(
9681            PathBuf::from(repo),
9682            "main".to_owned(),
9683            "abcdef1234".to_owned(),
9684            "x".to_owned(),
9685            crate::config::Config::default(),
9686        );
9687        run.id = id.to_owned();
9688        run.status = status;
9689        run.driver_exited = true;
9690        run.pr = Some(crate::run::PrRecord {
9691            number: url.rsplit('/').next().unwrap().parse().unwrap(),
9692            url: url.to_owned(),
9693            state: state.to_owned(),
9694            checks: "red".to_owned(),
9695            round: 0,
9696            rounds: 2,
9697            red_at_merge: Vec::new(),
9698        });
9699        run.save_under(home).unwrap();
9700    }
9701
9702    fn recorded(home: &Path, id: &str) -> String {
9703        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9704        serde_json::from_str::<RunState>(&body)
9705            .unwrap()
9706            .pr
9707            .unwrap()
9708            .state
9709    }
9710
9711    const PR: &str = "https://github.com/o/r/pull/7";
9712
9713    #[test]
9714    fn write_through_updates_predecessors_and_siblings_only() {
9715        let tmp = tempfile::tempdir().unwrap();
9716        let h = tmp.path();
9717        pr_run(
9718            h,
9719            "20261004-100000-aaaa",
9720            "/repo/r",
9721            RunStatus::Superseded,
9722            PR,
9723            "open",
9724        );
9725        pr_run(
9726            h,
9727            "20261004-100100-bbbb",
9728            "/repo/r",
9729            RunStatus::Blocked,
9730            PR,
9731            "open",
9732        );
9733        // Not terminal: a driver may be writing it.
9734        pr_run(
9735            h,
9736            "20261004-100200-cccc",
9737            "/repo/r",
9738            RunStatus::Landing,
9739            PR,
9740            "open",
9741        );
9742        // Another repository's pull request with the same number.
9743        pr_run(
9744            h,
9745            "20261004-100300-dddd",
9746            "/repo/other",
9747            RunStatus::Blocked,
9748            "https://github.com/o/other/pull/7",
9749            "open",
9750        );
9751        // A different pull request of the same repository.
9752        pr_run(
9753            h,
9754            "20261004-100400-eeee",
9755            "/repo/r",
9756            RunStatus::Blocked,
9757            "https://github.com/o/r/pull/8",
9758            "open",
9759        );
9760        pr_run(
9761            h,
9762            "20261004-100500-ffff",
9763            "/repo/r",
9764            RunStatus::Merged,
9765            PR,
9766            "open",
9767        );
9768        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9769
9770        assert_eq!(
9771            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9772            2
9773        );
9774        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9775        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9776        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9777        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9778        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9779        // The source's own record is the caller's to write.
9780        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9781        // Idempotent.
9782        assert_eq!(
9783            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9784            0
9785        );
9786        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9787        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9788    }
9789
9790    #[test]
9791    fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9792        let home = tempfile::tempdir().unwrap();
9793        let live_id = "20261004-100000-aaaa";
9794        let unknown_id = "20261004-100100-bbbb";
9795        for id in [live_id, unknown_id] {
9796            pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9797            let mut s = RunState::load_under(id, home.path()).unwrap();
9798            s.driver_exited = false;
9799            // Model legacy driver evidence, not a newly reserved unstarted run.
9800            s.driver_claimed = None;
9801            if id == live_id {
9802                s.driver_pid = Some(std::process::id());
9803                s.driver_started_at = crate::proc::process_started_at(std::process::id());
9804            }
9805            s.save_under(home.path()).unwrap();
9806            if id == unknown_id {
9807                assert_eq!(
9808                    s.liveness_under(home.path(), false),
9809                    crate::run::Liveness::Unknown
9810                );
9811            }
9812        }
9813        let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9814        assert_eq!(apply_pr_states(home.path(), &known), 0);
9815        for id in [live_id, unknown_id] {
9816            assert_eq!(recorded(home.path(), id), "open");
9817        }
9818    }
9819
9820    #[test]
9821    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9822        let tmp = tempfile::tempdir().unwrap();
9823        let h = tmp.path();
9824        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9825        pr_run(
9826            h,
9827            "20261004-100000-aaaa",
9828            "/repo/r",
9829            RunStatus::Superseded,
9830            &url(1),
9831            "open",
9832        );
9833        pr_run(
9834            h,
9835            "20261004-100100-bbbb",
9836            "/repo/r",
9837            RunStatus::Blocked,
9838            &url(2),
9839            "open",
9840        );
9841        pr_run(
9842            h,
9843            "20261004-100200-cccc",
9844            "/repo/r",
9845            RunStatus::Ready,
9846            &url(3),
9847            "open",
9848        );
9849        pr_run(
9850            h,
9851            "20261004-100300-dddd",
9852            "/repo/r",
9853            RunStatus::Ready,
9854            &url(4),
9855            "open",
9856        );
9857        pr_run(
9858            h,
9859            "20261004-100400-eeee",
9860            "/repo/r",
9861            RunStatus::Implementing,
9862            &url(1),
9863            "open",
9864        );
9865        assert_eq!(stale_open_prs(h).len(), 4);
9866
9867        let mut known = BTreeMap::new();
9868        known.insert(url(1), PrLifecycle::Merged);
9869        known.insert(url(2), PrLifecycle::Closed);
9870        known.insert(url(3), PrLifecycle::Open);
9871        // #4: the forge could not be read, so it has no answer.
9872        assert_eq!(apply_pr_states(h, &known), 2);
9873        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9874        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9875        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9876        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9877        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9878        assert_eq!(apply_pr_states(h, &known), 0);
9879    }
9880
9881    // --- the land loop against a scripted forge -------------------------
9882
9883    use std::collections::VecDeque;
9884    use std::sync::Mutex;
9885
9886    /// Answers views from a script (the last one repeats), merges from a
9887    /// queue, and records every call so a test can assert the order.
9888    struct Scripted {
9889        evidence: Mutex<Option<CiEvidence>>,
9890        views: Mutex<VecDeque<Seen>>,
9891        merges: Mutex<VecDeque<(bool, String)>>,
9892        fix: Mutex<Option<Fixed>>,
9893        log: Mutex<Vec<&'static str>>,
9894        argvs: Mutex<Vec<Vec<String>>>,
9895        required: Mutex<Option<BTreeSet<String>>>,
9896        /// Set once a merge answered ok: the forge then reports `merged`,
9897        /// unless `queued` says the merge only entered a queue.
9898        merged: Mutex<bool>,
9899        queued: Mutex<bool>,
9900        /// Views fail once a merge answered ok.
9901        unreadable_after_merge: Mutex<bool>,
9902        /// Workflow runs asked to rerun, and whether that call fails.
9903        reruns: Mutex<Vec<String>>,
9904        rerun_fails: Mutex<bool>,
9905        /// Unix seconds; every poll moves it on by `POLL`.
9906        clock: Mutex<i64>,
9907    }
9908
9909    impl Scripted {
9910        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9911            Self {
9912                evidence: Mutex::new(None),
9913                views: Mutex::new(views.into()),
9914                merges: Mutex::new(
9915                    merges
9916                        .into_iter()
9917                        .map(|(ok, m)| (ok, m.to_owned()))
9918                        .collect(),
9919                ),
9920                fix: Mutex::new(None),
9921                log: Mutex::new(Vec::new()),
9922                argvs: Mutex::new(Vec::new()),
9923                required: Mutex::new(None),
9924                merged: Mutex::new(false),
9925                queued: Mutex::new(false),
9926                unreadable_after_merge: Mutex::new(false),
9927                reruns: Mutex::new(Vec::new()),
9928                rerun_fails: Mutex::new(false),
9929                clock: Mutex::new(1_000),
9930            }
9931        }
9932        fn argvs(&self) -> Vec<Vec<String>> {
9933            self.argvs.lock().unwrap().clone()
9934        }
9935        fn calls(&self) -> Vec<&'static str> {
9936            self.log.lock().unwrap().clone()
9937        }
9938    }
9939
9940    impl Forge for Scripted {
9941        async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
9942            self.evidence.lock().unwrap().clone()
9943        }
9944        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9945            self.log.lock().unwrap().push("view");
9946            if *self.unreadable_after_merge.lock().unwrap()
9947                && !self.argvs.lock().unwrap().is_empty()
9948            {
9949                anyhow::bail!("forge unreachable");
9950            }
9951            let mut v = self.views.lock().unwrap();
9952            let mut seen = if v.len() > 1 {
9953                v.pop_front().unwrap()
9954            } else {
9955                v[0].clone()
9956            };
9957            if *self.merged.lock().unwrap() {
9958                seen.pr.state = PrLifecycle::Merged;
9959            }
9960            Ok(seen)
9961        }
9962        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9963            self.log.lock().unwrap().push("merge");
9964            self.argvs.lock().unwrap().push(argv.to_vec());
9965            let out = self
9966                .merges
9967                .lock()
9968                .unwrap()
9969                .pop_front()
9970                .expect("unscripted merge");
9971            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9972            {
9973                *self.merged.lock().unwrap() = true;
9974            }
9975            Ok(out)
9976        }
9977        async fn poll(&self) {
9978            self.log.lock().unwrap().push("poll");
9979            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9980        }
9981        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9982            self.log.lock().unwrap().push("rerun");
9983            self.reruns.lock().unwrap().push(run.to_owned());
9984            if *self.rerun_fails.lock().unwrap() {
9985                anyhow::bail!("gh run rerun: no");
9986            }
9987            Ok(())
9988        }
9989        fn now(&self) -> i64 {
9990            *self.clock.lock().unwrap()
9991        }
9992        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9993            self.required.lock().unwrap().clone()
9994        }
9995        async fn fix(
9996            &self,
9997            _state: &mut RunState,
9998            _pr: &PrState,
9999            _round: usize,
10000            _budget: usize,
10001            _reason: &str,
10002            _logs: &str,
10003        ) -> Result<Fixed> {
10004            self.log.lock().unwrap().push("fix");
10005            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
10006        }
10007    }
10008
10009    const REFUSED: &str =
10010        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
10011
10012    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
10013        let mut pr = green_pr();
10014        pr.checks = checks;
10015        pr.blocking = Blocking::of(merge_state);
10016        if !comments {
10017            pr.review_comments.clear();
10018        }
10019        Seen {
10020            pr,
10021            title: "feat: x".to_owned(),
10022            failing_urls: Vec::new(),
10023            head: head.to_owned(),
10024            rollup_head: head.to_owned(),
10025            merge_state: merge_state.to_owned(),
10026            contexts: Vec::new(),
10027            base: "main".to_owned(),
10028        }
10029    }
10030
10031    fn landing_state() -> RunState {
10032        crate::run::pin_test_home();
10033        let mut state = run_state();
10034        state.config.graph.land_approval = false;
10035        state
10036    }
10037
10038    #[test]
10039    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
10040        assert!(!awaiting_new_head(None, "aaa"));
10041        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
10042        assert!(awaiting_new_head(Some("abc123"), "def456"));
10043        assert!(awaiting_new_head(Some("abc123"), ""));
10044    }
10045
10046    #[test]
10047    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
10048        let open = |c, m: &str| seen("a", c, m, false);
10049        let table = [
10050            (None, false, Refused::Pending),
10051            (
10052                Some(open(Checks::Pending, "BLOCKED")),
10053                false,
10054                Refused::Pending,
10055            ),
10056            (
10057                Some(open(Checks::Unknown, "BLOCKED")),
10058                false,
10059                Refused::Pending,
10060            ),
10061            (
10062                Some(open(Checks::Green, "UNKNOWN")),
10063                false,
10064                Refused::Pending,
10065            ),
10066            (Some(open(Checks::Green, "")), false, Refused::Pending),
10067            (
10068                Some(open(Checks::Green, "BLOCKED")),
10069                false,
10070                Refused::Recheck,
10071            ),
10072            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
10073        ];
10074        for (after, rechecked, want) in table {
10075            assert_eq!(
10076                classify_refusal(after.as_ref(), rechecked, "a", CiExpectation::Expected),
10077                want
10078            );
10079        }
10080        let mut closed = open(Checks::Green, "CLEAN");
10081        closed.pr.state = PrLifecycle::Closed;
10082        assert_eq!(
10083            classify_refusal(Some(&closed), false, "a", CiExpectation::Expected),
10084            Refused::Final
10085        );
10086    }
10087
10088    #[tokio::test]
10089    async fn a_normal_landing_merges_on_the_first_look() {
10090        let mut state = landing_state();
10091        let forge = Scripted::new(
10092            vec![seen("a", Checks::Green, "CLEAN", false)],
10093            vec![(true, "")],
10094        );
10095        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10096            .await
10097            .unwrap();
10098        // One fresh read, then the head-bound merge.
10099        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10100        assert_eq!(state.status, RunStatus::Merged);
10101    }
10102
10103    #[tokio::test]
10104    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
10105        let mut state = landing_state();
10106        let forge = Scripted::new(
10107            vec![seen("a", Checks::Green, "CLEAN", false)],
10108            std::iter::repeat_n((true, ""), 100).collect(),
10109        );
10110        *forge.queued.lock().unwrap() = true;
10111        let task = async {
10112            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10113                .await
10114                .unwrap();
10115        };
10116        // Still open after the command succeeded: it keeps watching and
10117        // never records a merge (it stops at the wait ceiling instead).
10118        task.await;
10119        assert_ne!(state.status, RunStatus::Merged);
10120    }
10121
10122    #[tokio::test]
10123    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
10124        let mut state = landing_state();
10125        let forge = Scripted::new(
10126            vec![seen("a", Checks::Green, "CLEAN", false)],
10127            std::iter::repeat_n((true, ""), 100).collect(),
10128        );
10129        *forge.unreadable_after_merge.lock().unwrap() = true;
10130        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10131            .await
10132            .ok();
10133        assert_ne!(state.status, RunStatus::Merged);
10134    }
10135
10136    #[tokio::test]
10137    async fn a_task_public_head_is_awaited_on_land_entry() {
10138        let mut state = landing_state();
10139        state.pr_head = Some("magi/task-abcd-1234".into());
10140        state.pr_pushed_head = Some("new".into());
10141        let forge = Scripted::new(
10142            vec![
10143                seen("old", Checks::Green, "CLEAN", false),
10144                seen("new", Checks::Green, "CLEAN", false),
10145            ],
10146            vec![(true, "")],
10147        );
10148        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10149            .await
10150            .unwrap();
10151        assert_eq!(
10152            forge.calls(),
10153            ["view", "poll", "view", "view", "merge", "view"]
10154        );
10155        assert_eq!(state.status, RunStatus::Merged);
10156    }
10157
10158    #[tokio::test]
10159    async fn a_superseded_public_head_owner_cannot_reenter_land() {
10160        let mut state = landing_state();
10161        state.pr_taken_over_by = Some("next".into());
10162        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
10163        assert!(
10164            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10165                .await
10166                .is_err()
10167        );
10168        assert!(forge.calls().is_empty());
10169    }
10170
10171    #[tokio::test]
10172    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
10173        let mut state = landing_state();
10174        let forge = Scripted::new(
10175            vec![
10176                seen("old", Checks::Green, "CLEAN", true),
10177                // The forge has not moved to the new head yet: still green.
10178                seen("old", Checks::Green, "CLEAN", true),
10179                seen("new", Checks::Pending, "BLOCKED", true),
10180                seen("new", Checks::Green, "CLEAN", true),
10181            ],
10182            vec![(true, "")],
10183        );
10184        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10185            head: "NEW".to_owned(),
10186        });
10187        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10188            .await
10189            .unwrap();
10190        assert_eq!(
10191            forge.calls(),
10192            [
10193                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
10194                "view"
10195            ]
10196        );
10197        assert_eq!(state.status, RunStatus::Merged);
10198    }
10199
10200    #[tokio::test]
10201    async fn a_head_that_never_arrives_stops_naming_both_commits() {
10202        let mut state = landing_state();
10203        let forge = Scripted::new(
10204            vec![
10205                seen("old", Checks::Green, "CLEAN", true),
10206                seen("someone-elses", Checks::Green, "CLEAN", true),
10207            ],
10208            vec![],
10209        );
10210        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10211            head: "mine".to_owned(),
10212        });
10213        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10214            .await
10215            .unwrap();
10216        assert!(!forge.calls().contains(&"merge"));
10217        let why = state.merge.as_ref().unwrap().detail.clone();
10218        assert!(
10219            why.contains("mine") && why.contains("someone-elses"),
10220            "{why}"
10221        );
10222        assert_eq!(state.status, RunStatus::Blocked);
10223    }
10224
10225    #[tokio::test]
10226    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
10227        let mut state = landing_state();
10228        let forge = Scripted::new(
10229            vec![
10230                seen("a", Checks::Green, "CLEAN", false),
10231                seen("a", Checks::Green, "CLEAN", false),
10232                seen("a", Checks::Pending, "BLOCKED", false),
10233                seen("a", Checks::Pending, "BLOCKED", false),
10234                seen("a", Checks::Green, "CLEAN", false),
10235            ],
10236            vec![(false, REFUSED), (true, "")],
10237        );
10238        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10239            .await
10240            .unwrap();
10241        assert_eq!(
10242            forge.calls(),
10243            [
10244                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
10245                "view"
10246            ]
10247        );
10248        assert_eq!(state.status, RunStatus::Merged);
10249    }
10250
10251    #[tokio::test]
10252    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
10253        let mut state = landing_state();
10254        let forge = Scripted::new(
10255            vec![
10256                seen("a", Checks::Green, "CLEAN", false),
10257                seen("a", Checks::Green, "BLOCKED", false),
10258            ],
10259            vec![(false, REFUSED), (false, REFUSED)],
10260        );
10261        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10262            .await
10263            .unwrap();
10264        // One re-look is allowed for the forge's own lag, then it is final.
10265        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
10266        let why = state.merge.as_ref().unwrap().detail.clone();
10267        assert!(
10268            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
10269            "{why}"
10270        );
10271        assert_eq!(state.status, RunStatus::Blocked);
10272    }
10273
10274    #[test]
10275    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
10276        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
10277        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
10278        // The pull request is still on the commit before the push.
10279        assert_eq!(bound_head("old", "old", Some("new")), None);
10280        // The checks are the previous commit's.
10281        assert_eq!(bound_head("new", "old", Some("new")), None);
10282        assert_eq!(bound_head("new", "old", None), None);
10283        // Nothing readable.
10284        assert_eq!(bound_head("", "", None), None);
10285        assert_eq!(bound_head("", "", Some("new")), None);
10286        assert_eq!(bound_head("abc", "", None), None);
10287    }
10288
10289    fn view_json(head: &str) -> String {
10290        format!(
10291            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
10292            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
10293            "reviews":[],"comments":[]}}"#
10294        )
10295    }
10296
10297    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
10298        format!(
10299            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
10300            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
10301            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
10302            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
10303        )
10304    }
10305
10306    #[test]
10307    fn rollup_is_bound_to_the_commit_in_the_same_node() {
10308        // The view already points at the new head, but the node still answers
10309        // for the old commit with its red check: the head stays unbound.
10310        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
10311        assert_eq!(s.rollup_head, "old");
10312        assert_eq!(s.pr.checks, Checks::Red);
10313        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
10314        assert_eq!(s.failing_urls.len(), 1);
10315    }
10316
10317    #[test]
10318    fn checks_come_from_the_node_not_the_view() {
10319        let view = view_json("new").replace(
10320            r#""reviews""#,
10321            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
10322        );
10323        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
10324        assert_eq!(s.pr.checks, Checks::Green);
10325        assert!(s.pr.failing.is_empty());
10326        assert_eq!(
10327            bound_head(&s.head, &s.rollup_head, Some("new")),
10328            Some("new")
10329        );
10330    }
10331
10332    #[test]
10333    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
10334        for node in [
10335            None,
10336            Some("not json".to_owned()),
10337            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
10338            Some(node_json("new", "SUCCESS", true)),
10339        ] {
10340            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
10341            assert!(s.rollup_head.is_empty());
10342            assert_eq!(s.pr.checks, Checks::Unknown);
10343            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
10344        }
10345    }
10346
10347    #[test]
10348    fn the_merge_command_is_pinned_to_the_observed_head() {
10349        let argv = merge_argv_at(7, "feat: x", "deadbeef");
10350        let at = argv
10351            .iter()
10352            .position(|a| a == "--match-head-commit")
10353            .unwrap();
10354        assert_eq!(argv[at + 1], "deadbeef");
10355    }
10356
10357    #[tokio::test]
10358    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
10359        let mut state = landing_state();
10360        // The pull request is on the pushed head but the rollup is still the
10361        // previous commit's red, non-required result.
10362        let mut stale = seen("new", Checks::Red, "CLEAN", false);
10363        stale.rollup_head = "old".to_owned();
10364        let forge = Scripted::new(
10365            vec![seen("old", Checks::Green, "CLEAN", true), stale],
10366            vec![],
10367        );
10368        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10369            head: "new".to_owned(),
10370        });
10371        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10372            .await
10373            .unwrap();
10374        assert!(!forge.calls().contains(&"merge"));
10375        assert_eq!(state.status, RunStatus::Blocked);
10376        let why = state.merge.as_ref().unwrap().detail.clone();
10377        assert!(why.contains("new") && why.contains("old"), "{why}");
10378    }
10379
10380    #[test]
10381    fn a_refusal_read_against_another_commits_checks_is_pending() {
10382        let mut after = seen("a", Checks::Green, "BLOCKED", false);
10383        after.rollup_head = "old".to_owned();
10384        assert_eq!(
10385            classify_refusal(Some(&after), true, "a", CiExpectation::Expected),
10386            Refused::Pending
10387        );
10388    }
10389
10390    #[tokio::test]
10391    async fn absent_ci_empty_rollup_reaches_approval_then_merges_on_the_approved_head() {
10392        let mut state = landing_state();
10393        state.config.merge.ci = crate::config::MergeCi::None;
10394        state.config.graph.land_approval = true;
10395        let forge = Scripted::new(
10396            vec![seen("a", Checks::Unknown, "CLEAN", false)],
10397            vec![(true, "")],
10398        );
10399        land_with(&mut state, URL, &forge).await.unwrap();
10400        assert!(state.parked);
10401        assert_eq!(forge.calls(), ["view"]);
10402        let store = ask::Questions::open();
10403        let mut q = store
10404            .get(&state.land_approval.as_ref().unwrap().question)
10405            .unwrap();
10406        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10407        store.put(&mut q).unwrap();
10408
10409        land_with(&mut state, URL, &forge).await.unwrap();
10410        assert_eq!(forge.calls(), ["view", "view", "view", "merge", "view"]);
10411        assert_eq!(state.status, RunStatus::Merged);
10412        assert_eq!(
10413            value_of(&forge.argvs()[0], "--match-head-commit"),
10414            Some("a")
10415        );
10416    }
10417
10418    #[tokio::test]
10419    async fn absent_ci_ignores_failed_workflow_runs_without_rerunning_them() {
10420        let mut state = landing_state();
10421        state.config.merge.ci = crate::config::MergeCi::None;
10422        let forge = Scripted::new(vec![red("a", Some("7"))], vec![(true, "")]);
10423        land_with(&mut state, URL, &forge).await.unwrap();
10424        assert!(forge.reruns.lock().unwrap().is_empty());
10425        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10426        assert_eq!(state.status, RunStatus::Merged);
10427    }
10428
10429    #[test]
10430    fn absent_ci_still_requires_a_readable_matching_head_and_resolved_comments() {
10431        let shown = BTreeSet::new();
10432        let guard = |s: Option<&Seen>| {
10433            direct_merge_is_safe(s, "a", &shown, 0, 4, Duration::ZERO, CiExpectation::Absent)
10434        };
10435        let mut fresh = seen("a", Checks::Unknown, "CLEAN", false);
10436        assert!(guard(Some(&fresh)));
10437        assert!(!guard(None));
10438        fresh.rollup_head = "old".to_owned();
10439        assert!(!guard(Some(&fresh)));
10440        fresh.rollup_head = "a".to_owned();
10441        fresh.head = "b".to_owned();
10442        assert!(!guard(Some(&fresh)));
10443        assert!(!guard(Some(&seen("a", Checks::Unknown, "CLEAN", true))));
10444        assert!(!guard(Some(&seen("a", Checks::Unknown, "DIRTY", false))));
10445    }
10446
10447    #[test]
10448    fn absent_ci_refusal_does_not_wait_for_third_party_checks() {
10449        for checks in [Checks::Unknown, Checks::Pending, Checks::Red, Checks::Green] {
10450            let after = seen("a", checks, "BLOCKED", false);
10451            assert_eq!(
10452                classify_refusal(Some(&after), false, "a", CiExpectation::Absent),
10453                Refused::Recheck
10454            );
10455            assert_eq!(
10456                classify_refusal(Some(&after), true, "a", CiExpectation::Absent),
10457                Refused::Final
10458            );
10459        }
10460    }
10461
10462    #[tokio::test]
10463    async fn a_matching_head_with_red_non_required_checks_still_merges() {
10464        let mut state = landing_state();
10465        let forge = Scripted::new(
10466            vec![seen("a", Checks::Red, "CLEAN", false)],
10467            vec![(true, "")],
10468        );
10469        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10470            .await
10471            .unwrap();
10472        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10473        assert_eq!(state.status, RunStatus::Merged);
10474    }
10475
10476    #[tokio::test]
10477    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
10478        let mut state = landing_state();
10479        let forge = Scripted::new(
10480            vec![
10481                seen("a", Checks::Green, "CLEAN", false),
10482                seen("a", Checks::Green, "CLEAN", false),
10483                // Re-viewed after the refusal: someone pushed.
10484                seen("b", Checks::Green, "BLOCKED", false),
10485                seen("b", Checks::Green, "CLEAN", false),
10486            ],
10487            vec![(false, REFUSED), (true, "")],
10488        );
10489        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10490            .await
10491            .unwrap();
10492        assert_eq!(
10493            forge.calls(),
10494            [
10495                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
10496            ]
10497        );
10498        assert_eq!(state.status, RunStatus::Merged);
10499    }
10500
10501    fn merged_view(head: &str) -> Seen {
10502        let mut m = seen(head, Checks::Green, "CLEAN", false);
10503        m.pr.state = PrLifecycle::Merged;
10504        m
10505    }
10506
10507    fn has(argv: &[String], flag: &str) -> bool {
10508        argv.iter().any(|a| a == flag)
10509    }
10510
10511    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
10512        let at = argv.iter().position(|a| a == flag)?;
10513        argv.get(at + 1).map(String::as_str)
10514    }
10515
10516    const URL: &str = "https://github.com/o/r/pull/42";
10517
10518    #[tokio::test]
10519    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
10520        let mut state = landing_state();
10521        let forge = Scripted::new(
10522            vec![
10523                seen("abc", Checks::Green, "CLEAN", false),
10524                seen("abc", Checks::Green, "CLEAN", false),
10525            ],
10526            vec![(true, "")],
10527        );
10528        land_with(&mut state, URL, &forge).await.unwrap();
10529        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10530        let argv = &forge.argvs()[0];
10531        assert!(has(argv, "--squash") && has(argv, "--subject"));
10532        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
10533        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
10534        assert_eq!(state.status, RunStatus::Merged);
10535        assert!(state.land_armed_head.is_none());
10536    }
10537
10538    #[tokio::test]
10539    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
10540        let mut state = landing_state();
10541        state.land_armed_head = Some("a".to_owned());
10542        let forge = Scripted::new(
10543            vec![seen("a", Checks::Green, "CLEAN", false)],
10544            vec![(true, ""), (true, "")],
10545        );
10546        land_with(&mut state, URL, &forge).await.unwrap();
10547        let argvs = forge.argvs();
10548        assert!(has(&argvs[0], "--disable-auto"));
10549        assert!(!has(&argvs[1], "--auto"));
10550        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
10551        assert_eq!(state.status, RunStatus::Merged);
10552        assert!(state.land_armed_head.is_none());
10553    }
10554
10555    #[tokio::test]
10556    async fn an_approval_never_carries_over_to_a_new_head() {
10557        crate::run::pin_test_home();
10558        let mut state = run_state();
10559        state.config.graph.land_approval = true;
10560        let pr = green_pr();
10561        let store = ask::Questions::open();
10562
10563        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
10564            .await
10565            .unwrap();
10566        let mut q = store
10567            .list()
10568            .into_iter()
10569            .find(|q| q.run == state.id)
10570            .unwrap();
10571        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10572        store.put(&mut q).unwrap();
10573        assert_eq!(
10574            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
10575                .await
10576                .unwrap(),
10577            ApprovalGate::Approved,
10578            "the same head keeps its approval"
10579        );
10580
10581        // A new head is a new question, not the old word.
10582        assert_eq!(
10583            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10584                .await
10585                .unwrap(),
10586            ApprovalGate::Pending
10587        );
10588        let all: Vec<_> = store
10589            .list()
10590            .into_iter()
10591            .filter(|q| q.run == state.id)
10592            .collect();
10593        assert_eq!(all.len(), 2);
10594
10595        // A question recorded before heads were tracked is not reused either.
10596        state.land_approval = None;
10597        assert_eq!(
10598            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10599                .await
10600                .unwrap(),
10601            ApprovalGate::Pending
10602        );
10603        let open = store
10604            .list()
10605            .into_iter()
10606            .filter(|q| q.run == state.id && q.status.open())
10607            .count();
10608        assert_eq!(open, 1, "the superseded question was retired");
10609    }
10610
10611    #[tokio::test]
10612    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
10613        let mut state = landing_state();
10614        let forge = Scripted::new(
10615            vec![
10616                seen("a", Checks::Green, "CLEAN", false),
10617                // The fresh read before the merge: someone pushed.
10618                seen("b", Checks::Green, "CLEAN", false),
10619            ],
10620            vec![(true, "")],
10621        );
10622        land_with(&mut state, URL, &forge).await.unwrap();
10623        let argvs = forge.argvs();
10624        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
10625        assert!(!has(&argvs[0], "--auto"));
10626        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
10627        assert_eq!(state.status, RunStatus::Merged);
10628    }
10629
10630    fn passing(label: &str) -> CheckInfo {
10631        CheckInfo {
10632            label: label.to_owned(),
10633            verdict: Verdict::Pass,
10634            required: Some(false),
10635            run: None,
10636        }
10637    }
10638
10639    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
10640        (label.to_owned(), run.map(str::to_owned))
10641    }
10642
10643    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
10644        LandReruns {
10645            head: head.to_owned(),
10646            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
10647        }
10648    }
10649
10650    #[test]
10651    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
10652        let f = [failing("test (macos)", Some("7"))];
10653        assert_eq!(
10654            rerun_step(&f, None, "h", 100),
10655            RerunStep::Rerun(vec!["7".to_owned()])
10656        );
10657        let rec = reran("h", &[("7", 100)]);
10658        let grace = crate::release_watch::RERUN_GRACE;
10659        assert_eq!(
10660            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
10661            RerunStep::Grace
10662        );
10663        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
10664    }
10665
10666    #[test]
10667    fn a_failure_without_a_run_id_goes_to_the_fixer() {
10668        let f = [failing("a", Some("7")), failing("ci/external", None)];
10669        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
10670        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
10671    }
10672
10673    #[test]
10674    fn a_moved_head_forgets_the_reruns() {
10675        let f = [failing("a", Some("7"))];
10676        let rec = reran("old", &[("7", 100)]);
10677        assert_eq!(
10678            rerun_step(&f, Some(&rec), "new", 101),
10679            RerunStep::Rerun(vec!["7".to_owned()])
10680        );
10681    }
10682
10683    #[test]
10684    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
10685        let f = [
10686            failing("a", Some("7")),
10687            failing("b", Some("7")),
10688            failing("c", Some("8")),
10689        ];
10690        assert_eq!(
10691            rerun_step(&f, None, "h", 0),
10692            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
10693        );
10694        let rec = reran("h", &[("7", 0)]);
10695        assert_eq!(
10696            rerun_step(&f, Some(&rec), "h", 1),
10697            RerunStep::Rerun(vec!["8".to_owned()])
10698        );
10699    }
10700
10701    #[test]
10702    fn only_blocking_failures_are_candidates_for_a_rerun() {
10703        let check = |label: &str, verdict, required, run: &str| CheckInfo {
10704            label: label.to_owned(),
10705            verdict,
10706            required,
10707            run: Some(run.to_owned()),
10708        };
10709        let got = rerunnable(&[
10710            check("passed", Verdict::Pass, Some(true), "1"),
10711            check("optional", Verdict::Fail, Some(false), "2"),
10712            check("required", Verdict::Fail, Some(true), "3"),
10713            check("unknown", Verdict::Fail, None, "4"),
10714        ]);
10715        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
10716        assert_eq!(labels, ["required", "unknown"]);
10717    }
10718
10719    fn unrelated_ci_fixture() -> CiEvidence {
10720        let failure = CiFailure {
10721            path: "tests/web_render.rs".into(),
10722            start_line: 42,
10723            end_line: 42,
10724            title: "question_identity_disclosure_copy_and_live_updates".into(),
10725            message: "assertion failed".into(),
10726        };
10727        CiEvidence {
10728            head: "a".into(),
10729            base: "base-sha".into(),
10730            changed: vec![
10731                "AGENTS.md".into(),
10732                "src/talk.rs".into(),
10733                "tests/consult_investigation.rs".into(),
10734            ],
10735            checks: vec![CiCheckEvidence {
10736                label: "coverage".into(),
10737                head_check: 70,
10738                base_check: 60,
10739                app: 1,
10740                run: "7".into(),
10741                base_run: "6".into(),
10742                head_failures: vec![failure.clone()],
10743                base_failures: vec![failure],
10744                rerun_exhausted: true,
10745            }],
10746        }
10747    }
10748
10749    #[test]
10750    fn unrelated_ci_requires_complete_structural_evidence_for_every_failure() {
10751        let good = unrelated_ci_fixture();
10752        let red = vec!["coverage".into()];
10753        assert!(good.unrelated("a", &red));
10754        assert!(!good.unrelated("another-head", &red));
10755        assert!(!good.unrelated("a", &["coverage".into(), "test".into()]));
10756        assert!(!good.unrelated("a", &[]));
10757        for mutation in 0..8 {
10758            let mut bad = good.clone();
10759            match mutation {
10760                0 => bad.checks[0].rerun_exhausted = false,
10761                1 => bad.checks[0].base_failures.clear(),
10762                2 => bad.checks[0].head_failures.clear(),
10763                3 => bad.changed.push("tests/web_render.rs".into()),
10764                4 => bad.changed.push(".github/workflows/ci.yml".into()),
10765                5 => bad.checks[0].base_failures[0].message = "different failure".into(),
10766                6 => bad.checks.push(bad.checks[0].clone()),
10767                _ => bad.base.clear(),
10768            }
10769            assert!(!bad.unrelated("a", &red), "mutation {mutation}");
10770        }
10771    }
10772
10773    #[test]
10774    fn red_unknown_merge_state_waits_before_repair_but_never_implies_merge() {
10775        let pr = seen("a", Checks::Red, "UNKNOWN", false).pr;
10776        assert_eq!(decide(&pr, 0, 2, Duration::ZERO), Step::Wait);
10777        assert!(matches!(decide(&pr, 0, 2, CHECKS_GRACE), Step::Fix { .. }));
10778        assert!(matches!(
10779            decide(&pr, 2, 2, CHECKS_GRACE),
10780            Step::GiveUp { .. }
10781        ));
10782        let optional = CheckInfo {
10783            label: "coverage".into(),
10784            verdict: Verdict::Fail,
10785            required: Some(false),
10786            run: Some("7".into()),
10787        };
10788        assert!(rerunnable_with(std::slice::from_ref(&optional), false).is_empty());
10789        assert_eq!(
10790            rerunnable_with(&[optional], true),
10791            vec![("coverage".into(), Some("7".into()))]
10792        );
10793    }
10794
10795    #[tokio::test]
10796    async fn unrelated_ci_after_a_comment_round_does_not_charge_a_no_commit_stop() {
10797        let mut state = landing_state();
10798        let mut red = coverage_unknown();
10799        red.pr = seen("a", Checks::Red, "BLOCKED", true).pr;
10800        red.pr.failing = vec!["coverage".into()];
10801        let forge = Scripted::new(vec![red], vec![]);
10802        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10803        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10804        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10805        assert!(forge.calls().contains(&"fix"));
10806        assert!(state.land_unrelated.is_some());
10807        assert!(state.parked);
10808        assert!(
10809            !state
10810                .events
10811                .iter()
10812                .any(|e| e.message.contains("fixer produced no commit"))
10813        );
10814    }
10815
10816    fn coverage_unknown() -> Seen {
10817        let mut s = red("a", Some("7"));
10818        s.pr.blocking = Blocking::Unsaid;
10819        s.merge_state = "UNKNOWN".into();
10820        s.pr.failing = vec!["coverage".into()];
10821        s.contexts[0].label = "coverage".into();
10822        s.contexts[0].required = Some(false);
10823        s
10824    }
10825
10826    #[tokio::test]
10827    async fn run_196c_unknown_then_unstable_does_not_spend_a_fix_round() {
10828        let mut state = landing_state();
10829        let unknown = coverage_unknown();
10830        let mut unstable = unknown.clone();
10831        unstable.pr.blocking = Blocking::of("UNSTABLE");
10832        unstable.merge_state = "UNSTABLE".into();
10833        let forge = Scripted::new(vec![unknown, unstable], vec![(true, "")]);
10834        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10835        assert!(!forge.calls().contains(&"fix"));
10836        assert!(!forge.calls().contains(&"rerun"));
10837        assert_eq!(state.status, RunStatus::Merged);
10838        assert_eq!(state.pr.unwrap().round, 0);
10839    }
10840
10841    #[tokio::test]
10842    async fn run_196c_persistent_unknown_reruns_optional_once_then_parks_with_evidence() {
10843        let mut state = landing_state();
10844        let forge = Scripted::new(vec![coverage_unknown()], vec![]);
10845        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10846        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10847        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10848        assert!(!forge.calls().contains(&"fix"));
10849        assert!(state.parked);
10850        assert_eq!(state.status, RunStatus::Landing);
10851        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10852        let hold = state.land_unrelated.as_ref().unwrap();
10853        let q = ask::Questions::open().get(&hold.question).unwrap();
10854        assert!(
10855            q.actions
10856                .values()
10857                .any(|a| matches!(a, ask::ChoiceAction::Resume { .. }))
10858        );
10859        assert!(
10860            state
10861                .events
10862                .iter()
10863                .any(|e| e.message.contains("[ci-unrelated]") && e.message.contains("base-sha"))
10864        );
10865        let first = state.land_unsaid.clone();
10866        // A resume on this head neither extends UNKNOWN grace nor reruns 7.
10867        state.parked = false;
10868        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10869        assert_eq!(state.land_unsaid, first);
10870        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10871    }
10872
10873    fn red(head: &str, run: Option<&str>) -> Seen {
10874        let mut s = seen(head, Checks::Red, "BLOCKED", false);
10875        s.pr.failing = vec!["test (macos)".to_owned()];
10876        s.contexts = vec![CheckInfo {
10877            label: "test (macos)".to_owned(),
10878            verdict: Verdict::Fail,
10879            required: Some(true),
10880            run: run.map(str::to_owned),
10881        }];
10882        s
10883    }
10884
10885    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
10886
10887    #[tokio::test]
10888    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
10889        let mut state = landing_state();
10890        let forge = Scripted::new(
10891            vec![
10892                red("a", Some("7")),
10893                seen("a", Checks::Pending, "BLOCKED", false),
10894                seen("a", Checks::Green, "CLEAN", false),
10895            ],
10896            vec![(true, "")],
10897        );
10898        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10899        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10900        assert!(!forge.calls().contains(&"fix"));
10901        assert_eq!(state.status, RunStatus::Merged);
10902        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10903        let argvs = forge.argvs();
10904        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
10905        assert!(
10906            state
10907                .events
10908                .iter()
10909                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
10910        );
10911    }
10912
10913    #[tokio::test]
10914    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
10915        let mut state = landing_state();
10916        let forge = Scripted::new(
10917            vec![
10918                red("a", Some("7")),
10919                seen("a", Checks::Green, "CLEAN", false),
10920            ],
10921            vec![(true, "")],
10922        );
10923        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10924        // The red view repeats for the grace, then the fixer is asked once.
10925        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10926        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
10927        let calls = forge.calls();
10928        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
10929        let fix_at = calls.iter().position(|c| *c == "fix");
10930        assert!(fix_at.is_none_or(|f| f > rerun_at));
10931    }
10932
10933    #[tokio::test]
10934    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
10935        let mut state = landing_state();
10936        let mut v = red("a", Some("7"));
10937        v.pr.checks = Checks::Pending;
10938        v.contexts.push(CheckInfo {
10939            label: "slow".to_owned(),
10940            verdict: Verdict::Pending,
10941            required: Some(true),
10942            run: Some("8".to_owned()),
10943        });
10944        let forge = Scripted::new(
10945            vec![v, seen("a", Checks::Green, "CLEAN", false)],
10946            vec![(true, "")],
10947        );
10948        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10949        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10950        assert_eq!(state.status, RunStatus::Merged);
10951    }
10952
10953    #[test]
10954    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10955        let mut v = red("a", Some("7"));
10956        v.contexts.push(CheckInfo {
10957            label: "same run, still going".to_owned(),
10958            verdict: Verdict::Pending,
10959            required: Some(true),
10960            run: Some("7".to_owned()),
10961        });
10962        assert!(rerunnable(&v.contexts).is_empty());
10963    }
10964
10965    #[tokio::test]
10966    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10967        let mut state = landing_state();
10968        state.config.graph.land_rounds = 0;
10969        let forge = Scripted::new(
10970            vec![
10971                red("a", Some("7")),
10972                seen("a", Checks::Green, "CLEAN", false),
10973            ],
10974            vec![(true, "")],
10975        );
10976        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10977        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10978        assert_eq!(state.status, RunStatus::Merged);
10979        assert!(!forge.calls().contains(&"fix"));
10980        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10981    }
10982
10983    #[tokio::test]
10984    async fn a_deferred_landing_fix_keeps_its_round_for_resume() {
10985        let mut state = landing_state();
10986        state.config.graph.land_rounds = 1;
10987        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", true)], vec![]);
10988        *forge.fix.lock().unwrap() = Some(Fixed::Deferred);
10989        let pr = land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10990        assert_eq!(pr.state, PrLifecycle::Open);
10991        assert_eq!(state.status, RunStatus::Blocked);
10992        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10993        assert!(
10994            state.merge_decision.is_none(),
10995            "no ordinary failure handoff"
10996        );
10997        assert!(forge.argvs().is_empty(), "no merge on outage");
10998
10999        let recovered = Scripted::new(
11000            vec![
11001                seen("a", Checks::Green, "CLEAN", true),
11002                seen("b", Checks::Green, "CLEAN", false),
11003            ],
11004            vec![(true, "")],
11005        );
11006        *recovered.fix.lock().unwrap() = Some(Fixed::Committed { head: "b".into() });
11007        land_with(&mut state, RERUN_PR, &recovered).await.unwrap();
11008        assert_eq!(state.status, RunStatus::Merged);
11009        assert_eq!(state.pr.as_ref().unwrap().round, 1);
11010        assert_eq!(recovered.calls().iter().filter(|c| **c == "fix").count(), 1);
11011    }
11012
11013    #[tokio::test]
11014    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
11015        let mut state = landing_state();
11016        state.config.graph.land_rounds = 1;
11017        let forge = Scripted::new(
11018            vec![
11019                seen("a", Checks::Green, "CLEAN", true),
11020                red("b", Some("7")),
11021                seen("b", Checks::Green, "CLEAN", false),
11022            ],
11023            vec![(true, "")],
11024        );
11025        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
11026            head: "b".to_owned(),
11027        });
11028        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
11029        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
11030        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
11031        assert_eq!(state.pr.as_ref().unwrap().round, 1);
11032        assert_eq!(state.status, RunStatus::Merged);
11033        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
11034        assert_eq!(
11035            value_of(&forge.argvs()[0], "--match-head-commit"),
11036            Some("b")
11037        );
11038    }
11039
11040    #[tokio::test]
11041    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
11042        let mut state = landing_state();
11043        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
11044        *forge.rerun_fails.lock().unwrap() = true;
11045        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
11046        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
11047        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
11048        assert_eq!(
11049            forge.calls().iter().filter(|c| **c == "fix").count(),
11050            1,
11051            "{:?}",
11052            forge.calls()
11053        );
11054    }
11055
11056    #[tokio::test]
11057    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
11058        let mut state = landing_state();
11059        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
11060        // The record was written just before the previous pass died.
11061        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
11062        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
11063        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
11064        assert!(forge.reruns.lock().unwrap().is_empty());
11065    }
11066
11067    #[tokio::test]
11068    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
11069        let mut state = landing_state();
11070        state.config.graph.rerun_flaky_checks = false;
11071        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
11072        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
11073        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
11074        assert!(forge.reruns.lock().unwrap().is_empty());
11075        assert!(forge.calls().contains(&"fix"));
11076
11077        let mut state = landing_state();
11078        let forge = Scripted::new(vec![red("a", None)], vec![]);
11079        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
11080        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
11081        assert!(forge.reruns.lock().unwrap().is_empty());
11082        assert!(forge.calls().contains(&"fix"));
11083    }
11084
11085    fn names(xs: &[&str]) -> BTreeSet<String> {
11086        xs.iter().map(|x| (*x).to_owned()).collect()
11087    }
11088
11089    #[test]
11090    fn a_required_check_the_rollup_never_listed_is_named() {
11091        let req = names(&["build"]);
11092        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
11093        assert!(why.contains("never reported: build"), "{why}");
11094        assert!(!why.contains("probably waiting for a review"), "{why}");
11095    }
11096
11097    #[test]
11098    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
11099        let why = waiting_on("BLOCKED", &[passing("review")], None);
11100        assert!(why.contains("could not be read"), "{why}");
11101        assert!(!why.contains("probably waiting for a review"), "{why}");
11102    }
11103
11104    #[test]
11105    fn all_required_reported_keeps_the_review_guess() {
11106        let req = names(&["build"]);
11107        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
11108        assert!(why.contains("probably waiting for a review"), "{why}");
11109        assert!(!why.contains("never reported"), "{why}");
11110    }
11111
11112    #[test]
11113    fn required_names_match_the_rollup_ignoring_case_only() {
11114        let req = names(&["Build"]);
11115        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
11116        assert!(!why.contains("never reported"), "{why}");
11117    }
11118
11119    #[test]
11120    fn required_contexts_are_read_from_protection_and_rulesets() {
11121        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
11122        assert_eq!(
11123            parse_classic_required(classic),
11124            Some(names(&["build", "lint"]))
11125        );
11126        let rules = r#"[{"type":"pull_request","parameters":{}},
11127            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
11128        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
11129        assert_eq!(parse_ruleset_required("nope"), None);
11130        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
11131    }
11132
11133    #[test]
11134    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
11135        let shown = BTreeSet::new();
11136        let ok = seen("a", Checks::Green, "CLEAN", false);
11137        let guard = |s: Option<&Seen>| {
11138            direct_merge_is_safe(
11139                s,
11140                "A",
11141                &shown,
11142                0,
11143                4,
11144                Duration::ZERO,
11145                CiExpectation::Expected,
11146            )
11147        };
11148        assert!(guard(Some(&ok)));
11149        assert!(!guard(None));
11150        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
11151        let mut stale = ok.clone();
11152        stale.rollup_head = "old".to_owned();
11153        assert!(!guard(Some(&stale)));
11154        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
11155        assert!(!guard(Some(&merged_view("a"))));
11156    }
11157
11158    #[test]
11159    fn the_rollup_node_carries_whether_each_check_is_required() {
11160        let node = node_json("new", "SUCCESS", false)
11161            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
11162        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
11163        assert_eq!(s.contexts.len(), 1);
11164        assert_eq!(s.contexts[0].required, Some(true));
11165        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
11166        assert_eq!(s.contexts[0].required, None);
11167    }
11168
11169    #[tokio::test]
11170    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
11171        let mut state = landing_state();
11172        state.land_armed_head = Some("a".to_owned());
11173        let forge = Scripted::new(
11174            vec![seen("a", Checks::Green, "CLEAN", true)],
11175            vec![(false, "disable exploded")],
11176        );
11177        land_with(&mut state, URL, &forge).await.unwrap();
11178        assert!(!forge.calls().contains(&"fix"));
11179        assert_eq!(state.status, RunStatus::Blocked);
11180        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
11181        let why = state.merge.as_ref().unwrap().detail.clone();
11182        assert!(why.contains("disable exploded"), "{why}");
11183    }
11184}