Skip to main content

magi/
land.rs

1//! Landing the winner: watch the pull request, fix what it complains about,
2//! and merge it.
3//!
4//! Opening the pull request used to be where magi stopped and the operator
5//! started: watch the checks, read what the review bots found, push a fix,
6//! wait again, merge. That loop is mechanical, it takes an hour of wall-clock
7//! time per pull request, and doing it by hand six times in one session is how
8//! a queue that drains unattended stops being unattended. So it lives here.
9//!
10//! # Shape
11//!
12//! [`PrState`] is one observation of a pull request and [`decide`] is the whole
13//! policy as a *pure* function of it. Nothing in [`decide`] talks to `gh`,
14//! which is what makes "green with an unresolved comment is a fix, not a merge"
15//! an assertion in a test rather than a claim in a comment. [`land`] is the
16//! only part that performs I/O: observe, decide, act, repeat.
17//!
18//! # What it refuses to do
19//!
20//! Merging is the one irreversible thing magi can do to a repository, so the
21//! loop is built to stop rather than to guess:
22//!
23//! * A red pull request is never merged. When the budget runs out the pull
24//!   request is left open with a comment naming what is still failing, because
25//!   a magi that force-merges a red pull request is worse than one that stops.
26//! * A pull request whose checks cannot be read at all (`gh` reported no
27//!   rollup) is not merged either. Landing is for repositories with CI; with no
28//!   signal there is nothing to be green.
29//! * A pull request a human merged or closed underneath us is
30//!   [`Step::Done`] - the person won, and their decision is not an error.
31//!
32//! # Why `--subject` is not optional
33//!
34//! A candidate branch holds one commit whose subject is
35//! `magi: candidate A (uncommitted work)`, and `gh pr merge --squash` prefers a
36//! single commit's message over the pull request title. Merging without
37//! [`merge_argv`]'s explicit `--subject` therefore writes a `main` history that
38//! says nothing about what landed. `AGENTS.md` records the trap; this module is
39//! where it is prevented.
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::MergeMode;
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{
58    ContestedHandoff, LandApproval, LandReruns, MergeOutcome, RunState, RunStatus, tail,
59};
60
61/// How often the pull request is re-read while its checks are still running.
62///
63/// Thirty seconds: a CI matrix takes minutes, so anything shorter is spent
64/// entirely on `gh` invocations, and anything much longer adds latency to every
65/// single round of a loop that already waits for agents.
66pub const POLL: Duration = Duration::from_secs(30);
67
68/// How long one wait may last before landing gives up on the checks finishing.
69///
70/// A workflow that has not settled in forty-five minutes is stuck on a runner
71/// queue, a missing approval, or a hung job - none of which more polling fixes,
72/// and all of which a person needs to see.
73pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
74
75/// How long the checks may stay unreadable before landing gives up on them.
76///
77/// GitHub registers a workflow run some seconds after the branch is pushed, so
78/// immediately after a pull request is opened "no checks" and "no CI in this
79/// repository" look identical. Measured on run 01c2: magi opened pull request
80/// 22, read `unknown` four seconds later, refused to merge on a guess and
81/// marked the run blocked - and every check on that pull request was green
82/// minutes afterwards, with the whole competition then re-run from scratch for
83/// a task that was already finished. Three minutes is well past the observed
84/// registration delay and still bounded, so a repository that genuinely has no
85/// checks costs one three-minute wait and then says so.
86pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
87
88/// Bytes of failing log kept per check. The fixer needs the assertion and the
89/// frame around it, not the forty thousand lines of `cargo` output above it.
90const LOG_TAIL: usize = 4_000;
91
92/// Failing checks whose logs are fetched. Beyond a handful the failures share a
93/// cause, and fetching each one costs a `gh` round trip.
94const MAX_LOGS: usize = 3;
95
96/// Marker carried by every comment magi posts on a pull request.
97///
98/// Without it magi's own "still failing" comment is indistinguishable from a
99/// reviewer's, and the next observation would hand magi's own prose to the
100/// fixer as a finding.
101pub const MARKER: &str = "<!-- magi:land -->";
102
103/// Markers a bot puts in a comment to say that the comment is not a review.
104///
105/// CodeRabbit labels its own machinery in HTML comments - the trigger notice,
106/// the walkthrough summary, the "thanks for using" footer - and its actual
107/// findings arrive as *inline* review comments with a path and a line. Taking
108/// the bot at its word is more honest than guessing from prose, and it is the
109/// difference between a fix round that has something to fix and one that asks
110/// an agent to act on a quota notice.
111const NOT_A_REVIEW: [&str; 3] = [
112    "skip review by coderabbit.ai",
113    "summarize by coderabbit.ai",
114    "<!-- tips_start -->",
115];
116
117/// Where a pull request is in its life.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrLifecycle {
120    /// Still ours to land.
121    Open,
122    /// Already merged, by us or by a person.
123    Merged,
124    /// Closed without merging.
125    Closed,
126}
127
128/// The aggregate verdict of a pull request's checks.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Checks {
131    /// At least one check has not finished.
132    Pending,
133    /// Every check passed (a skipped check counts as passed: the review
134    /// workflow skips release and bot pull requests by design).
135    Green,
136    /// At least one check finished without passing.
137    Red,
138    /// Nothing readable - no rollup at all, or a status magi does not know.
139    Unknown,
140}
141
142impl PrLifecycle {
143    /// Stable lower-case name, as the API and the reports spell it.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::Open => "open",
147            Self::Merged => "merged",
148            Self::Closed => "closed",
149        }
150    }
151}
152
153impl Checks {
154    /// Stable lower-case name, as the API and the reports spell it.
155    pub fn as_str(self) -> &'static str {
156        match self {
157            Self::Pending => "pending",
158            Self::Green => "green",
159            Self::Red => "red",
160            Self::Unknown => "unknown",
161        }
162    }
163}
164
165/// One outstanding review comment, human or bot.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ReviewComment {
168    /// Login of whoever wrote it.
169    pub author: String,
170    /// File it was left on, for inline review comments.
171    pub path: Option<String>,
172    /// Line it was left on, when the comment is inline and still anchored.
173    pub line: Option<u64>,
174    /// The comment itself, as written.
175    pub body: String,
176}
177
178/// One observation of a pull request.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct PrState {
181    /// Pull request url, as `gh` reports it.
182    pub url: String,
183    /// Pull request number.
184    pub number: u64,
185    /// Open, merged, or closed.
186    pub state: PrLifecycle,
187    /// Aggregate check verdict.
188    pub checks: Checks,
189    /// Names of the checks that finished without passing.
190    pub failing: Vec<String>,
191    /// Comments that still want an answer, human and bot.
192    pub review_comments: Vec<ReviewComment>,
193    /// Whether the forge itself considers the failures blocking.
194    pub blocking: Blocking,
195}
196
197/// Whether a failing check actually stands between the pull request and
198/// `main`, according to the forge.
199///
200/// The rollup lists every check equally, so `coverage` going red on a
201/// repository that deliberately does not require it looked exactly like a
202/// broken build - and magi answered by spending a fix round on a change that
203/// was fine. Pull request 37 had to be merged by hand for that reason: the
204/// only red check was `editorconfig`, which was failing because the *action*
205/// could not fetch its own binary, and which the repository does not require.
206///
207/// `mergeStateStatus` is where GitHub applies the required-check set, so it
208/// is the one field that can tell the difference.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Blocking {
211    /// Required checks are satisfied and the branch merges cleanly.
212    No,
213    /// Something required is failing or missing.
214    Yes,
215    /// The branch no longer merges: the base moved under it.
216    Conflict,
217    /// The forge did not say - an older `gh`, or a token without the scope.
218    /// Treated as `Yes`, because refusing to guess is the rule everywhere
219    /// else in this module.
220    Unsaid,
221}
222
223impl Blocking {
224    /// Read `mergeStateStatus`, which is upper-case in `gh`'s output.
225    fn of(raw: &str) -> Self {
226        match raw.to_ascii_uppercase().as_str() {
227            // Mergeable. `UNSTABLE` is the interesting one: mergeable, with a
228            // non-required check failing or still running.
229            "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
230            "DIRTY" => Self::Conflict,
231            "" | "UNKNOWN" => Self::Unsaid,
232            // BLOCKED, BEHIND, DRAFT: something has to change first.
233            _ => Self::Yes,
234        }
235    }
236
237    /// Does this stand between the pull request and the base branch?
238    #[must_use]
239    pub fn stops_a_merge(self) -> bool {
240        !matches!(self, Self::No)
241    }
242}
243
244/// What the loop decided to do next. Pure, so the policy is testable.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Step {
247    /// Checks are still running; re-read the pull request after [`POLL`].
248    Wait,
249    /// The base moved and the branch no longer merges: rebase it.
250    ///
251    /// Not a fix round. Nothing is wrong with the change - a competition
252    /// that runs for two hours against a repository merging pull requests
253    /// all day conflicts on the way in, and that is arithmetic rather than a
254    /// defect. Pull requests 35 and 37 were both rebased by hand for exactly
255    /// this.
256    Rebase,
257    /// Red checks or unresolved comments; run a fix round.
258    Fix {
259        /// What is unhappy, in one line, for the run log and the fix prompt.
260        reason: String,
261    },
262    /// Green and nothing outstanding; merge it.
263    Merge,
264    /// The pull request left our hands.
265    Done {
266        /// Did it land, or was it closed?
267        merged: bool,
268    },
269    /// Stop and leave the pull request to a person.
270    GiveUp {
271        /// Why magi stopped, in one line.
272        reason: String,
273    },
274}
275
276/// The outcome to record when `gh pr merge` exits non-zero, given what the
277/// pull request looked like immediately afterwards.
278///
279/// `gh pr merge` merges server-side first and only then does local work -
280/// deleting the branch, switching back to a base branch - so a non-zero exit
281/// does not mean the merge did not happen. In a jj-colocated repository it
282/// reliably does not mean that: git HEAD is detached, and `--delete-branch`
283/// ends with "could not determine current branch: not on any branch" *after*
284/// the merge has landed. Run ec12 merged pull request 28 into `main` and
285/// recorded `ok: false`, and its task was held waiting for a merge that was
286/// already done.
287///
288/// So the forge is asked, and its answer wins - the same authority [`decide`]
289/// gives the pull request's own state over everything else. The recorded
290/// detail carries both facts, because "the command failed and the merge
291/// happened anyway" is exactly what someone reading the run later needs to
292/// know.
293///
294/// `None` means the merge really did not happen, including when the pull
295/// request could not be read at all: an unreadable answer is not evidence of
296/// success.
297pub(crate) fn merged_after_all(
298    argv: &[String],
299    stderr: &str,
300    after: Option<PrLifecycle>,
301) -> Option<MergeOutcome> {
302    if after? != PrLifecycle::Merged {
303        return None;
304    }
305    Some(MergeOutcome {
306        mode: MergeMode::Pr,
307        ok: true,
308        detail: format!(
309            "gh {} (the command reported `{}`, but the pull request is merged)",
310            argv.join(" "),
311            stderr.trim()
312        ),
313        empty: false,
314    })
315}
316
317/// Decide the next step. No I/O.
318///
319/// `round` counts the fix rounds already spent, so `round == budget` means the
320/// budget is gone. A wait never spends a round: waiting is free, and a slow CI
321/// must not consume the allowance meant for actual fixes.
322///
323/// The order of the tests is the policy:
324///
325/// 1. **The pull request's own state wins.** A merge or a close that happened
326///    underneath us is the end of the story regardless of what the checks say.
327/// 2. **Pending beats red.** A check that is still running may yet fail, and one
328///    fix round that addresses every failure is cheaper than two that each
329///    address half - the fix pushes and restarts the whole suite anyway.
330/// 3. **Comments outrank green.** An unresolved comment holds the merge even
331///    when CI is happy; that is what a review is for.
332/// 4. **Unreadable is not absent.** Checks that cannot be read yet are waited
333///    on for [`CHECKS_GRACE`], because a pull request opened a moment ago has
334///    not been given its workflow runs yet. Past the grace they are treated as
335///    genuinely missing and magi stops rather than merge on a guess.
336pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
337    decide_with(pr, round, budget, waited, CiExpectation::Expected)
338}
339
340/// Whether the repository's CI is expected to report on this pull request.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum CiExpectation {
343    /// Checks will come: wait for them, judge them (the default, and what
344    /// [`decide`] always uses).
345    Expected,
346    /// No check will ever report (`[release] mode = "local"` on a repository
347    /// whose Actions never run). Waiting out [`CHECKS_GRACE`] or reading
348    /// `unknown` as a refusal would stall forever, so the checks are read as
349    /// absent and the pull request is ready as soon as it merges cleanly.
350    Absent,
351}
352
353/// [`decide`] with the CI expectation made explicit. `Expected` is exactly
354/// [`decide`]; `Absent` reads the absence of CI as the reason to proceed, and
355/// still stops for a conflict (the base moved), which a rebase cures and no
356/// check state does.
357pub fn decide_with(
358    pr: &PrState,
359    round: usize,
360    budget: usize,
361    waited: Duration,
362    ci: CiExpectation,
363) -> Step {
364    match pr.state {
365        PrLifecycle::Merged => return Step::Done { merged: true },
366        PrLifecycle::Closed => return Step::Done { merged: false },
367        PrLifecycle::Open => {}
368    }
369
370    // Before the checks: every check on a branch that cannot land is an
371    // answer about a state that cannot land.
372    if pr.blocking == Blocking::Conflict {
373        return Step::Rebase;
374    }
375
376    if ci == CiExpectation::Absent {
377        return Step::Merge;
378    }
379
380    let spent = round >= budget;
381    match pr.checks {
382        Checks::Pending => Step::Wait,
383        Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
384        Checks::Unknown => Step::GiveUp {
385            reason: format!(
386                "no check status is readable on the pull request after {} minute(s); \
387                 refusing to merge on a guess",
388                CHECKS_GRACE.as_secs() / 60
389            ),
390        },
391        // Red, but the forge says it does not stand in the way: the failing
392        // checks are ones this repository chose not to require. Spending a fix
393        // round on them asks an agent to repair something nobody is gating on
394        // - and pull request 37's only red check was an *action* that could
395        // not fetch its own binary. Merge, and name them so the record is
396        // honest about what was red when it landed.
397        Checks::Red if pr.blocking == Blocking::Unsaid && waited < CHECKS_GRACE => Step::Wait,
398        Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
399        Checks::Red => {
400            let what = format!(
401                "{} check(s) failing: {}",
402                pr.failing.len(),
403                pr.failing.join(", ")
404            );
405            if spent {
406                Step::GiveUp {
407                    reason: format!("{what} — still red after {budget} fix round(s)"),
408                }
409            } else {
410                Step::Fix { reason: what }
411            }
412        }
413        Checks::Green if pr.review_comments.is_empty() => Step::Merge,
414        Checks::Green => {
415            let what = format!(
416                "checks are green but {} review comment(s) are unresolved: {}",
417                pr.review_comments.len(),
418                authors(&pr.review_comments)
419            );
420            if spent {
421                Step::GiveUp {
422                    reason: format!("{what} — still unresolved after {budget} fix round(s)"),
423                }
424            } else {
425                Step::Fix { reason: what }
426            }
427        }
428    }
429}
430
431/// Distinct comment authors, in the order they first appear.
432fn authors(comments: &[ReviewComment]) -> String {
433    let mut seen: Vec<&str> = Vec::new();
434    for c in comments {
435        if !seen.contains(&c.author.as_str()) {
436            seen.push(&c.author);
437        }
438    }
439    seen.join(", ")
440}
441
442/// The argv magi merges with, minus the program name.
443///
444/// `--subject` is the point of this function existing: see the module docs.
445pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
446    vec![
447        "pr".to_owned(),
448        "merge".to_owned(),
449        number.to_string(),
450        "--squash".to_owned(),
451        "--delete-branch".to_owned(),
452        "--subject".to_owned(),
453        subject.to_owned(),
454    ]
455}
456
457/// [`merge_argv`] pinned to the commit the decision was made about.
458///
459/// `--match-head-commit` makes the forge refuse the merge if the branch moved
460/// after it was observed, so a push landing between the look and the merge is
461/// never merged unseen.
462pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
463    let mut argv = merge_argv(number, subject);
464    argv.push("--match-head-commit".to_owned());
465    argv.push(head.to_owned());
466    argv
467}
468
469/// Take auto-merge back. magi no longer arms auto-merge, but a run recorded by
470/// an older build may still have one standing on the forge, so the disarm
471/// paths (and `RunState::land_armed_head`) stay. Run before anything that changes the head, so an
472/// armed merge never outlives the commit that was approved for it.
473pub fn disable_automerge_argv(number: u64) -> Vec<String> {
474    ["pr", "merge", &number.to_string(), "--disable-auto"]
475        .map(str::to_owned)
476        .to_vec()
477}
478
479/// May the direct merge go ahead, judged from a fresh read?
480///
481/// The pull request must still be open on the approved head, the checks must
482/// have been read for that very head, and the policy must still say merge.
483/// Pure, so the head guard is asserted without a forge.
484fn direct_merge_is_safe(
485    fresh: Option<&Seen>,
486    approved_head: &str,
487    shown: &BTreeSet<String>,
488    round: usize,
489    budget: usize,
490    waited: Duration,
491) -> bool {
492    let Some(fresh) = fresh else {
493        return false;
494    };
495    if fresh.pr.state != PrLifecycle::Open {
496        return false;
497    }
498    let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
499        return false;
500    };
501    if !bound.eq_ignore_ascii_case(approved_head) {
502        return false;
503    }
504    let mut pr = fresh.pr.clone();
505    pr.review_comments.retain(|c| !shown.contains(&c.body));
506    decide(&pr, round, budget, waited) == Step::Merge
507}
508
509/// What GitHub is still waiting for, for the stop reason when an armed merge
510/// does not happen in time. No I/O.
511///
512/// Required checks that are pending or failing are named. A check whose
513/// required-ness could not be read is never assumed optional: every unsettled
514/// check is listed and the reason says so.
515fn waiting_on(
516    merge_state: &str,
517    contexts: &[CheckInfo],
518    required_set: Option<&BTreeSet<String>>,
519) -> String {
520    let state = if merge_state.is_empty() {
521        "unknown"
522    } else {
523        merge_state
524    };
525    let tag = |c: &CheckInfo| match c.verdict {
526        Verdict::Fail => "failed",
527        _ => "pending",
528    };
529    let unsettled: Vec<&CheckInfo> = contexts
530        .iter()
531        .filter(|c| c.verdict != Verdict::Pass)
532        .collect();
533    let required: Vec<String> = unsettled
534        .iter()
535        .filter(|c| c.required == Some(true))
536        .map(|c| format!("{} ({})", c.label, tag(c)))
537        .collect();
538    let unknown: Vec<String> = unsettled
539        .iter()
540        .filter(|c| c.required.is_none())
541        .map(|c| format!("{} ({})", c.label, tag(c)))
542        .collect();
543    // Required contexts the rollup never listed: nothing reported them, so no
544    // pending/failing entry exists to name. Matched case-insensitively against
545    // the labels as the rollup spells them, and no further.
546    let never: Vec<&str> = required_set
547        .map(|set| {
548            set.iter()
549                .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
550                .map(String::as_str)
551                .collect()
552        })
553        .unwrap_or_default();
554    let mut out = format!("merge state: {state}");
555    if !never.is_empty() {
556        let _ = write!(
557            out,
558            "; required checks never reported: {}",
559            never.join(", ")
560        );
561    }
562    if !required.is_empty() {
563        let _ = write!(
564            out,
565            "; required checks not passing: {}",
566            required.join(", ")
567        );
568    }
569    if !unknown.is_empty() {
570        let _ = write!(
571            out,
572            "; whether these are required could not be read, so they may be: {}",
573            unknown.join(", ")
574        );
575    }
576    if required.is_empty() && unknown.is_empty() && never.is_empty() {
577        if required_set.is_some() {
578            out.push_str(
579                "; no required check is pending, failing or unreported, so GitHub is probably \
580                 waiting for a review or another branch rule",
581            );
582        } else {
583            out.push_str(
584                "; the required check list could not be read, so a required check that was \
585                 never reported cannot be ruled out",
586            );
587        }
588    }
589    out
590}
591
592/// The squash subject to merge under.
593///
594/// The pull request title, unless it is empty or is a candidate branch's commit
595/// subject that leaked into the title - in which case the task's own first line
596/// is used, because `magi: candidate A (uncommitted work)` in `main` tells a
597/// reader nothing about what landed.
598pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
599    let title = pr_title.trim();
600    if !title.is_empty() && !title.starts_with("magi: candidate") {
601        return title.to_owned();
602    }
603    let first = instruction
604        .lines()
605        .map(str::trim)
606        .find(|l| !l.is_empty())
607        .unwrap_or("magi: land the winning candidate");
608    first.trim_start_matches(['#', ' ']).to_owned()
609}
610
611/// The choice that lets the merge happen, verbatim as the owner taps it.
612pub const APPROVE: &str = "merge";
613
614/// The choice that leaves the pull request open.
615pub const HOLD: &str = "hold";
616
617/// A bounded repair followed by a new review and a fresh owner decision.
618pub const REPAIR: &str = "fix findings and re-review";
619
620/// Whether review evidence offers a meaningful repair choice. This does not
621/// decide whether owner approval is required.
622pub fn repair_available(state: &RunState) -> bool {
623    state.reviews.last().is_some_and(|r| {
624        state.open_findings().iter().any(|f| f.severity.blocks())
625            || r.final_votes()
626                .iter()
627                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
628    })
629}
630
631/// All applicable reasons for consulting the owner, independent of repair choices.
632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
633pub enum ApprovalReason {
634    /// Universal owner approval is enabled for every merge.
635    EveryMerge,
636    /// Contested-merge approval is enabled and blocking findings remain open.
637    OpenBlockingFindings,
638    /// Contested-merge approval is enabled and a final reviewer vote rejects.
639    FinalReject,
640    /// An existing owner decision must be honored even if controls change.
641    ExistingDecision,
642    /// Merge repair requires a fresh owner decision, even on the same head.
643    AfterRepair,
644}
645
646/// Disabling fresh approval controls never bypasses an existing owner decision
647/// or the fresh decision owed after repair, even if repair kept the same head.
648pub fn approval_reasons(state: &RunState) -> Vec<ApprovalReason> {
649    let mut reasons = Vec::new();
650    if state.owner_hold(crate::config::OwnerHold::LandApproval) {
651        reasons.push(ApprovalReason::EveryMerge);
652    }
653    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
654        if state.open_findings().iter().any(|f| f.severity.blocks()) {
655            reasons.push(ApprovalReason::OpenBlockingFindings);
656        }
657        if state.reviews.last().is_some_and(|r| {
658            r.final_votes()
659                .iter()
660                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
661        }) {
662            reasons.push(ApprovalReason::FinalReject);
663        }
664    }
665    if state.land_approval.is_some() {
666        reasons.push(ApprovalReason::ExistingDecision);
667    }
668    if !state.merge_repairs.is_empty() {
669        reasons.push(ApprovalReason::AfterRepair);
670    }
671    reasons
672}
673
674/// Bound free-form approval evidence by characters, including an explicit
675/// ellipsis when abbreviated. Keep UTF-8 text intact in questions and panels.
676fn approval_excerpt(text: &str, max_chars: usize) -> String {
677    if text.chars().count() <= max_chars {
678        text.to_owned()
679    } else {
680        let mut excerpt: String = text.chars().take(max_chars.saturating_sub(1)).collect();
681        excerpt.push('…');
682        excerpt
683    }
684}
685
686/// Persisted verbatim in the question and panel at creation. Deputies and
687/// resumed questions use that snapshot, never reconstruct past policy from
688/// today's settings. Fixer claims and successful gates are not resolutions.
689fn approval_evidence(state: &RunState) -> String {
690    const TITLE_CHARS: usize = 100;
691    const REASON_CHARS: usize = 300;
692    let reason = |text: &str| approval_excerpt(text, REASON_CHARS);
693    let ja = words(&state.config.graph.language).html_lang == "ja";
694    let mut s = if ja {
695        "承認が必要な理由:\n"
696    } else {
697        "Approval required because:\n"
698    }
699    .to_owned();
700    for reason in approval_reasons(state) {
701        let label = match (reason, ja) {
702            (ApprovalReason::EveryMerge, false) => {
703                "land_approval=true: owner approval for every merge"
704            }
705            (ApprovalReason::EveryMerge, true) => {
706                "land_approval=true: すべてのマージに所有者の承認が必要"
707            }
708            (ApprovalReason::OpenBlockingFindings, false) => {
709                "hold_contested_merge=true: open Major-or-above internal findings"
710            }
711            (ApprovalReason::OpenBlockingFindings, true) => {
712                "hold_contested_merge=true: Major 以上の内部指摘が未解決"
713            }
714            (ApprovalReason::FinalReject, false) => {
715                "hold_contested_merge=true: final reviewer reject after reconsideration (even without Major findings)"
716            }
717            (ApprovalReason::FinalReject, true) => {
718                "hold_contested_merge=true: 再考後の最終票が reject(Major 指摘がなくても承認が必要)"
719            }
720            (ApprovalReason::ExistingDecision, false) => {
721                "existing owner decision: changing controls cannot bypass it"
722            }
723            (ApprovalReason::ExistingDecision, true) => {
724                "既存の所有者判断: 設定変更では迂回できません"
725            }
726            (ApprovalReason::AfterRepair, false) => {
727                "fresh owner approval after merge repair, even with an unchanged head"
728            }
729            (ApprovalReason::AfterRepair, true) => {
730                "マージ修復後の新しい所有者承認(head に変更がなくても必要)"
731            }
732        };
733        let control = match reason {
734            ApprovalReason::EveryMerge => Some(crate::config::OwnerHold::LandApproval),
735            ApprovalReason::OpenBlockingFindings | ApprovalReason::FinalReject => {
736                Some(crate::config::OwnerHold::HoldContestedMerge)
737            }
738            _ => None,
739        };
740        let source = control
741            .filter(|c| !c.base_value(&state.config.graph))
742            .and_then(|c| state.branch_owner_holds.iter().find(|h| h.control == c));
743        if let Some(source) = source {
744            if ja {
745                let _ = writeln!(
746                    s,
747                    "- {}=true(レビュー対象ブランチの {} 由来, {})",
748                    source.control.key(),
749                    source.file,
750                    source.head
751                );
752            } else {
753                let _ = writeln!(
754                    s,
755                    "- {}=true from the reviewed branch's {} ({})",
756                    source.control.key(),
757                    source.file,
758                    source.head
759                );
760            }
761        }
762        let _ = writeln!(s, "- {label}");
763    }
764    s.push_str(if ja {
765        "\n再考後の各レビュアーの最終票:\n"
766    } else {
767        "\nFinal reviewer votes after reconsideration:\n"
768    });
769    if let Some(round) = state.reviews.last() {
770        let votes = round.final_votes();
771        for r in &round.reviews {
772            let vote = votes
773                .iter()
774                .find(|(seat, _, _)| *seat == r.reviewer)
775                .map(|(_, _, v)| v.label())
776                .unwrap_or(if ja { "記録なし" } else { "not recorded" });
777            let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
778            let rv = round
779                .reconsideration
780                .iter()
781                .find(|rv| rv.reviewer == r.reviewer);
782            if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
783                let initial = r.vote.map(|v| v.label()).unwrap_or("?");
784                let _ = writeln!(
785                    s,
786                    "  {}: {initial} → {vote}",
787                    if ja { "再考" } else { "reconsideration" }
788                );
789                let _ = writeln!(
790                    s,
791                    "  {}: {}",
792                    if ja { "理由" } else { "reason" },
793                    reason(if rv.reason.trim().is_empty() {
794                        if ja { "記録なし" } else { "not recorded" }
795                    } else {
796                        &rv.reason
797                    })
798                );
799            } else {
800                let _ = writeln!(
801                    s,
802                    "  {}: {}",
803                    if ja {
804                        "初回票を保持・理由"
805                    } else {
806                        "initial vote retained; reason"
807                    },
808                    reason(if r.summary.trim().is_empty() {
809                        if ja { "記録なし" } else { "not recorded" }
810                    } else {
811                        &r.summary
812                    })
813                );
814                if let Some(rv) = rv {
815                    let _ = writeln!(
816                        s,
817                        "  {}: {}",
818                        if ja {
819                            "再投票の回答なし"
820                        } else {
821                            "no reconsideration vote"
822                        },
823                        reason(rv.failed.as_deref().unwrap_or(if ja {
824                            "理由の記録なし"
825                        } else {
826                            "reason not recorded"
827                        }))
828                    );
829                }
830            }
831            if let Some(failed) = &r.failed {
832                let _ = writeln!(
833                    s,
834                    "  {}: {}",
835                    if ja { "回答なし" } else { "no answer" },
836                    reason(failed)
837                );
838            }
839        }
840    } else {
841        s.push_str(if ja {
842            "レビュー記録なし\n"
843        } else {
844            "No review recorded\n"
845        });
846    }
847    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" });
848    let findings = state.last_round_findings();
849    for f in &findings {
850        let _ = writeln!(
851            s,
852            "[{}] {} {}:{}: {}\n{}",
853            f.id,
854            f.severity,
855            f.file.as_deref().unwrap_or("?"),
856            f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
857            approval_excerpt(&f.title, TITLE_CHARS),
858            reason(&f.detail)
859        );
860        let mut evidence = false;
861        for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
862            state
863                .operator_fixes
864                .iter()
865                .filter_map(|request| request.fix.as_ref()),
866        ) {
867            if fix.addressed.contains(&f.id) {
868                evidence = true;
869                let _ = writeln!(
870                    s,
871                    "  {} ({}): {}",
872                    if ja {
873                        "採用申告(解決未確認)"
874                    } else {
875                        "claimed addressed (resolution unconfirmed)"
876                    },
877                    fix.agent,
878                    reason(&fix.notes)
879                );
880            }
881            for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
882                evidence = true;
883                let _ = writeln!(
884                    s,
885                    "  {}: {}",
886                    if ja { "不採用理由" } else { "declined" },
887                    reason(&rejection.why)
888                );
889            }
890        }
891        if !evidence {
892            s.push_str(if ja {
893                "  採用・解決確認の記録なし\n"
894            } else {
895                "  No adoption or resolution evidence recorded\n"
896            });
897        }
898    }
899    if findings.is_empty() {
900        s.push_str(if ja {
901            "最終ラウンドに指摘の記録なし\n"
902        } else {
903            "No findings recorded in the last round\n"
904        });
905    }
906    s
907}
908
909/// Shared choices for web, CLI and consultation questions.
910pub fn approval_choices(state: &RunState) -> Vec<String> {
911    let mut choices = vec![
912        APPROVE.to_owned(),
913        HOLD.to_owned(),
914        REPAIR.to_owned(),
915        crate::merge_decision::WITHDRAW.to_owned(),
916        crate::merge_decision::DISMISS.to_owned(),
917    ];
918    choices.extend(
919        state
920            .config
921            .agents
922            .iter()
923            .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
924    );
925    choices
926}
927
928/// Extend unanswered legacy cards in place. Answered cards keep their choices;
929/// a legacy repair answer gains only its missing dispatch action.
930pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
931    if q.node != APPROVAL_NODE
932        || !state
933            .land_approval
934            .as_ref()
935            .is_some_and(|a| a.question == q.id)
936    {
937        return false;
938    }
939    let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
940    if q.status.open() {
941        // Older cards attached Resume to hold; a hold must remain answerable
942        // without dispatching a question action.
943        q.actions.remove(HOLD);
944        for choice in approval_choices(state) {
945            if !q.choices.contains(&choice) {
946                q.choices.push(choice);
947            }
948        }
949        if q.merge_target.is_none() {
950            q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
951                pr: pr.url.clone(),
952                head: state.land_approval.as_ref().unwrap().head.clone(),
953                language: state.config.graph.language.clone(),
954            });
955        }
956    }
957    for choice in q.choices.clone() {
958        if (q.status.open()
959            && crate::merge_decision::stops(&choice)
960            && choice != HOLD
961            && choice != crate::merge_decision::DISMISS)
962            || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
963        {
964            q.actions.insert(
965                choice,
966                ask::ChoiceAction::Resume {
967                    run: state.id.clone(),
968                },
969            );
970        }
971    }
972    before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
973}
974
975/// Whether `quote` is a verbatim part of the owner's `message` and nothing in
976/// the whole message carries a hedge, a condition, a negation, a question or a
977/// retraction. Used by `deputy::destructive`.
978///
979/// The whole message is read, not just the quote's sentence: a condition or a
980/// second thought in another sentence ("Merge it. Only if CI passes.") makes
981/// the approval conditional all the same. Doubt anywhere is `false`.
982pub fn unhedged(message: &str, quote: &str) -> bool {
983    let quote = quote.trim();
984    !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
985}
986
987/// Hedging, conditional, negated or interrogative wording. ASCII words are
988/// matched as whole words so `note` is not `not`. A bare negation or stop word
989/// (`no`, `stop`, `nope`, ...) anywhere in the message voids the approval.
990fn hedged(text: &str) -> bool {
991    const WORDS: &[&str] = &[
992        "maybe",
993        "probably",
994        "perhaps",
995        "might",
996        "if",
997        "unless",
998        "not",
999        "no",
1000        "nope",
1001        "stop",
1002        "dont",
1003        "abort",
1004        "revert",
1005        "undo",
1006        "never",
1007        "wait",
1008        "hold",
1009        "cancel",
1010        "but",
1011        "think",
1012        "guess",
1013        "suppose",
1014        "unsure",
1015        "yet",
1016        "except",
1017        "only",
1018        "cannot",
1019        "should",
1020        "once",
1021        "provided",
1022        "providing",
1023        "when",
1024        "whenever",
1025        "after",
1026        "until",
1027        "before",
1028        "assuming",
1029        "given",
1030        "while",
1031        "whether",
1032        "depending",
1033        "pending",
1034    ];
1035    const JA: &[&str] = &[
1036        "かも",
1037        "たぶん",
1038        "多分",
1039        "なら",
1040        "たら",
1041        "ちょっと待",
1042        "しないで",
1043        "しない",
1044        "保留",
1045        "まだ",
1046        "ただし",
1047        "やめ",
1048        "いや",
1049        "止め",
1050        "だめ",
1051        "ダメ",
1052        "じゃない",
1053        "ではない",
1054        "ですか",
1055        "かな",
1056        "でしょう",
1057        "思う",
1058        "でも",
1059        "けど",
1060        "ただ",
1061        "次第",
1062        "場合",
1063        "限り",
1064        "条件",
1065        "とき",
1066        "まで",
1067        "後で",
1068    ];
1069    if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
1070        return true;
1071    }
1072    text.to_lowercase()
1073        .replace('\u{2019}', "'")
1074        .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
1075        .filter(|w| !w.is_empty())
1076        .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
1077}
1078
1079/// Wording that takes back what the rest of the message said. Bare negation
1080/// and stop words are `hedged`'s whole-word list, not substrings here.
1081fn retracts(message: &str) -> bool {
1082    let lower = message.to_lowercase();
1083    [
1084        "やっぱ",
1085        "待って",
1086        "撤回",
1087        "never mind",
1088        "actually",
1089        "on second thought",
1090    ]
1091    .iter()
1092    .any(|w| lower.contains(w))
1093        || lower
1094            .split(|c: char| !c.is_ascii_alphabetic())
1095            .any(|w| w == "wait")
1096}
1097
1098/// Graph node recorded on the approval question.
1099///
1100/// The phone keys its high-stakes card off this rather than off the choice
1101/// strings, so renaming a button cannot silently downgrade the card that
1102/// guards the one irreversible action magi takes.
1103pub const APPROVAL_NODE: &str = "land-approval";
1104
1105/// Unified diff lines carried in the panel before it is truncated.
1106///
1107/// Four hundred: the panel is read on a 390px phone, where a diff line often
1108/// wraps to two rows, so this is already a few thousand rows of scrolling -
1109/// past that nobody is reading, and the bytes still count against the panel's
1110/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
1111/// cut and which worktree holds the whole patch.
1112pub const DIFF_MAX_LINES: usize = 400;
1113
1114/// What the owner's answer to the approval question means.
1115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1116pub enum Approval {
1117    /// The owner said [`APPROVE`]. Merge.
1118    Merge,
1119    /// Repair the existing PR, then ask again; never approval to merge.
1120    Repair,
1121    /// Anything else, including silence. Leave the pull request open.
1122    Hold,
1123}
1124
1125/// Read the owner's answer, where `None` is an unanswered question.
1126///
1127/// Silence is a hold. A timed-out question means the owner never saw it or
1128/// never decided, and defaulting an irreversible merge to "yes" would make this
1129/// gate worse than no gate at all: it would merge unattended while claiming to
1130/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
1131/// function does not recognise holds too.
1132pub fn approval(answer: Option<&str>) -> Approval {
1133    match answer {
1134        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1135        Some(a) if a.trim() == REPAIR => Approval::Repair,
1136        _ => Approval::Hold,
1137    }
1138}
1139
1140/// What [`approval_gate`] found on one check of the owner's merge decision.
1141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1142enum ApprovalGate {
1143    /// The owner said [`APPROVE`]. Merge.
1144    Approved,
1145    /// The owner explicitly requested repair, without authorizing a merge.
1146    Repair,
1147    /// The owner said anything else, the question timed out, or it was
1148    /// closed with no decision recorded.
1149    Held,
1150    /// Filed and still waiting - the caller parks rather than blocking on it.
1151    Pending,
1152}
1153
1154/// Escape text for HTML, including both quote characters.
1155///
1156/// Every string in the panel is agent-influenced: a branch name, a file path, a
1157/// commit subject, a review comment. The sandboxed frame stops such text from
1158/// *running*, but it does not stop a `<` from ending the document early or a
1159/// `"` from ending an attribute and inventing a new one - the panel would then
1160/// render a lie, or not render at all. Both quotes are escaped because the same
1161/// function is used inside attributes, where remembering which quote style the
1162/// caller used is one mistake away from an injected attribute.
1163fn esc(s: &str) -> String {
1164    let mut out = String::with_capacity(s.len());
1165    for c in s.chars() {
1166        match c {
1167            '&' => out.push_str("&amp;"),
1168            '<' => out.push_str("&lt;"),
1169            '>' => out.push_str("&gt;"),
1170            '"' => out.push_str("&quot;"),
1171            '\'' => out.push_str("&#39;"),
1172            _ => out.push(c),
1173        }
1174    }
1175    out
1176}
1177
1178/// One row of the diffstat table.
1179#[derive(Debug, Clone, PartialEq, Eq)]
1180struct StatRow {
1181    path: String,
1182    /// `None` for a binary file, which `git` reports as `-`.
1183    added: Option<u64>,
1184    removed: Option<u64>,
1185}
1186
1187impl StatRow {
1188    /// Lines touched, for sorting. A binary file counts as zero rather than as
1189    /// unknown, which puts it at the bottom where it needs no attention.
1190    fn churn(&self) -> u64 {
1191        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1192    }
1193}
1194
1195/// Parse `git diff --numstat` into rows, biggest churn first.
1196///
1197/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
1198/// terminal width, so counting its characters would print fabricated numbers in
1199/// the one table an operator approves an irreversible action from.
1200fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1201    let mut rows: Vec<StatRow> = numstat
1202        .lines()
1203        .filter_map(|line| {
1204            let mut parts = line.splitn(3, '\t');
1205            let added = parts.next()?.trim();
1206            let removed = parts.next()?.trim();
1207            let path = parts.next()?.trim();
1208            if path.is_empty() {
1209                return None;
1210            }
1211            Some(StatRow {
1212                path: path.to_owned(),
1213                added: added.parse().ok(),
1214                removed: removed.parse().ok(),
1215            })
1216        })
1217        .collect();
1218    // Path breaks the tie so the same change always renders the same table; an
1219    // operator comparing two panels should not see rows shuffle.
1220    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1221    rows
1222}
1223
1224/// How one diff line is shown: a gutter character, a style, and the body to
1225/// print - which is the line minus its marker, so the marker appears exactly
1226/// once, in the gutter.
1227///
1228/// The gutter is why this exists at all. The operator may be colour blind, or
1229/// reading in sunlight with the screen dimmed, so an added line is never
1230/// distinguished by its background alone: `+` and `-` sit in a fixed column,
1231/// the same mark they already read in a terminal.
1232fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1233    if line.starts_with("+++") || line.starts_with("---") {
1234        (" ", "color:#57606a;font-weight:600", line)
1235    } else if let Some(body) = line.strip_prefix('+') {
1236        ("+", "background:#e6ffec;color:#0a3622", body)
1237    } else if let Some(body) = line.strip_prefix('-') {
1238        ("-", "background:#ffebe9;color:#5c1a17", body)
1239    } else if line.starts_with("@@") {
1240        ("~", "background:#eef2ff;color:#3730a3", line)
1241    } else if let Some(body) = line.strip_prefix(' ') {
1242        (" ", "", body)
1243    } else {
1244        (" ", "color:#57606a;font-weight:600", line)
1245    }
1246}
1247
1248/// The handful of words the approval panel says in its own voice.
1249///
1250/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
1251/// the operator asked why the merge question spoke English on a repository
1252/// configured for Japanese, and "because that string is a literal in Rust" is
1253/// not an answer. Only the languages magi can actually check are translated;
1254/// anything else falls back to English rather than shipping a guess, and that
1255/// fallback is deliberate.
1256struct Words {
1257    html_lang: &'static str,
1258    task: &'static str,
1259    what_changed: &'static str,
1260    review_verdict: &'static str,
1261    reviewer: &'static str,
1262    reviewer_no_answer: &'static str,
1263    checks: &'static str,
1264    nothing_failing: &'static str,
1265    files_changed: &'static str,
1266    commits: &'static str,
1267    no_commits: &'static str,
1268    comments: &'static str,
1269    no_comments: &'static str,
1270    diff: &'static str,
1271    truncated: &'static str,
1272    lands_as: &'static str,
1273}
1274
1275const EN: Words = Words {
1276    html_lang: "en",
1277    task: "Task",
1278    what_changed: "What changed",
1279    review_verdict: "Review verdict",
1280    reviewer: "Reviewer",
1281    reviewer_no_answer: "produced no answer",
1282    checks: "Checks",
1283    nothing_failing: "Nothing failing.",
1284    files_changed: "file(s) changed",
1285    commits: "Commits being squashed",
1286    no_commits: "No commit subjects could be read from the branch.",
1287    comments: "External PR review comments",
1288    no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1289    diff: "Diff",
1290    truncated: "Truncated",
1291    lands_as: "They land as one commit titled",
1292};
1293
1294const JA: Words = Words {
1295    html_lang: "ja",
1296    task: "タスク",
1297    what_changed: "変更内容",
1298    review_verdict: "レビューの結論",
1299    reviewer: "レビュアー",
1300    reviewer_no_answer: "回答なし",
1301    checks: "チェック",
1302    nothing_failing: "失敗しているものはありません。",
1303    files_changed: "ファイル変更",
1304    commits: "squash されるコミット",
1305    no_commits: "ブランチからコミット件名を読めませんでした。",
1306    comments: "外部 PR レビューコメント",
1307    no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1308    diff: "差分",
1309    truncated: "省略",
1310    lands_as: "これらは次の件名の1コミットとして入ります:",
1311};
1312
1313impl Words {
1314    /// The clause after the merge subject. Split out because word order moves:
1315    /// Japanese puts the subject before the verb, so a shared template with a
1316    /// hole in the middle would read as machine translation.
1317    fn lands_as_tail(&self) -> &'static str {
1318        if self.html_lang == "ja" {
1319            "。この件名も承認の対象です。"
1320        } else {
1321            ", which you are approving too."
1322        }
1323    }
1324
1325    /// The question's own one-line summary, which is what a phone shows first.
1326    fn approval_summary(&self, number: u64, subject: &str) -> String {
1327        if self.html_lang == "ja" {
1328            format!("プルリクエスト #{number} をマージ: {subject}")
1329        } else {
1330            format!("merge pull request #{number}: {subject}")
1331        }
1332    }
1333
1334    /// The body under the summary, above the panel.
1335    fn approval_detail(
1336        &self,
1337        url: &str,
1338        base: &str,
1339        subject: &str,
1340        contested: Option<&ContestedHandoff>,
1341    ) -> String {
1342        let body = if self.html_lang == "ja" {
1343            format!(
1344                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1345                 できる状態です。差分の要約・パッチ・squash されるコミットは\
1346                 下のパネルにあります。"
1347            )
1348        } else {
1349            format!(
1350                "{url} is green and ready to squash into `{base}` as `{subject}`. \
1351                 The panel holds the diffstat, the patch and the commits being squashed."
1352            )
1353        };
1354        match contested {
1355            Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1356            None => body,
1357        }
1358    }
1359
1360    /// Why this question exists although merge approvals are off: the open
1361    /// blocking findings and who rejected. Short enough for a phone.
1362    fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1363        const SHOWN: usize = 5;
1364        const TITLE_CHARS: usize = 100;
1365        let ja = self.html_lang == "ja";
1366        let mut out = if ja {
1367            format!(
1368                "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1369                 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1370            )
1371        } else {
1372            format!(
1373                "{url} is held for approval although merge approvals are off: the \
1374                 review ended with blocking findings still open and a reviewer \
1375                 voting reject.\n"
1376            )
1377        };
1378        for f in c.findings.iter().take(SHOWN) {
1379            let at = match (&f.file, f.line) {
1380                (Some(file), Some(line)) => format!("{file}:{line}"),
1381                (Some(file), None) => file.clone(),
1382                _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1383            };
1384            let title: String = f.title.chars().take(TITLE_CHARS).collect();
1385            let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1386        }
1387        if c.findings.len() > SHOWN {
1388            let more = c.findings.len() - SHOWN;
1389            let _ = writeln!(
1390                out,
1391                "{}",
1392                if ja {
1393                    format!("- ほか {more} 件")
1394                } else {
1395                    format!("- and {more} more")
1396                }
1397            );
1398        }
1399        let seats: Vec<String> = c
1400            .rejecters
1401            .iter()
1402            .map(|(seat, agent)| format!("#{seat} ({agent})"))
1403            .collect();
1404        let _ = write!(
1405            out,
1406            "{} {}",
1407            if ja {
1408                "却下したレビュアー:"
1409            } else {
1410                "Rejected by reviewer:"
1411            },
1412            seats.join(", ")
1413        );
1414        out
1415    }
1416
1417    /// The truncation note, written whole in each language for the same reason.
1418    fn truncated_note(
1419        &self,
1420        omitted: usize,
1421        total: usize,
1422        shown: usize,
1423        where_: &str,
1424        base: &str,
1425        head: &str,
1426    ) -> String {
1427        if self.html_lang == "ja" {
1428            format!(
1429                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1430                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1431                 プルリクエストにあります。"
1432            )
1433        } else {
1434            format!(
1435                "{omitted} of {total} diff lines omitted after the first {shown}. \
1436                 The whole patch is in <code>{where_}</code> \
1437                 (<code>git diff {base}...{head}</code>) and on the pull request."
1438            )
1439        }
1440    }
1441}
1442
1443/// Pick the panel's language. Codes and names both, because `[graph] language`
1444/// has always accepted either.
1445fn words(language: &str) -> &'static Words {
1446    if crate::lang::is_japanese(language) {
1447        &JA
1448    } else {
1449        &EN
1450    }
1451}
1452
1453/// The approval panel's html: what is about to land, and the evidence for it.
1454///
1455/// Pure, so the whole document is asserted in tests without `gh`, without a
1456/// network and without a repository. The caller gathers `diffstat`
1457/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
1458/// being squashed) and `subject` (what the squash will be called) from the
1459/// winner's worktree.
1460///
1461/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
1462/// content security policy blocks all three: anything of the sort here would be
1463/// dead markup that misleads the next reader into thinking it works.
1464pub fn approval_panel(
1465    state: &RunState,
1466    pr: &PrState,
1467    diffstat: &str,
1468    diff: &str,
1469    commits: &[String],
1470    subject: &str,
1471) -> String {
1472    let rows = parse_numstat(diffstat);
1473    let w = words(&state.config.graph.language);
1474    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1475
1476    let _ = writeln!(
1477        h,
1478        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1479         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1480        w.html_lang
1481    );
1482    let _ = writeln!(
1483        h,
1484        "<title>merge #{} — {}</title>\n</head>",
1485        pr.number,
1486        esc(subject)
1487    );
1488    h.push_str(
1489        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1490         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1491         word-break:break-word\">\n",
1492    );
1493
1494    // The decision, in the words the operator is approving.
1495    let _ = writeln!(
1496        h,
1497        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1498         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1499         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1500         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1501         <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1502        pr.number,
1503        esc(&state.base_branch),
1504        esc(subject),
1505        esc(&state.id),
1506        esc(&pr.url),
1507        esc(&pr.url),
1508    );
1509
1510    let _ = writeln!(
1511        h,
1512        "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1513        esc(&approval_evidence(state))
1514    );
1515
1516    // The task, verbatim: the operator's own words for what was asked, so the
1517    // panel does not make them reconstruct the request from a diffstat.
1518    let _ = writeln!(
1519        h,
1520        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1521         <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1522        w.task,
1523        esc(&state.instruction)
1524    );
1525
1526    // The winner's own account of what it did and why, when there is one.
1527    if let Some(summary) = state
1528        .winner()
1529        .map(|c| c.summary.as_str())
1530        .filter(|s| !s.is_empty())
1531    {
1532        let _ = writeln!(
1533            h,
1534            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1535             <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1536            w.what_changed,
1537            esc(summary)
1538        );
1539    }
1540
1541    // Only the last round's reviewer prose is current. Its votes and
1542    // remaining findings are shown explicitly in the evidence above.
1543    if let Some(round) = state.reviews.last() {
1544        let _ = writeln!(
1545            h,
1546            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1547            w.review_verdict
1548        );
1549        for r in &round.reviews {
1550            // A seat the review loop counted as answered has real prose in
1551            // `summary`; one it counted against `incomplete` (see
1552            // `graph::Runner::review_loop`) never produced any and left it
1553            // empty - which must not be read back as a blank verdict, since
1554            // an empty box here looks like "nothing to say" rather than
1555            // "never answered".
1556            let body = match &r.failed {
1557                Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1558                None => esc(&r.summary),
1559            };
1560            let _ = writeln!(
1561                h,
1562                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1563                 border-radius:6px\">\
1564                 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1565                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1566                w.reviewer,
1567                r.reviewer,
1568                esc(&r.agent),
1569                body,
1570            );
1571        }
1572    }
1573
1574    let _ = writeln!(
1575        h,
1576        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1577        w.checks,
1578        esc(pr.checks.as_str())
1579    );
1580    if pr.failing.is_empty() {
1581        let _ = writeln!(
1582            h,
1583            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1584            w.nothing_failing
1585        );
1586    } else {
1587        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1588        for f in &pr.failing {
1589            let _ = writeln!(h, "<li>{}</li>", esc(f));
1590        }
1591        h.push_str("</ul>\n");
1592    }
1593
1594    // Diffstat as a real table, so a phone reads what moved without scrolling
1595    // sideways through a terminal bar chart.
1596    let _ = writeln!(
1597        h,
1598        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1599        rows.len(),
1600        w.files_changed
1601    );
1602    h.push_str(
1603        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1604         <thead><tr>\
1605         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1606         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1607         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1608         </th></tr></thead>\n<tbody>\n",
1609    );
1610    let mut total_added = 0u64;
1611    let mut total_removed = 0u64;
1612    for r in &rows {
1613        total_added += r.added.unwrap_or(0);
1614        total_removed += r.removed.unwrap_or(0);
1615        let cell = |n: Option<u64>| match n {
1616            Some(n) => n.to_string(),
1617            None => "bin".to_owned(),
1618        };
1619        let _ = writeln!(
1620            h,
1621            "<tr>\
1622             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1623             font-family:ui-monospace,monospace\">{}</td>\
1624             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1625             color:#0a3622\">{}</td>\
1626             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1627             color:#5c1a17\">{}</td></tr>",
1628            esc(&r.path),
1629            cell(r.added),
1630            cell(r.removed),
1631        );
1632    }
1633    let _ = writeln!(
1634        h,
1635        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1636         <td style=\"padding:4px 2px\">total</td>\
1637         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1638         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1639         </tr></tfoot>\n</table>"
1640    );
1641
1642    // The commits being squashed, and the subject that replaces them.
1643    let _ = writeln!(
1644        h,
1645        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1646        w.commits
1647    );
1648    if commits.is_empty() {
1649        h.push_str(&format!(
1650            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1651            w.no_commits
1652        ));
1653    } else {
1654        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1655        for c in commits {
1656            let _ = writeln!(h, "<li>{}</li>", esc(c));
1657        }
1658        h.push_str("</ol>\n");
1659    }
1660    let _ = writeln!(
1661        h,
1662        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1663        w.lands_as,
1664        esc(subject),
1665        w.lands_as_tail()
1666    );
1667
1668    // The review comments that shaped this branch, and who asked for them.
1669    let _ = writeln!(
1670        h,
1671        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1672        w.comments
1673    );
1674    if pr.review_comments.is_empty() {
1675        h.push_str(&format!(
1676            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1677            w.no_comments
1678        ));
1679    } else {
1680        for c in &pr.review_comments {
1681            let anchor = match (&c.path, c.line) {
1682                (Some(p), Some(l)) => format!("{p}:{l}"),
1683                (Some(p), None) => p.clone(),
1684                _ => "pull request thread".to_owned(),
1685            };
1686            let _ = writeln!(
1687                h,
1688                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1689                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1690                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1691                esc(&c.author),
1692                esc(&anchor),
1693                esc(&tail(&c.body, 800)),
1694            );
1695        }
1696    }
1697
1698    // The patch itself.
1699    let total = diff.lines().count();
1700    let shown = total.min(DIFF_MAX_LINES);
1701    let _ = writeln!(
1702        h,
1703        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1704        w.diff
1705    );
1706    h.push_str(
1707        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1708         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1709    );
1710    for line in diff.lines().take(shown) {
1711        let (gutter, style, body) = diff_row(line);
1712        let _ = writeln!(
1713            h,
1714            "<div style=\"display:flex;{style}\">\
1715             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1716             border-right:1px solid #d0d7de\">{gutter}</span>\
1717             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1718            esc(body),
1719        );
1720    }
1721    h.push_str("</div>\n");
1722    if total > shown {
1723        let omitted = total - shown;
1724        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1725        let where_ = state.winner().map_or_else(
1726            || state.repo.display().to_string(),
1727            |w| w.worktree.display().to_string(),
1728        );
1729        let _ = writeln!(
1730            h,
1731            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1732             font-size:13px\">{}: {}</p>",
1733            w.truncated,
1734            w.truncated_note(
1735                omitted,
1736                total,
1737                shown,
1738                &esc(&where_),
1739                &esc(&state.base_branch),
1740                &esc(head),
1741            ),
1742        );
1743    }
1744
1745    h.push_str("</body>\n</html>\n");
1746    h
1747}
1748
1749/// Historical contested hand-off evidence for the question detail. Approval
1750/// itself is decided by `approval_reasons`, including reject-only cases.
1751fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1752    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
1753        state.contested_handoff.clone()
1754    } else {
1755        None
1756    }
1757}
1758
1759/// What a merge-approval question's deputy is told: the pull request, the run,
1760/// what each answer does, and the evidence saved when the question was filed.
1761///
1762/// A snapshot taken when the deputy is attached, so it says so and points at
1763/// `magi show` / `gh pr view` for anything current. `state` is `None` for a run
1764/// that cannot be read; what is missing is named as missing, and no past
1765/// approval basis is rebuilt from today's state.
1766pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1767    let mut s = format!(
1768        "This is the merge approval for run {run} (`magi show {run}`). The question's \
1769         own text above names the pull request. Answering `{APPROVE}` squash-merges \
1770         it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1771         request open. Silence is a hold: the owner not answering never merges. Only \
1772         the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1773         own words, merges. Whether their wording is a clear, unconditional instruction \
1774         is your judgement alone: if it is doubtful, conditional, retracted or a \
1775         question, do not settle - ask back with `magi ask --thread`. Doubt and \
1776         silence are a hold.\n\n\
1777         This brief is a snapshot from when you were attached: check `magi show {run}` \
1778         and `gh pr view` (read-only) before telling the owner anything current. \
1779         You run with permission to write the question record, and what keeps you \
1780         from touching anything else is this brief and your instructions - so do \
1781         not change files, branches or the pull request.",
1782        run = q.run
1783    );
1784    if let Some(target) = &q.merge_target {
1785        s.push_str(&format!("\n\nThis decision names PR {} at head `{}`. Merge through chat requires the owner's latest whole message and quote to be exactly `merge {}`. Discussion alone never approves. For hold, repair, withdrawal or replacement, use the exact offered token from the owner's latest whole message. Withdrawal explicitly closes the PR and ends the task. Replacement closes the old PR before dispatching a new attempt with the selected implementer; do not start a separate task yourself.", target.pr, target.head, target.head));
1786    }
1787    if q.choices.iter().any(|c| c == REPAIR) {
1788        s.push_str("\n\n`fix findings and re-review` requests one repair and bounded re-review on the existing PR. It never approves a merge. Settle this choice only when the owner's whole latest message is exactly this label; otherwise discuss it with `--thread`. The driver performs the repair; do not edit or queue another task. Updated review and gate results require a fresh merge decision.");
1789    }
1790    if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1791    {
1792        s.push_str("\n\nSaved approval evidence (at question creation):\n");
1793        s.push_str(&q.detail);
1794    } else {
1795        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.");
1796    }
1797    let Some(state) = state else {
1798        s.push_str(
1799            "\n\nThe run's record could not be read, so the pull request, the panel \
1800             summary and any contested findings are not known to you beyond the \
1801             question's own text. Say so to the owner rather than guessing.",
1802        );
1803        return s;
1804    };
1805    if let Some(pr) = &state.pr {
1806        s.push_str(&format!(
1807            "\n\nPull request #{} {} (recorded state: {}, last seen).",
1808            pr.number, pr.url, pr.state
1809        ));
1810    }
1811    s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1812    if let Some(w) = state.winner() {
1813        s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1814    }
1815
1816    s
1817}
1818
1819// Releasing a task permits work to resume; it does not renew merge consent.
1820// Check the persisted operator decision both when reusing a card and at the
1821// final merge boundary, since a hold/release can arrive between those reads.
1822fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1823    if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1824        return Ok(false);
1825    }
1826    let Some(task_id) = state
1827        .origin
1828        .as_ref()
1829        .and_then(|origin| origin.task.as_ref())
1830    else {
1831        return Ok(false);
1832    };
1833    let task = crate::queue::Queue::open()
1834        .get(task_id)
1835        .context("cannot verify operator decisions for the merge approval")?;
1836    Ok(task
1837        .operator_decided_at
1838        .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1839}
1840
1841/// Ask the owner before merging, with the whole case attached as a panel.
1842///
1843/// The evidence is gathered from the winner's own worktree with the `git` CLI,
1844/// never from the network, so a phone on a slow link gets the diff magi is
1845/// looking at rather than a link it has to go and open.
1846///
1847/// Never blocks. `land` used to sit inside [`ask::ask_and_wait`]'s poll loop
1848/// for up to a day right here, which held the whole run's task claim - and
1849/// the daemon's one slot with it - for exactly as long as the owner took to
1850/// notice their phone. [`ApprovalGate::Pending`] is the answer that lets the
1851/// caller park the run and hand the slot back instead: the question is on
1852/// disk either way, so nothing about the wait itself changes, only who is
1853/// blocked on it.
1854///
1855/// Idempotent across resumes: called again for a run already waiting on its
1856/// own question, this finds that question by [`crate::ask::Questions::list`]
1857/// rather than filing a second one - asking twice would double the
1858/// notification for one decision, and leave the first question's panel an
1859/// orphan nobody's answer ever reaches.
1860async fn approval_gate(
1861    state: &mut RunState,
1862    pr: &PrState,
1863    subject: &str,
1864    contested: Option<&ContestedHandoff>,
1865    head: &str,
1866) -> Result<ApprovalGate> {
1867    let store = ask::Questions::open();
1868    // An answer belongs to the commit its panel showed. A question recorded
1869    // for another head - or none recorded at all, as for a question filed
1870    // before heads were tracked - is never reused: a fix or rebase push made
1871    // a commit the owner has not seen, and their word does not carry over.
1872    let reusable = state.land_approval.as_ref().and_then(|a| {
1873        store
1874            .list()
1875            .into_iter()
1876            .find(|q| q.id == a.question)
1877            .filter(|q| {
1878                q.status != ask::QuestionStatus::Abandoned
1879                    && (a.head.eq_ignore_ascii_case(head)
1880                        || q.resolution()
1881                            .as_deref()
1882                            .is_some_and(crate::merge_decision::stops))
1883            })
1884    });
1885    let reusable = match reusable {
1886        Some(q) if merge_consent_superseded(state, &q)? => None,
1887        other => other,
1888    };
1889    if reusable.is_none() {
1890        for stale in store
1891            .list()
1892            .into_iter()
1893            .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1894        {
1895            let why =
1896                "the pull request head or operator decision changed; asked again for approval";
1897            if let Err(e) = store.update(&stale.id, |q| {
1898                q.abandon(why);
1899                Ok(())
1900            }) {
1901                tracing::warn!("could not retire the superseded approval question: {e:#}");
1902            }
1903        }
1904    }
1905
1906    let q = match reusable {
1907        Some(mut q) => {
1908            if upgrade_repair_choice(&mut q, state) {
1909                q = store
1910                    .update(&q.id, |current| {
1911                        upgrade_repair_choice(current, state);
1912                        Ok(())
1913                    })?
1914                    .0;
1915            }
1916            q
1917        }
1918        None => {
1919            let worktree = match state.winner() {
1920                Some(w) => w.worktree.clone(),
1921                None => state.repo.clone(),
1922            };
1923            // The diff is built from the commit being approved, not from the
1924            // branch name, which may already point somewhere else.
1925            let head = if head.is_empty() {
1926                state
1927                    .winner()
1928                    .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1929            } else {
1930                head.to_owned()
1931            };
1932            // The diff is against what the remote's base holds, never the
1933            // local branch of that name; unreadable means less evidence.
1934            let remote = &state.config.merge.remote;
1935            let tracking = format!("{remote}/{}", state.base_branch);
1936            let base = if git::rev_exists(&worktree, &tracking).await {
1937                tracking
1938            } else {
1939                String::new()
1940            };
1941            let range = format!("{base}...{head}");
1942            // A failed `git` must not decide the merge: the panel degrades to
1943            // less evidence and the owner still chooses. Merging because the
1944            // diff could not be read would be the worst of both.
1945            let numstat = if base.is_empty() {
1946                String::new()
1947            } else {
1948                git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1949                    .await
1950                    .map(|o| o.stdout)
1951                    .unwrap_or_default()
1952            };
1953            let diff = if base.is_empty() {
1954                String::new()
1955            } else {
1956                git::diff(&worktree, &base, &head).await.unwrap_or_default()
1957            };
1958            let commits: Vec<String> = if base.is_empty() {
1959                Vec::new()
1960            } else {
1961                git::git_raw(
1962                    &worktree,
1963                    &[
1964                        "log",
1965                        "--reverse",
1966                        "--format=%s",
1967                        &format!("{base}..{head}"),
1968                    ],
1969                )
1970                .await
1971                .map(|o| o.stdout)
1972                .unwrap_or_default()
1973                .lines()
1974                .filter(|l| !l.trim().is_empty())
1975                .map(str::to_owned)
1976                .collect()
1977            };
1978
1979            let w = words(&state.config.graph.language);
1980            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1981            let mut fresh = ask::Question::new(
1982                state.id.clone(),
1983                APPROVAL_NODE.to_owned(),
1984                "land".to_owned(),
1985                w.approval_summary(pr.number, subject),
1986                format!(
1987                    "{}\n\n{}",
1988                    approval_evidence(state),
1989                    w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1990                ),
1991                approval_choices(state),
1992            );
1993            fresh.merge_target = Some(crate::merge_decision::Target {
1994                pr: pr.url.clone(),
1995                head: head.clone(),
1996                language: state.config.graph.language.clone(),
1997            });
1998            if fresh.choices.iter().any(|c| c == REPAIR) {
1999                fresh.actions.insert(
2000                    REPAIR.to_owned(),
2001                    ask::ChoiceAction::Resume {
2002                        run: state.id.clone(),
2003                    },
2004                );
2005                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.");
2006            }
2007            for choice in fresh.choices.clone() {
2008                if crate::merge_decision::stops(&choice)
2009                    && choice != HOLD
2010                    && choice != crate::merge_decision::DISMISS
2011                {
2012                    fresh.actions.insert(
2013                        choice,
2014                        ask::ChoiceAction::Resume {
2015                            run: state.id.clone(),
2016                        },
2017                    );
2018                }
2019            }
2020            store
2021                .put_panel(&mut fresh, &html, &[])
2022                .context("write the merge approval panel")?;
2023            store
2024                .put_executable(&mut fresh)
2025                .context("file the merge approval question")?;
2026            state.land_approval = Some(LandApproval {
2027                question: fresh.id.clone(),
2028                head: head.clone(),
2029            });
2030            state.event(
2031                "land",
2032                format!("asking for merge approval ({})", fresh.short()),
2033            );
2034            state.save()?;
2035            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
2036                // A broken webhook is not a reason to lose the merge: the
2037                // question is already on disk and the web UI already shows
2038                // it, so the operator still has a way in.
2039                tracing::warn!(
2040                    "could not notify about merge approval question {}: {e:#} - \
2041                     the web UI is the only surface for it now",
2042                    fresh.short()
2043                );
2044            }
2045            fresh
2046        }
2047    };
2048
2049    Ok(match q.status {
2050        ask::QuestionStatus::Open => ApprovalGate::Pending,
2051        // Nobody answered before `state.config.graph.answer_timeout` passed,
2052        // or the question was closed with no decision recorded underneath
2053        // this run - either way there is nothing left to wait on.
2054        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
2055        // The merge gate does not speak `--thread`: an owner who talked back
2056        // instead of choosing never reaches `Answered`, so this arm only
2057        // ever sees an actual decision.
2058        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
2059            Approval::Merge => ApprovalGate::Approved,
2060            Approval::Repair if !state.reviews.is_empty() => {
2061                if !state.merge_repairs.iter().any(|r| r.question == q.id) {
2062                    state.merge_repairs.push(crate::run::MergeRepair {
2063                        question: q.id.clone(),
2064                        head: head.to_owned(),
2065                        pr: pr.url.clone(),
2066                        reviews: state.reviews.clone(),
2067                        gate: state.gate.clone(),
2068                        gate_head: state.gate_head.clone(),
2069                        gate_fixes: state.gate_fixes.clone(),
2070                        pre_gate: state.pre_gate.clone(),
2071                        phase: crate::run::MergeRepairPhase::Requested,
2072                        failure: None,
2073                    });
2074                    state.land_approval = None;
2075                    state.event(
2076                        "repair",
2077                        "owner requested fix findings and re-review; merge is not approved",
2078                    );
2079                    state.save()?;
2080                }
2081                ApprovalGate::Repair
2082            }
2083            Approval::Repair | Approval::Hold => ApprovalGate::Held,
2084        },
2085    })
2086}
2087
2088/// Fold a rollup's entries into one verdict plus the failing checks' labels.
2089/// No I/O. An empty rollup is `Unknown`, never green.
2090fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
2091    let mut failing = Vec::new();
2092    let mut pending = false;
2093    let mut unknown = false;
2094    for check in rollup {
2095        match check.verdict() {
2096            Verdict::Pass => {}
2097            Verdict::Pending => pending = true,
2098            Verdict::Fail => failing.push(check.label()),
2099            Verdict::Unknown => unknown = true,
2100        }
2101    }
2102    let checks = if rollup.is_empty() {
2103        Checks::Unknown
2104    } else if pending {
2105        Checks::Pending
2106    } else if !failing.is_empty() {
2107        Checks::Red
2108    } else if unknown {
2109        Checks::Unknown
2110    } else {
2111        Checks::Green
2112    };
2113    (checks, failing)
2114}
2115
2116/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
2117/// output into a [`PrState`]. No I/O.
2118pub fn parse_pr(json: &str) -> Result<PrState> {
2119    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2120    let state = match raw.state.to_ascii_uppercase().as_str() {
2121        "OPEN" => PrLifecycle::Open,
2122        "MERGED" => PrLifecycle::Merged,
2123        "CLOSED" => PrLifecycle::Closed,
2124        other => bail!("unknown pull request state `{other}`"),
2125    };
2126
2127    let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2128
2129    let mut review_comments = Vec::new();
2130    for r in raw.reviews {
2131        push_if_outstanding(
2132            &mut review_comments,
2133            ReviewComment {
2134                author: r.author.login,
2135                path: None,
2136                line: None,
2137                body: r.body,
2138            },
2139        );
2140    }
2141    for c in raw.comments {
2142        push_if_outstanding(
2143            &mut review_comments,
2144            ReviewComment {
2145                author: c.author.login,
2146                path: None,
2147                line: None,
2148                body: c.body,
2149            },
2150        );
2151    }
2152
2153    Ok(PrState {
2154        url: raw.url,
2155        number: raw.number,
2156        state,
2157        checks,
2158        failing,
2159        review_comments,
2160        blocking: Blocking::of(&raw.merge_state_status),
2161    })
2162}
2163
2164/// One rollup entry as the release watcher reads it.
2165#[derive(Debug, Clone, PartialEq, Eq)]
2166pub(crate) struct CheckView {
2167    pub name: String,
2168    pub verdict: Verdict,
2169    /// Workflow run behind the check, when its url names one.
2170    pub run: Option<String>,
2171    pub url: Option<String>,
2172}
2173
2174/// A pull request's lifecycle, head commit and per-check verdicts, without
2175/// [`rollup_verdict`]'s aggregation (a pending check anywhere hides a failure
2176/// from it, which is exactly what the release watcher must not inherit).
2177#[derive(Debug, Clone, PartialEq, Eq)]
2178pub(crate) struct RollupView {
2179    pub url: String,
2180    pub number: u64,
2181    pub state: PrLifecycle,
2182    pub head: String,
2183    pub checks: Vec<CheckView>,
2184}
2185
2186/// Parse `gh pr view --json url,number,state,headRefOid,statusCheckRollup`
2187/// output. No I/O.
2188pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2189    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2190    let state = match raw.state.to_ascii_uppercase().as_str() {
2191        "OPEN" => PrLifecycle::Open,
2192        "MERGED" => PrLifecycle::Merged,
2193        "CLOSED" => PrLifecycle::Closed,
2194        other => bail!("unknown pull request state `{other}`"),
2195    };
2196    let checks = raw
2197        .status_check_rollup
2198        .iter()
2199        .map(|c| CheckView {
2200            name: c.label(),
2201            verdict: c.verdict(),
2202            run: c.url().and_then(run_of),
2203            url: c.url().map(str::to_owned),
2204        })
2205        .collect();
2206    Ok(RollupView {
2207        url: raw.url,
2208        number: raw.number,
2209        state,
2210        head: raw.head_ref_oid,
2211        checks,
2212    })
2213}
2214
2215/// Read just a pull request's lifecycle state - open, merged, or closed -
2216/// with none of the checks/reviews/comments [`land`] itself needs to decide
2217/// what to do next.
2218///
2219/// For a caller that only ever wants one fact and must not risk anything
2220/// else: `magi fold --merged` uses this to confirm a URL the operator hands
2221/// it is actually a merged pull request *before* touching a run's state, so a
2222/// typo or a still-open PR fails loudly instead of quietly recording a merge
2223/// that never happened.
2224pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2225    let view = gh(
2226        repo,
2227        &[
2228            "pr".to_owned(),
2229            "view".to_owned(),
2230            pr_url.to_owned(),
2231            "--json".to_owned(),
2232            "state".to_owned(),
2233        ],
2234    )
2235    .await?;
2236    if !view.0 {
2237        bail!("gh pr view {pr_url}: {}", view.1);
2238    }
2239    // `parse_pr` reads every other field of `GhPr` as its serde default
2240    // (empty string, empty vec, zero) when this narrower `--json` selection
2241    // does not carry them - harmless, since only `.state` is read back.
2242    Ok(parse_pr(&view.1)?.state)
2243}
2244
2245/// A pull request the operator merged outside of `land::land`'s own loop,
2246/// found by asking GitHub about the run's own winning branch rather than
2247/// requiring the operator to go and find the URL themselves.
2248#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2249pub struct ExternalMerge {
2250    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
2251    pub url: String,
2252    /// The pull request's number.
2253    pub number: u64,
2254}
2255
2256#[derive(Debug, Deserialize)]
2257#[serde(rename_all = "camelCase")]
2258struct GhMergedPr {
2259    url: String,
2260    number: u64,
2261    merged_at: String,
2262    base_ref_name: String,
2263}
2264
2265/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
2266/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
2267/// decide whether exactly one of the pull requests it lists could actually
2268/// be *this* run's.
2269///
2270/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
2271/// from the run's own short id, so a collision with some other, unrelated
2272/// task's merged pull request from a same-named branch is rare but not
2273/// impossible once branches are deleted and ids run out. Filtering on
2274/// `base_ref_name` (the branch this run actually targets) and `merged_at`
2275/// (which cannot predate the run itself) rules that case out. More than one
2276/// survivor is exactly as uninformative as zero — something this run cannot
2277/// tell apart from another — so only a unique survivor is returned.
2278fn pick_merged_pr(
2279    json: &str,
2280    base_branch: &str,
2281    created_at: Timestamp,
2282) -> Result<Option<ExternalMerge>> {
2283    let raw: Vec<GhMergedPr> =
2284        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2285    let mut matches: Vec<ExternalMerge> = Vec::new();
2286    for pr in raw {
2287        if pr.base_ref_name != base_branch {
2288            continue;
2289        }
2290        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2291            continue;
2292        };
2293        if merged_at < created_at {
2294            continue;
2295        }
2296        matches.push(ExternalMerge {
2297            url: pr.url,
2298            number: pr.number,
2299        });
2300    }
2301    if matches.len() == 1 {
2302        Ok(matches.pop())
2303    } else {
2304        Ok(None)
2305    }
2306}
2307
2308/// What `gh pr list --head <branch> --base <base> --state open` found.
2309#[derive(Debug, Clone, PartialEq, Eq)]
2310pub enum OpenPr {
2311    /// Nothing open: the caller creates one.
2312    None,
2313    /// Exactly one: the caller adopts it instead of creating a second.
2314    One {
2315        /// The pull request's URL.
2316        url: String,
2317        /// Its current title.
2318        title: String,
2319    },
2320    /// More than one: magi does not pick between them.
2321    Many(Vec<String>),
2322}
2323
2324#[derive(Debug, Deserialize)]
2325#[serde(rename_all = "camelCase")]
2326struct GhOpenPr {
2327    // `url` and `baseRefName` are required: a record missing either must be a
2328    // parse error, not a pull request that silently fails the base filter and
2329    // reads as "none open" (which would go on to create a duplicate).
2330    url: String,
2331    #[serde(default)]
2332    title: String,
2333    base_ref_name: String,
2334    // A fork's branch of the same name is not the task head; `gh pr list --head`
2335    // matches by branch name alone, so the filter has to be ours.
2336    #[serde(default)]
2337    is_cross_repository: bool,
2338}
2339
2340/// Pure half of [`find_open_pr`]: classify the raw `--json
2341/// number,url,title,baseRefName` output. Entries whose base is not `base` are
2342/// dropped even though the query already filtered on it, so a stub or an old
2343/// `gh` that ignores `--base` cannot get a pull request into the wrong branch
2344/// adopted.
2345pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2346    let raw: Vec<GhOpenPr> =
2347        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2348    let mut hits: Vec<GhOpenPr> = raw
2349        .into_iter()
2350        .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2351        .collect();
2352    Ok(match hits.len() {
2353        0 => OpenPr::None,
2354        1 => {
2355            let p = hits.remove(0);
2356            OpenPr::One {
2357                url: p.url,
2358                title: p.title,
2359            }
2360        }
2361        _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2362    })
2363}
2364
2365/// Open pull requests whose head is `branch` and whose base is `base`. A
2366/// failing `gh` is an error carrying its own output, never "none": guessing
2367/// there is how a duplicate gets created.
2368pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2369    let (ok, out) = gh(
2370        repo,
2371        &[
2372            "pr".to_owned(),
2373            "list".to_owned(),
2374            "--head".to_owned(),
2375            branch.to_owned(),
2376            "--base".to_owned(),
2377            base.to_owned(),
2378            "--state".to_owned(),
2379            "open".to_owned(),
2380            "--json".to_owned(),
2381            "number,url,title,baseRefName,isCrossRepository".to_owned(),
2382        ],
2383    )
2384    .await?;
2385    if !ok {
2386        bail!("gh pr list failed: {out}");
2387    }
2388    pick_open_pr(&out, base)
2389}
2390
2391#[derive(Debug, Deserialize)]
2392#[serde(rename_all = "camelCase")]
2393struct GhPrHead {
2394    head_ref_name: String,
2395    base_ref_name: String,
2396    state: String,
2397    // Required, like `GhOpenPr`'s fields: a record that cannot say whether the
2398    // head lives in a fork must be a parse error, never "same repository".
2399    is_cross_repository: bool,
2400    // Required too: it is what ties the pull request to the commits that were
2401    // actually proven to be on the base.
2402    head_ref_oid: String,
2403}
2404
2405/// Why [`closable`] said no, and whether asking again later could say yes.
2406#[derive(Debug, Clone, PartialEq, Eq)]
2407pub struct Refusal {
2408    /// A later attempt may succeed (the head moved, the view was unreadable);
2409    /// `false` means this pull request is simply not the run's to close.
2410    pub retry: bool,
2411    /// What was wrong.
2412    pub why: String,
2413}
2414
2415impl Refusal {
2416    fn final_(why: String) -> Self {
2417        Self { retry: false, why }
2418    }
2419}
2420
2421/// Pure half of [`close_superseded_pr`]: read `gh pr view --json
2422/// headRefName,baseRefName,state,isCrossRepository` and say whether closing
2423/// is safe, `Err` carrying the reason when it is not.
2424///
2425/// Closing is outward-facing, so every property is checked on what the forge
2426/// says now, not on what magi recorded: the head must be exactly `branch` in
2427/// this repository (a fork's branch of the same name is somebody else's), the
2428/// base must be `base`, and the pull request must still be open.
2429pub fn closable(
2430    json: &str,
2431    branch: &str,
2432    base: &str,
2433    verified: &[String],
2434) -> std::result::Result<(), Refusal> {
2435    let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2436        retry: true,
2437        why: format!("could not read the pull request ({e})"),
2438    })?;
2439    if pr.head_ref_name != branch {
2440        return Err(Refusal::final_(format!(
2441            "its head is `{}`, not this run's `{branch}`",
2442            pr.head_ref_name
2443        )));
2444    }
2445    if pr.is_cross_repository {
2446        return Err(Refusal::final_("its head lives in a fork".to_owned()));
2447    }
2448    if pr.base_ref_name != base {
2449        return Err(Refusal::final_(format!(
2450            "it targets `{}`, not `{base}`",
2451            pr.base_ref_name
2452        )));
2453    }
2454    if !pr.state.eq_ignore_ascii_case("open") {
2455        return Err(Refusal::final_(format!(
2456            "it is already {}",
2457            pr.state.to_ascii_lowercase()
2458        )));
2459    }
2460    // Last, so a pull request that is not this run's at all is reported as
2461    // such. A head that is this branch but not a commit checked against the
2462    // base (somebody pushed since) may well get checked next time: retry.
2463    if !verified.contains(&pr.head_ref_oid) {
2464        return Err(Refusal {
2465            retry: true,
2466            why: format!(
2467                "its head {} is not a commit this run checked against the base",
2468                crate::already::short_sha(&pr.head_ref_oid)
2469            ),
2470        });
2471    }
2472    Ok(())
2473}
2474
2475/// Does `remote` point at something a forge could host - a URL or an scp-style
2476/// `user@host:path` - rather than a filesystem path or nothing at all? Judged
2477/// from the URL alone, so it answers the same on every machine, whatever `gh`
2478/// happens to be installed or logged in to.
2479async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2480    let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2481        return false;
2482    };
2483    is_forge_url(url.trim())
2484}
2485
2486fn is_forge_url(url: &str) -> bool {
2487    url.contains("://") && !url.starts_with("file://")
2488        || url
2489            .split_once(':')
2490            .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2491}
2492
2493/// Does this `gh` failure mean there is no GitHub to ask, as opposed to a
2494/// request that failed?
2495fn forge_unavailable(message: &str) -> bool {
2496    message.contains("known GitHub host") || message.contains("spawn gh")
2497}
2498
2499/// The comment left on a pull request closed because its change is already on
2500/// the base.
2501pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2502    let how = match evidence.proof {
2503        crate::already::Proof::PatchId => format!(
2504            "carried by commit {} on `{base}` with the same patch",
2505            evidence.names()
2506        ),
2507        crate::already::Proof::Ancestry => {
2508            format!("already in the history of `{base}` as {}", evidence.names())
2509        }
2510        crate::already::Proof::Tree => format!(
2511            "already part of `{base}` (merging this branch changes nothing at {})",
2512            crate::already::short_sha(&evidence.tip)
2513        ),
2514    };
2515    format!(
2516        "Closing: everything this branch adds is {how}, so there is nothing left to \
2517         land. This pull request was closed automatically after that was verified; \
2518         reopen it if you disagree."
2519    )
2520}
2521
2522/// Close the open pull request for `branch`, if there is one and it is
2523/// provably this run's, with a comment naming what supersedes it. `Ok(Ok(url))` is
2524/// the URL closed; `Ok(Err(why))` is a final "nothing to close" (no pull request,
2525/// not this run's, no forge); `Err` is anything a later attempt could resolve (a
2526/// failed `gh` call, a head that moved since it was checked), which the caller
2527/// must not treat as settled.
2528///
2529/// The recorded `state.pr` is preferred, else the forge is asked for an open
2530/// pull request on `branch`; either way the candidate is re-read and passed
2531/// through [`closable`] before anything is written; `verified` lists the
2532/// commits proven to be on the base, and the pull request's head must be one.
2533pub async fn close_superseded_pr(
2534    state: &mut RunState,
2535    branch: &str,
2536    evidence: &crate::already::Evidence,
2537    verified: &[String],
2538) -> Result<std::result::Result<String, String>> {
2539    let repo = state.repo.clone();
2540    let base = state.base_branch.clone();
2541    let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2542        Some(p) => p.url.clone(),
2543        // No recorded pull request. A forge that cannot be asked at all (no
2544        // GitHub remote, no `gh`) says nothing about this run, so that is
2545        // "none found"; any other lookup failure is an error, because "could
2546        // not look" is not "nothing there" and the caller must retry.
2547        None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2548            return Ok(Err(
2549                "the remote is not a forge, so there is no pull request".to_owned(),
2550            ));
2551        }
2552        None => match find_open_pr(&repo, branch, &base).await {
2553            Err(e) if forge_unavailable(&format!("{e:#}")) => {
2554                return Ok(Err(format!("no forge to ask: {e:#}")));
2555            }
2556            Err(e) => return Err(e),
2557            Ok(OpenPr::One { url, .. }) => url,
2558            Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2559            Ok(OpenPr::Many(urls)) => {
2560                return Ok(Err(format!(
2561                    "{} open pull requests name it; not choosing between them",
2562                    urls.len()
2563                )));
2564            }
2565        },
2566    };
2567    let (ok, view) = gh(
2568        &repo,
2569        &[
2570            "pr".to_owned(),
2571            "view".to_owned(),
2572            url.clone(),
2573            "--json".to_owned(),
2574            "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2575        ],
2576    )
2577    .await?;
2578    if !ok {
2579        bail!("gh pr view {url} failed: {view}");
2580    }
2581    if let Err(refusal) = closable(&view, branch, &base, verified) {
2582        // A pull request whose head cannot be tied to what was proven is not
2583        // one to walk away from: the caller keeps the run resumable.
2584        if refusal.retry {
2585            bail!("left {url} open: {}", refusal.why);
2586        }
2587        return Ok(Err(format!("left {url} open: {}", refusal.why)));
2588    }
2589    let (ok, out) = gh(
2590        &repo,
2591        &[
2592            "pr".to_owned(),
2593            "close".to_owned(),
2594            url.clone(),
2595            "--comment".to_owned(),
2596            superseded_comment(&base, evidence),
2597        ],
2598    )
2599    .await?;
2600    if !ok {
2601        bail!("gh pr close {url} failed: {out}");
2602    }
2603    if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2604        p.state = "closed".to_owned();
2605    }
2606    Ok(Ok(url))
2607}
2608
2609/// `gh pr edit <url> --title <title>`, for an adopted pull request whose title
2610/// differs from the one this run computed. Only the title: the body may have
2611/// been edited by the owner and cannot be compared.
2612pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2613    let (title, _) = crate::github_text::prepare(state, title, "");
2614    edit_pr_title(repo, url, &title).await
2615}
2616
2617/// [`set_pr_title`] for a title the owner explicitly chose to post unmodified
2618/// (`github_text::POST_ORIGINAL` or `github_text::USE_REWRITE`): the posting
2619/// gate is skipped on purpose.
2620pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2621    edit_pr_title(repo, url, title).await
2622}
2623
2624/// Post both fields of the owner's chosen rewrite to an adopted pull request,
2625/// unmodified and without the posting gate.
2626pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2627    let (ok, _) = gh(
2628        repo,
2629        &[
2630            "pr".to_owned(),
2631            "edit".to_owned(),
2632            url.to_owned(),
2633            "--title".to_owned(),
2634            title.to_owned(),
2635            "--body".to_owned(),
2636            body.to_owned(),
2637        ],
2638    )
2639    .await
2640    .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2641    if !ok {
2642        // Command output may echo the chosen text; keep it out of run events.
2643        bail!("gh pr edit failed while posting the chosen rewrite");
2644    }
2645    Ok(())
2646}
2647
2648async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2649    let (ok, out) = gh(
2650        repo,
2651        &[
2652            "pr".to_owned(),
2653            "edit".to_owned(),
2654            url.to_owned(),
2655            "--title".to_owned(),
2656            title.to_owned(),
2657        ],
2658    )
2659    .await?;
2660    if !ok {
2661        bail!("gh pr edit failed: {out}");
2662    }
2663    Ok(())
2664}
2665
2666/// Ask GitHub whether this run's winning candidate branch was actually merged
2667/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2668/// --merged` exists to close, minus the operator having to find the URL by
2669/// hand.
2670///
2671/// `Ok(None)` covers every case where nothing can be said with confidence: no
2672/// winner decided yet (nothing to check a branch for), no merged pull request
2673/// found, or [`pick_merged_pr`] found more than one candidate and would not
2674/// guess between them. Never wired to a weaker, URL-less signal like
2675/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2676/// separately, precisely because it cannot drive an automatic correction on
2677/// its own (see that function's own doc).
2678pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2679    let Some(winner) = state.winner() else {
2680        return Ok(None);
2681    };
2682    let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2683    let out = gh(
2684        &state.repo,
2685        &[
2686            "pr".to_owned(),
2687            "list".to_owned(),
2688            "--head".to_owned(),
2689            branch.clone(),
2690            "--state".to_owned(),
2691            "merged".to_owned(),
2692            "--json".to_owned(),
2693            "url,number,mergedAt,baseRefName".to_owned(),
2694        ],
2695    )
2696    .await?;
2697    if !out.0 {
2698        bail!("gh pr list --head {branch}: {}", out.1);
2699    }
2700    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2701}
2702
2703/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2704/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2705///
2706/// Deliberately never consulted by [`find_external_merge`]: a base branch
2707/// that has moved since the run started can make an old, abandoned branch
2708/// look like an ancestor of the *current* base for reasons that have nothing
2709/// to do with a merge (a later commit that happens to supersede it, an
2710/// unrelated squash), and there is no pull request URL here to confirm
2711/// against or to land through anyway. Its only honest use is a weaker
2712/// notice — "this looks merged, go check" — never an automatic rewrite of
2713/// `status`/`merge`.
2714pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2715    let out = tokio::process::Command::new("git")
2716        .args(["merge-base", "--is-ancestor", branch, base_branch])
2717        .current_dir(repo)
2718        .quiet()
2719        .stdin(std::process::Stdio::null())
2720        .output()
2721        .await
2722        .context("spawn git merge-base --is-ancestor")?;
2723    Ok(out.status.success())
2724}
2725
2726/// Parse `host/owner/repo` out of a forge URL, with no network access.
2727///
2728/// The host is part of the slug, not discarded: `owner/repo` alone would
2729/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2730/// repository, which is exactly the mix-up the same-repo guard exists to
2731/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2732/// shape at all.
2733fn forge_slug(url: &str) -> Option<(String, &str)> {
2734    let rest = url.rsplit("://").next()?;
2735    let (host, path) = rest.split_once('/')?;
2736    if host.is_empty() {
2737        return None;
2738    }
2739    Some((host.to_ascii_lowercase(), path))
2740}
2741
2742/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2743/// access - the first half of the same-repo guard [`correct_manual_merge`]
2744/// applies before it writes anything.
2745///
2746/// Returns `None` for anything that does not look like
2747/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2748/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2749pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2750    let (host, path) = forge_slug(url)?;
2751    let mut segments = path.split('/');
2752    let owner = segments.next()?;
2753    let repo = segments.next()?;
2754    let kind = segments.next()?;
2755    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2756        return None;
2757    }
2758    Some(format!("{host}/{owner}/{repo}"))
2759}
2760
2761/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2762/// suffix), the shape `gh repo view --json url` returns - the other half of
2763/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2764fn slug_of_repo_url(url: &str) -> Option<String> {
2765    let (host, path) = forge_slug(url)?;
2766    let mut segments = path.split('/');
2767    let owner = segments.next()?;
2768    let repo = segments.next()?;
2769    if owner.is_empty() || repo.is_empty() {
2770        return None;
2771    }
2772    Some(format!("{host}/{owner}/{repo}"))
2773}
2774
2775/// Refuse to correct a run against a pull request from a different
2776/// repository than the one it is recorded against.
2777///
2778/// This is the guard the shun/8c75 incident argued for: an operator ran
2779/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2780/// in a different repository, omitted the run id, and the id defaulted to
2781/// this machine's most recently created run - an unrelated, still-in-progress
2782/// run in a completely different repository - which then had its `status`
2783/// rewritten to `merged` from a pull request it had nothing to do with.
2784/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2785/// CLI help), but a mistyped or stale id could still name a run in a
2786/// different repository than the one the URL belongs to, so this checks that
2787/// independently rather than trusting the id alone.
2788///
2789/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2790/// mismatch names both slugs rather than just refusing, so an operator whose
2791/// local checkout's `origin` is a fork of the repository the pull request was
2792/// opened against (a legitimate setup this cannot tell apart from a genuine
2793/// mix-up) can judge for themselves rather than being blocked with no way to
2794/// see why.
2795pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2796    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2797        return Ok(());
2798    }
2799    bail!(
2800        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2801         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2802         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2803         verify by hand before treating this as a false positive)"
2804    );
2805}
2806
2807/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2808/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2809///
2810/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2811/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2812/// repository the same way GitHub itself would recognize it, so the
2813/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2814/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2815/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2816/// a same-named one on a GitHub Enterprise host.
2817async fn repo_slug(repo: &Path) -> Result<String> {
2818    let out = gh(
2819        repo,
2820        &[
2821            "repo".to_owned(),
2822            "view".to_owned(),
2823            "--json".to_owned(),
2824            "url".to_owned(),
2825        ],
2826    )
2827    .await?;
2828    if !out.0 {
2829        bail!("gh repo view --json url: {}", out.1);
2830    }
2831    #[derive(Debug, Deserialize)]
2832    struct GhRepo {
2833        url: String,
2834    }
2835    let parsed: GhRepo = serde_json::from_str(&out.1)
2836        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2837    slug_of_repo_url(&parsed.url)
2838        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2839}
2840
2841/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2842/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2843/// would have written them had magi opened and merged this pull request
2844/// itself.
2845///
2846/// This is `magi fold --merged`'s whole implementation, and also what the
2847/// web `fold-merged` route calls once it has a URL in hand — an operator
2848/// recovery path for a merge magi could not finish on its own: a PR title too
2849/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2850/// closed by hand with a pull request magi never opened and so never
2851/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2852/// directly keeps this one authoritative: a merged pull request decides
2853/// `Step::Done { merged: true }` on the very first read, before any of
2854/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2855/// safe to call here even though this pull request was never magi's own.
2856///
2857/// [`ensure_same_repo`] is checked before anything else: a pull request from
2858/// a different repository than the one `state` is recorded against is
2859/// refused outright, regardless of its lifecycle. This is the guard for a
2860/// URL an *operator* hands in - the CLI or the web route - where a stale or
2861/// mistyped run id could otherwise get corrected from an unrelated
2862/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2863/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2864/// goes through [`correct_confirmed_external_merge`] instead, which skips
2865/// this check: its `url` was never operator-supplied, it comes from
2866/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2867/// it is already guaranteed to name a pull request in that same repository -
2868/// re-deriving and re-checking the repository here would only be a second
2869/// `gh repo view` call that can fail for reasons that have nothing to do with
2870/// correctness (a rate limit, a network blip), turning a self-heal that would
2871/// otherwise have succeeded into a run left `Blocked` for another pass.
2872///
2873/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2874/// URL fails loudly without writing anything, rather than handing an open
2875/// pull request to the full autonomous loop by accident.
2876///
2877/// Correcting `status` this way does not run `bump::after_merge`
2878/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2879/// which this path never goes through. A release version bump the change
2880/// might have earned is therefore not filed automatically and has to be
2881/// requested by hand - recorded as an event on the run so the gap is visible
2882/// to whoever reads it later, not just wherever this was called from. Follow-up
2883/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2884///
2885/// Returns the status before and after, so every caller (CLI, janitor, web
2886/// route) can build its own log line or response from the same pair rather
2887/// than each re-deriving it.
2888pub async fn correct_manual_merge(
2889    state: &mut RunState,
2890    url: &str,
2891) -> Result<(RunStatus, RunStatus)> {
2892    let Some(pr_slug) = slug_of_pr_url(url) else {
2893        bail!(
2894            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2895             this pull request belongs to"
2896        );
2897    };
2898    let run_slug = repo_slug(&state.repo).await?;
2899    ensure_same_repo(&run_slug, &pr_slug)?;
2900    correct_merge(state, url).await
2901}
2902
2903/// The janitor's own entry point into the same correction
2904/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2905/// same-repo guard - see that function's own doc for why skipping it here is
2906/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2907/// calls this with a `url` [`find_external_merge`] already found by querying
2908/// `state.repo`'s own remote, so the guard could never do anything here but
2909/// fail on its own transient errors.
2910///
2911/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2912/// because it lives in a different module.
2913pub(crate) async fn correct_confirmed_external_merge(
2914    state: &mut RunState,
2915    url: &str,
2916) -> Result<(RunStatus, RunStatus)> {
2917    correct_merge(state, url).await
2918}
2919
2920/// Same pull request, same repository: the url, or the number within one repo.
2921fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2922    let Some(pr) = a.pr.as_ref() else {
2923        return false;
2924    };
2925    if !url.is_empty()
2926        && pr
2927            .url
2928            .trim_end_matches('/')
2929            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2930    {
2931        return true;
2932    }
2933    number > 0
2934        && pr.number == number
2935        && match (a.repo.canonicalize(), repo.canonicalize()) {
2936            (Ok(x), Ok(y)) => x == y,
2937            _ => a.repo == repo,
2938        }
2939}
2940
2941/// Rewrite `pr.state` to a final state on every run record under `home` that
2942/// `decide` picks, and report how many were rewritten.
2943///
2944/// Lifecycle writes through to other runs are deliberately narrow (task-head
2945/// ownership is recorded separately): only a **terminal** run (never one a
2946/// driver may still be writing), only a proven-stopped driver with no daemon
2947/// claim (the shared liveness judge), only a record
2948/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2949/// The record is read as folding reads it (no schema check: every bump so far
2950/// only added fields, and the whole struct round-trips) and written through
2951/// [`RunState::save_under`], the path every record uses, so nothing but
2952/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2953/// A run that cannot be read or written is skipped with a warning.
2954fn rewrite_open_prs(
2955    home: &Path,
2956    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2957) -> usize {
2958    let now = Timestamp::now();
2959    let mut changed = 0;
2960    for id in crate::run::list_ids_in(&home.join("runs")) {
2961        let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2962            continue;
2963        };
2964        let path = home.join("runs").join(&id).join("run.json");
2965        let Ok(body) = std::fs::read_to_string(&path) else {
2966            continue;
2967        };
2968        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2969            continue;
2970        };
2971        if !state.status.done()
2972            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2973            || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2974                != crate::run::Liveness::Dead
2975        {
2976            continue;
2977        }
2978        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2979            continue;
2980        };
2981        if let Some(pr) = state.pr.as_mut() {
2982            pr.state = to.as_str().to_owned();
2983        }
2984        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2985        state.event(
2986            "land",
2987            format!("recorded {url} as {}: another run settled it", to.as_str()),
2988        );
2989        match state.save_under(home) {
2990            Ok(()) => changed += 1,
2991            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2992        }
2993    }
2994    changed
2995}
2996
2997/// A run reached a final state for its pull request: tell every other terminal
2998/// run in the same repository that names the same pull request (handed-over
2999/// predecessors, blocked attempts, anything), so their records stop saying
3000/// `open`. Best effort; `run`'s own record is the caller's.
3001pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
3002    if to == PrLifecycle::Open {
3003        return;
3004    }
3005    let Some(home) = crate::run::try_home() else {
3006        return;
3007    };
3008    write_pr_state_through_in(&home, run, to);
3009}
3010
3011pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
3012    let Some(pr) = run.pr.as_ref() else {
3013        return 0;
3014    };
3015    let (url, number) = (pr.url.clone(), pr.number);
3016    rewrite_open_prs(home, &mut |other| {
3017        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
3018    })
3019}
3020
3021/// Terminal runs whose record still says their pull request is open, as
3022/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
3023pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
3024    let now = Timestamp::now();
3025    let mut out = Vec::new();
3026    for id in crate::run::list_ids_in(&home.join("runs")) {
3027        let path = home.join("runs").join(&id).join("run.json");
3028        let Ok(body) = std::fs::read_to_string(&path) else {
3029            continue;
3030        };
3031        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
3032            continue;
3033        };
3034        if let Some(pr) = state.pr.as_ref()
3035            && state.status.done()
3036            && pr.state == "open"
3037            && !pr.url.is_empty()
3038            && !crate::daemon::is_working_on(home, &id, now)
3039        {
3040            out.push((id, state.repo.clone(), pr.url.clone()));
3041        }
3042    }
3043    out
3044}
3045
3046/// Apply forge answers (`pr url -> state`) to every stale terminal record.
3047/// A url with no answer (the forge was unreadable) changes nothing.
3048pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
3049    rewrite_open_prs(home, &mut |s| {
3050        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
3051    })
3052}
3053
3054/// One-time (and idempotent) repair of records that froze an `open` pull
3055/// request: ask the forge about each distinct pull request that a terminal run
3056/// still calls open - at most `max_lookups` of them - and rewrite the merged
3057/// and closed ones. A genuinely open pull request is left alone, and a lookup
3058/// that fails is "unknown, change nothing". Returns `(records rewritten,
3059/// lookups that failed)`.
3060pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
3061    let mut known = BTreeMap::new();
3062    let mut failed = 0;
3063    let mut seen = BTreeSet::new();
3064    for (_, repo, url) in stale_open_prs(home) {
3065        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
3066            continue;
3067        }
3068        match lifecycle(&repo, &url).await {
3069            Ok(state) => {
3070                known.insert(url, state);
3071            }
3072            Err(e) => {
3073                tracing::warn!("repair pr state of {url}: {e:#}");
3074                failed += 1;
3075            }
3076        }
3077    }
3078    (apply_pr_states(home, &known), failed)
3079}
3080
3081async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
3082    match lifecycle(&state.repo, url).await? {
3083        PrLifecycle::Merged => {}
3084        other => bail!(
3085            "{url} is {}, not merged; refusing to record {} as merged on a guess",
3086            other.as_str(),
3087            state.id
3088        ),
3089    }
3090    let before = state.status;
3091    if let Err(e) = land(state, url).await {
3092        // `land` sets `status` to `Landing` and saves before its first read
3093        // of the pull request — see its own doc — so a failure here (a
3094        // transient `gh` hiccup between the two forge reads this function
3095        // makes) can leave the run stuck on that in-between value with
3096        // nothing left driving it. Land it on the same terminal shape an
3097        // automated `land` failure lands on instead of leaving it stuck.
3098        state.status = RunStatus::Blocked;
3099        state.event("fold", format!("manual-merge correction failed: {e:#}"));
3100        state.save()?;
3101        return Err(e).context(format!("confirming the merge of {url}"));
3102    }
3103    state.event(
3104        "fold",
3105        "operator recorded this pull request as a manual merge; this run never \
3106         re-entered `land`, so `bump::after_merge` did not run for it - a release \
3107         bump this change might warrant has to be filed by hand",
3108    );
3109    // Unlike the bump, the findings the merge left open are filed on this
3110    // path too: nothing else would ever carry them forward.
3111    if state.status == RunStatus::Merged {
3112        crate::followup::after_merge(state, url).await;
3113    }
3114    state.save()?;
3115    Ok((before, state.status))
3116}
3117
3118/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
3119/// comments. No I/O.
3120///
3121/// `gh pr view` does not surface inline comments, and inline is exactly where
3122/// both review bots put their findings - a landing loop that read only the
3123/// top-level thread would never see the thing it is supposed to fix.
3124pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3125    let raw: Vec<GhInline> =
3126        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3127    let mut out = Vec::new();
3128    for c in raw {
3129        push_if_outstanding(
3130            &mut out,
3131            ReviewComment {
3132                author: c.user.login,
3133                path: c.path,
3134                line: c.line,
3135                body: c.body,
3136            },
3137        );
3138    }
3139    Ok(out)
3140}
3141
3142/// Keep a comment only when it asks for something.
3143///
3144/// An inline comment always does: it names a file and a line. A top-level
3145/// comment is dropped when it is empty, when it is magi's own, or when it is
3146/// [noise](is_noise).
3147fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3148    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3149        return;
3150    }
3151    if comment.path.is_none() && is_noise(&comment.body) {
3152        return;
3153    }
3154    out.push(comment);
3155}
3156
3157/// Is this comment body machinery rather than a finding?
3158///
3159/// Two tests, both structural, because guessing from prose is how a "looks
3160/// good to me" turns into a fix round:
3161///
3162/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
3163///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
3164///    footer.
3165/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
3166///    horizontal rules, and the bot's own status banner are removed, every
3167///    remaining line is a task-list item. That is exactly the shape of the
3168///    comment the Claude review job posts while it is still working.
3169///
3170/// Anything else is input, including bot prose. A bot that writes a paragraph
3171/// has said something, and the fix prompt tells the fixer it may decline a
3172/// comment with an argument - a wasted sentence in a prompt is cheaper than a
3173/// missed finding.
3174pub fn is_noise(body: &str) -> bool {
3175    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3176        return true;
3177    }
3178    let mut content = false;
3179    for line in strip_blocks(body).lines() {
3180        let line = unquote(line);
3181        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3182            continue;
3183        }
3184        content = true;
3185        break;
3186    }
3187    !content
3188}
3189
3190/// Remove HTML comments and collapsed `<details>` blocks.
3191fn strip_blocks(body: &str) -> String {
3192    let mut out = String::with_capacity(body.len());
3193    let mut rest = body;
3194    loop {
3195        let open = ["<!--", "<details>"]
3196            .iter()
3197            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3198            .min_by_key(|(i, _)| *i);
3199        let Some((at, tag)) = open else {
3200            out.push_str(rest);
3201            return out;
3202        };
3203        out.push_str(&rest[..at]);
3204        let after = &rest[at + tag.len()..];
3205        let close = if tag == "<!--" { "-->" } else { "</details>" };
3206        match after.find(close) {
3207            Some(end) => rest = &after[end + close.len()..],
3208            // Unterminated: the rest of the body is inside the block.
3209            None => return out,
3210        }
3211    }
3212}
3213
3214/// Strip blockquote markers, which both bots wrap their callouts in.
3215fn unquote(line: &str) -> &str {
3216    let mut s = line.trim();
3217    while let Some(rest) = s.strip_prefix('>') {
3218        s = rest.trim_start();
3219    }
3220    s.trim()
3221}
3222
3223/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
3224fn is_checklist(line: &str) -> bool {
3225    let rest = line
3226        .strip_prefix("- ")
3227        .or_else(|| line.strip_prefix("* "))
3228        .unwrap_or("");
3229    let rest = rest.trim_start();
3230    matches!(
3231        rest.get(..3),
3232        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3233    )
3234}
3235
3236/// A heading, a horizontal rule, or a callout tag - shape, never content.
3237fn is_decoration(line: &str) -> bool {
3238    line.starts_with('#')
3239        || line.starts_with("[!")
3240        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3241}
3242
3243/// A line that is nothing but emphasis and links.
3244///
3245/// Both review jobs open with a status banner
3246/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
3247/// to a line-based test and asks for nothing, so it is measured the same way a
3248/// heading is: strip the markup, and if no word survives, it was decoration.
3249fn is_banner(line: &str) -> bool {
3250    let plain = drop_spans(line, "**", "**");
3251    let plain = if plain.contains("](") {
3252        drop_spans(&plain, "[", ")")
3253    } else {
3254        plain
3255    };
3256    !plain.chars().any(char::is_alphanumeric)
3257}
3258
3259/// Remove every `open` .. `close` span, including the delimiters. An
3260/// unterminated span swallows the rest of the input, which is what a reader
3261/// sees too.
3262fn drop_spans(s: &str, open: &str, close: &str) -> String {
3263    let mut out = String::with_capacity(s.len());
3264    let mut rest = s;
3265    while let Some(at) = rest.find(open) {
3266        out.push_str(&rest[..at]);
3267        let after = &rest[at + open.len()..];
3268        match after.find(close) {
3269            Some(end) => rest = &after[end + close.len()..],
3270            None => return out,
3271        }
3272    }
3273    out.push_str(rest);
3274    out
3275}
3276
3277/// The lock that keeps at most one run per repository actually moving the
3278/// base branch at a time: a rebase push, or `gh pr merge`.
3279///
3280/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
3281/// running a fix round in the winner's own worktree, waiting on the owner's
3282/// approval - touches nothing a *different* run in the same repository could
3283/// collide with, and holding a lock across any of that would serialise one
3284/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
3285/// resume, which is precisely the "must not wait on another task" property
3286/// the daemon's slot-freeing exists to give a resume. Only the two moments
3287/// that actually write to the shared base branch need mutual exclusion, and
3288/// both are brief.
3289///
3290/// One entry per repository, each its own `tokio::sync::Mutex`, so two
3291/// different repositories' runs never wait on each other. The outer
3292/// `std::sync::Mutex` guards only the map itself, held long enough to find or
3293/// insert an entry and clone its `Arc`, never across an `.await`.
3294fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3295    static LOCKS: std::sync::LazyLock<
3296        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3297    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3298    LOCKS
3299        .lock()
3300        .unwrap_or_else(std::sync::PoisonError::into_inner)
3301        .entry(repo.to_path_buf())
3302        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3303        .clone()
3304}
3305
3306/// `owner/repo` out of a pull request url, falling back to the checkout's
3307/// directory name when the url is not the usual `host/owner/repo/pull/N`.
3308fn repo_label(repo: &Path, pr_url: &str) -> String {
3309    let parts: Vec<&str> = pr_url.split('/').collect();
3310    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3311        && at >= 2
3312        && !parts[at - 1].is_empty()
3313        && !parts[at - 2].is_empty()
3314    {
3315        return format!("{}/{}", parts[at - 2], parts[at - 1]);
3316    }
3317    repo.file_name()
3318        .map(|n| n.to_string_lossy().into_owned())
3319        .unwrap_or_default()
3320}
3321
3322/// The operator-facing sentence for a merge that went ahead with red checks,
3323/// or `None` when the checks were not red. Judged on `checks`, not on
3324/// `failing`, which can be non-empty on a green observation.
3325fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3326    (pr.checks == Checks::Red).then(|| {
3327        let details = crate::notices::format_list(&pr.failing)
3328            .map(|names| format!(": {names}"))
3329            .unwrap_or_default();
3330        format!(
3331            "Merged {repo_name} PR #{} with red checks{details} ({})",
3332            pr.number, pr.url
3333        )
3334    })
3335}
3336
3337/// After a merge that succeeded: record which checks were red and tell the
3338/// operator. The decision to merge is already made; this only makes it audible.
3339/// Best-effort - a broken notifier never fails the run.
3340async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3341    let repo_name = repo_label(&state.repo, &pr.url);
3342    let Some(summary) = red_merge_summary(&repo_name, pr) else {
3343        return;
3344    };
3345    if let Some(rec) = state.pr.as_mut() {
3346        rec.red_at_merge = pr.failing.clone();
3347    }
3348    state.event("land", summary.clone());
3349    // The notice pages through `[notify]` itself, once, unless a question
3350    // already carries the cause.
3351    crate::notices::raise_with(
3352        crate::notices::merged_red(&state.id, &summary),
3353        &state.config.notify,
3354    );
3355}
3356
3357/// Run the loop against a real pull request until it merges or the budget runs
3358/// out.
3359///
3360/// The caller decides whether landing happens at all: this is only reached when
3361/// `graph.land` is on. Returns the last observation, so the caller can report
3362/// what magi was looking at when it stopped.
3363pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3364    land_with(state, pr_url, &GhForge).await
3365}
3366
3367/// The forge calls whose timing the loop's decisions depend on, behind a seam
3368/// so a test can script what the pull request looks like from one observation
3369/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
3370trait Forge {
3371    async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3372        let (ok, out) = gh(
3373            repo,
3374            &[
3375                "pr".into(),
3376                "close".into(),
3377                pr_url.into(),
3378                "--comment".into(),
3379                comment.into(),
3380            ],
3381        )
3382        .await?;
3383        if !ok {
3384            bail!("PR closure failed: {out}");
3385        }
3386        Ok(())
3387    }
3388    async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
3389        None
3390    }
3391    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3392    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3393    async fn poll(&self);
3394    /// Rerun the failed jobs of one workflow run.
3395    async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3396    /// Unix seconds, behind the seam so a test owns the clock.
3397    fn now(&self) -> i64;
3398    /// The check contexts the base branch requires, for a stop reason only.
3399    /// `None` when they could not be read, which is not the same as none.
3400    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3401    #[allow(clippy::too_many_arguments)]
3402    async fn fix(
3403        &self,
3404        state: &mut RunState,
3405        pr: &PrState,
3406        round: usize,
3407        budget: usize,
3408        reason: &str,
3409        logs: &str,
3410    ) -> Result<Fixed>;
3411}
3412
3413struct GhForge;
3414
3415impl Forge for GhForge {
3416    async fn attribution(&self, state: &RunState, seen: &Seen) -> Option<CiEvidence> {
3417        collect_ci_evidence(state, seen).await
3418    }
3419    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3420        observe(repo, pr_url).await
3421    }
3422    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3423        gh(repo, argv).await
3424    }
3425    async fn poll(&self) {
3426        tokio::time::sleep(POLL).await;
3427    }
3428    async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3429        let args = [
3430            "run".to_owned(),
3431            "rerun".to_owned(),
3432            run.to_owned(),
3433            "--failed".to_owned(),
3434        ];
3435        let (ok, out) = gh(repo, &args).await?;
3436        if !ok {
3437            bail!("gh run rerun {run}: {out}");
3438        }
3439        Ok(())
3440    }
3441    fn now(&self) -> i64 {
3442        jiff::Timestamp::now().as_second()
3443    }
3444    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3445        required_contexts_of(repo, base).await
3446    }
3447    async fn fix(
3448        &self,
3449        state: &mut RunState,
3450        pr: &PrState,
3451        round: usize,
3452        budget: usize,
3453        reason: &str,
3454        logs: &str,
3455    ) -> Result<Fixed> {
3456        fix_round(state, pr, round, budget, reason, logs).await
3457    }
3458}
3459
3460/// Percent-encode a branch name for a URL path segment (`/` included).
3461fn encode_path_segment(s: &str) -> String {
3462    let mut out = String::new();
3463    for b in s.bytes() {
3464        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3465            out.push(b as char);
3466        } else {
3467            let _ = write!(out, "%{b:02X}");
3468        }
3469    }
3470    out
3471}
3472
3473/// Read the required contexts of `base` from classic branch protection and
3474/// from rulesets, once, for a stop reason. Organisation-level rulesets that
3475/// these two endpoints do not list are missed. Any unreadable source makes the
3476/// whole answer `None`: a partial set would read as a complete one.
3477async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3478    let enc = encode_path_segment(base);
3479    let mut all = BTreeSet::new();
3480    // Classic protection answers 404 for an unprotected branch, which is
3481    // "nothing required here", not a failure to read.
3482    let classic = gh(
3483        repo,
3484        &[
3485            "api".to_owned(),
3486            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3487        ],
3488    )
3489    .await
3490    .ok()?;
3491    if classic.0 {
3492        all.extend(parse_classic_required(&classic.1)?);
3493    } else if !classic.1.contains("404") {
3494        return None;
3495    }
3496    let rules = gh(
3497        repo,
3498        &[
3499            "api".to_owned(),
3500            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3501        ],
3502    )
3503    .await
3504    .ok()?;
3505    if !rules.0 {
3506        return None;
3507    }
3508    all.extend(parse_ruleset_required(&rules.1)?);
3509    Some(all)
3510}
3511
3512/// `required_status_checks` of classic protection: `contexts` plus the
3513/// `checks[].context` form.
3514fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3515    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3516    let mut out = BTreeSet::new();
3517    for c in v.get("contexts")?.as_array()? {
3518        out.insert(c.as_str()?.to_owned());
3519    }
3520    for c in v
3521        .get("checks")
3522        .and_then(|c| c.as_array())
3523        .into_iter()
3524        .flatten()
3525    {
3526        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3527            out.insert(name.to_owned());
3528        }
3529    }
3530    Some(out)
3531}
3532
3533/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
3534fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3535    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3536    let mut out = BTreeSet::new();
3537    for rule in v.as_array()? {
3538        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3539            continue;
3540        }
3541        let checks = rule
3542            .pointer("/parameters/required_status_checks")?
3543            .as_array()?;
3544        for c in checks {
3545            out.insert(c.get("context")?.as_str()?.to_owned());
3546        }
3547    }
3548    Some(out)
3549}
3550
3551/// Is the observation older than the commit a fix round pushed?
3552///
3553/// The forge takes a few seconds to move the pull request to a new head and
3554/// attach that head's check runs, and until it has, the rollup is the previous
3555/// head's - all green, which is exactly what a merge decision must not read.
3556/// An absent or different head counts as not yet: guessing "close enough"
3557/// would reopen the hole.
3558fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3559    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3560}
3561
3562/// The commit an observation may be decided on, or `None` while it cannot be
3563/// trusted to describe one.
3564///
3565/// `None` when a pushed commit is awaited and the pull request is not on it,
3566/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3567/// last commit's) is not the head's: that is the previous commit's checks. The
3568/// caller re-polls on `None`. A rollup that cannot be read (including one
3569/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3570/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3571fn bound_head<'a>(
3572    seen_head: &'a str,
3573    rollup_head: &str,
3574    awaiting: Option<&str>,
3575) -> Option<&'a str> {
3576    if seen_head.is_empty()
3577        || awaiting_new_head(awaiting, seen_head)
3578        || !rollup_head.eq_ignore_ascii_case(seen_head)
3579    {
3580        return None;
3581    }
3582    Some(seen_head)
3583}
3584
3585/// What a refused `gh pr merge` means.
3586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3587enum Refused {
3588    /// The branch policy is not satisfied *yet*: go back to waiting.
3589    Pending,
3590    /// Checks have settled and the merge state still says no, seen for the
3591    /// first time. The forge updates `mergeStateStatus` a moment after the last
3592    /// check finishes, so one more look is allowed before believing it.
3593    Recheck,
3594    /// Nothing is in flight and the policy still refuses: a person has to
3595    /// supply what it asks for (a review, say).
3596    Final,
3597}
3598
3599/// Judged from the pull request's state after the refusal, never from the
3600/// refusal's wording, which belongs to the forge and changes.
3601fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3602    let Some(after) = after else {
3603        // Unreadable is not evidence of anything; the next loop reads again.
3604        return Refused::Pending;
3605    };
3606    if after.pr.state != PrLifecycle::Open {
3607        return Refused::Final;
3608    }
3609    // The branch moved after it was observed (the forge refuses a merge pinned
3610    // to the old commit): not a verdict on anything, look again.
3611    if !after.head.eq_ignore_ascii_case(observed_head) {
3612        return Refused::Pending;
3613    }
3614    // The same binding the loop applies: checks of another commit say nothing.
3615    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3616        return Refused::Pending;
3617    }
3618    let state = after.merge_state.to_ascii_uppercase();
3619    if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3620        || state.is_empty()
3621        || state == "UNKNOWN"
3622    {
3623        return Refused::Pending;
3624    }
3625    if rechecked {
3626        Refused::Final
3627    } else {
3628        Refused::Recheck
3629    }
3630}
3631
3632/// Take auto-merge back from the forge and forget that it was armed.
3633///
3634/// `Err` carries the forge's message: the caller must not push or move on, as
3635/// an armed merge left behind could still fire for a commit nobody approved.
3636async fn disarm<F: Forge>(
3637    forge: &F,
3638    state: &mut RunState,
3639    repo: &Path,
3640    number: u64,
3641) -> std::result::Result<(), String> {
3642    let argv = disable_automerge_argv(number);
3643    let out = {
3644        let merge_lock = repo_merge_lock(repo);
3645        let _merge_slot = merge_lock.lock().await;
3646        forge.merge(repo, &argv).await
3647    };
3648    match out {
3649        Ok((true, _)) => {
3650            state.land_armed_head = None;
3651            state.event("land", "auto-merge disabled");
3652            state.save().map_err(|e| format!("{e:#}"))?;
3653            Ok(())
3654        }
3655        Ok((false, msg)) => Err(msg),
3656        Err(e) => Err(format!("{e:#}")),
3657    }
3658}
3659
3660/// [`stop`], first taking back an armed auto-merge so a pull request magi
3661/// gave up on does not merge on its own later. A failure to disarm is added
3662/// to the reason rather than hiding it.
3663async fn stop_disarmed<F: Forge>(
3664    forge: &F,
3665    state: &mut RunState,
3666    repo: &Path,
3667    pr: &PrState,
3668    why: &str,
3669) -> Result<()> {
3670    if state.land_armed_head.is_none() {
3671        return stop(state, repo, pr, why).await;
3672    }
3673    match disarm(forge, state, repo, pr.number).await {
3674        Ok(()) => stop(state, repo, pr, why).await,
3675        Err(e) => {
3676            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3677            stop(state, repo, pr, &why).await
3678        }
3679    }
3680}
3681
3682/// Check the persisted card again around awaits that can outlive an owner tap.
3683fn owner_requested_stop(state: &RunState) -> Result<bool> {
3684    let Some(binding) = &state.land_approval else {
3685        return Ok(false);
3686    };
3687    let q = ask::Questions::open().get(&binding.question)?;
3688    Ok(q.run == state.id
3689        && q.node == APPROVAL_NODE
3690        && q.resolution()
3691            .as_deref()
3692            .is_some_and(crate::merge_decision::stops))
3693}
3694
3695/// Read stop choices before checks, repair or rebase. These are about stopping
3696/// ownership, so a changed head never cancels them as it cancels merge consent.
3697async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3698    use crate::merge_decision as md;
3699    let Some(binding) = &state.land_approval else {
3700        return Ok(false);
3701    };
3702    let q = ask::Questions::open().get(&binding.question)?;
3703    let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3704        return Ok(false);
3705    };
3706    if q.run != state.id || q.node != APPROVAL_NODE {
3707        bail!("stop decision names another run");
3708    }
3709    let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3710        pr: pr.url.clone(),
3711        head: binding.head.clone(),
3712        language: state.config.graph.language.clone(),
3713    });
3714    if target.pr != pr.url {
3715        bail!("stop decision names another PR");
3716    }
3717    if state.merge_decision.is_none() {
3718        state.merge_decision = Some(md::Decision {
3719            question: q.id.clone(),
3720            target,
3721            choice: choice.clone(),
3722            closed: false,
3723            replacement_run: None,
3724            replacement_pr: None,
3725        });
3726        state.save()?;
3727    }
3728    let repo = state.repo.clone();
3729    // Always disable, including arms left by an older driver.
3730    if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3731        stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3732        return Ok(true);
3733    }
3734    if choice.eq_ignore_ascii_case(HOLD) {
3735        stop(
3736            state,
3737            &repo,
3738            pr,
3739            "owner held the decision: PR left open; no further repair or merge work",
3740        )
3741        .await?;
3742        return Ok(true);
3743    }
3744    if md::selected_agent(&choice).is_some()
3745        || state
3746            .origin
3747            .as_ref()
3748            .and_then(|o| o.task.as_ref())
3749            .is_some()
3750    {
3751        let owners: Vec<_> = crate::queue::Queue::open()
3752            .list()
3753            .into_iter()
3754            .filter(|task| task.runs.last() == Some(&state.id))
3755            .collect();
3756        let owner = (owners.len() == 1).then(|| &owners[0]);
3757        if !owner.is_some_and(|task| {
3758            task.runs.last() == Some(&state.id)
3759                && (!task
3760                    .operator_decided_at
3761                    .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3762                    || task.resumption.as_ref().is_some_and(|release| {
3763                        release.question_id.as_deref() == Some(q.id.as_str())
3764                            && task.operator_decided_at == Some(release.at)
3765                    }))
3766        }) {
3767            stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3768            return Ok(true);
3769        }
3770    }
3771    if pr.state == PrLifecycle::Merged {
3772        stop(
3773            state,
3774            &repo,
3775            pr,
3776            "PR already merged; withdrawal/replacement cannot be applied",
3777        )
3778        .await?;
3779        return Ok(true);
3780    }
3781    let comment = format!(
3782        "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3783        q.id, choice, state.id
3784    );
3785    if pr.state == PrLifecycle::Open {
3786        if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3787            stop(
3788                state,
3789                &repo,
3790                pr,
3791                &format!(
3792                    "owner requested closure but it failed: {e}; task held, no replacement started"
3793                ),
3794            )
3795            .await?;
3796            return Ok(true);
3797        }
3798    }
3799    let confirmed = forge.view(&repo, &pr.url).await?;
3800    if confirmed.pr.state != PrLifecycle::Closed {
3801        stop(
3802            state,
3803            &repo,
3804            pr,
3805            "PR closure was not confirmed; task held, no replacement started",
3806        )
3807        .await?;
3808        return Ok(true);
3809    }
3810    *pr = confirmed.pr;
3811    state.merge_decision.as_mut().unwrap().closed = true;
3812    if let Some(record) = &mut state.pr {
3813        record.state = "closed".into();
3814    }
3815    state.status = RunStatus::Superseded;
3816    state.merge = Some(MergeOutcome {
3817        mode: MergeMode::Pr,
3818        ok: false,
3819        detail: format!("owner decision: {choice}; PR closure confirmed"),
3820        empty: false,
3821    });
3822    state.event(
3823        "land",
3824        format!(
3825            "{} applied: PR closure confirmed; old execution retired",
3826            choice
3827        ),
3828    );
3829    state.save()?;
3830    Ok(true)
3831}
3832
3833/// Keep an unstarted verification resumable without charging a task attempt.
3834fn check_landing_resources(
3835    state: &mut RunState,
3836    node: &str,
3837    outcomes: &[crate::run::CommandOutcome],
3838) -> Result<()> {
3839    if let Some(blocked) = outcomes.iter().find(|outcome| outcome.resource_blocked) {
3840        let detail = format!("{}: {}", blocked.command, blocked.output_tail);
3841        state
3842            .resource_block
3843            .get_or_insert_with(|| crate::run::ResourceBlock {
3844                cause: crate::run::ResourceBlockCause::Other,
3845                node: node.into(),
3846                kind: "shared build cache".into(),
3847                detail: detail.clone(),
3848                at: jiff::Timestamp::now(),
3849            });
3850        state.event("resource", &detail);
3851        state.save()?;
3852        bail!("landing verification could not start: {detail}");
3853    }
3854    Ok(())
3855}
3856
3857/// Gate the local winner and publish that exact SHA, retaining the saved lease.
3858/// A push completed before a crash is recoverable only when its SHA was gated.
3859async fn publish_landing_head<F: Forge>(
3860    state: &mut RunState,
3861    observed: &str,
3862    pr_url: &str,
3863    forge: &F,
3864) -> Result<String> {
3865    crate::pr_head::ensure_owner(state)?;
3866    let winner = state.winner().cloned().context("landing has no winner")?;
3867    let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3868    let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3869    anyhow::ensure!(
3870        observed == expected
3871            || (observed == head
3872                && state.gate_head.as_deref() == Some(&head)
3873                && state.gate_allows_progress()),
3874        "public head moved outside this run; refusing to overwrite it"
3875    );
3876    anyhow::ensure!(
3877        git::is_clean(&winner.worktree).await?,
3878        "landing worktree is dirty"
3879    );
3880    if state.gate_head.as_deref() != Some(&head) || !state.gate_ran || !state.gate_allows_progress()
3881    {
3882        state.gate.clear();
3883        state.gate_head = None;
3884        state.gate_ran = false;
3885        state.pre_gate.clear();
3886        state.pre_gate_commit = None;
3887        state.save()?;
3888        let inherited = crate::repo_lock::WaitPolicy::current();
3889        let wait = crate::repo_lock::WaitPolicy::new(
3890            Duration::from_secs(state.config.graph.timeout_resource_wait),
3891            move || inherited.cancelled(),
3892        );
3893        let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3894        loop {
3895            if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3896                state.gate = outcomes.clone();
3897                check_landing_resources(state, "gate", &outcomes)?;
3898            }
3899            if outcomes.iter().all(|o| o.ok()) {
3900                break;
3901            }
3902            match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3903                crate::graph::GateFix::Retry => {
3904                    outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3905                }
3906                crate::graph::GateFix::Stop => break,
3907                crate::graph::GateFix::Defer => {
3908                    // The repair checks e2e immediately, before the next gate.
3909                    // Preserve a cache wait here as well as below; otherwise
3910                    // the daemon charges an attempt for unstarted verification.
3911                    if let Some(e2e) = &state.gate_repair_e2e {
3912                        let outcomes = e2e.outcomes.clone();
3913                        check_landing_resources(state, "e2e", &outcomes)?;
3914                    }
3915                    bail!("landing gate repair deferred");
3916                }
3917            }
3918        }
3919        state.gate = outcomes;
3920        state.gate_ran = true;
3921        state.gate_head = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3922        state.save()?;
3923    }
3924    anyhow::ensure!(
3925        state.gate_allows_progress(),
3926        "landing gate failed; nothing published"
3927    );
3928    let checked = state
3929        .gate_head
3930        .clone()
3931        .context("landing gate has no checked SHA")?;
3932    anyhow::ensure!(
3933        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3934            && git::is_clean(&winner.worktree).await?,
3935        "landing tree changed after gate"
3936    );
3937    let inherited = crate::repo_lock::WaitPolicy::current();
3938    let wait = crate::repo_lock::WaitPolicy::new(
3939        Duration::from_secs(state.config.graph.timeout_resource_wait),
3940        move || inherited.cancelled(),
3941    );
3942    let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3943    if matches!(e2e, crate::graph::GateFix::Defer) {
3944        let outcomes = state
3945            .gate_repair_e2e
3946            .as_ref()
3947            .context("deferred landing e2e has no outcomes")?
3948            .outcomes
3949            .clone();
3950        check_landing_resources(state, "e2e", &outcomes)?;
3951    }
3952    anyhow::ensure!(
3953        matches!(e2e, crate::graph::GateFix::Retry),
3954        "post-repair e2e has not passed on the landing head; nothing published"
3955    );
3956    anyhow::ensure!(
3957        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3958            && git::is_clean(&winner.worktree).await?,
3959        "landing tree changed during post-repair e2e"
3960    );
3961    crate::pr_head::ensure_owner(state)?;
3962    if owner_requested_stop(state)? {
3963        bail!("owner requested stop before publication");
3964    }
3965    let current = forge.view(&state.repo, pr_url).await?;
3966    anyhow::ensure!(
3967        current.pr.state == PrLifecycle::Open,
3968        "PR closed or merged while gating; nothing published"
3969    );
3970    anyhow::ensure!(
3971        current.head == observed || current.head == checked,
3972        "PR head changed while gating; nothing published"
3973    );
3974    let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3975    let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3976    let refspec = format!("{checked}:refs/heads/{public}");
3977    let lock = repo_merge_lock(&state.repo);
3978    let _slot = lock.lock().await;
3979    let pushed = git::git_raw(
3980        &winner.worktree,
3981        &["push", &lease, &state.config.merge.remote, &refspec],
3982    )
3983    .await?;
3984    anyhow::ensure!(
3985        pushed.ok(),
3986        "publishing gated landing head failed: {}",
3987        pushed.stderr
3988    );
3989    state.pr_pushed_head = Some(checked.clone());
3990    state.land_sync_pending = false;
3991    state.save()?;
3992    Ok(checked)
3993}
3994
3995/// A scratch rebase moves the winner ref before its index and files. Recover
3996/// only a tree proven unchanged since the saved start; never discard edits.
3997async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
3998    let winner = state.winner().cloned().context("landing has no winner")?;
3999    let inherited = crate::repo_lock::WaitPolicy::current();
4000    let wait = crate::repo_lock::WaitPolicy::new(
4001        Duration::from_secs(state.config.graph.timeout_resource_wait),
4002        move || inherited.cancelled(),
4003    );
4004    let _coordination = crate::repo_lock::SeatLease::operation_with(
4005        &winner.worktree,
4006        state.config.coordination.as_ref(),
4007        &wait,
4008    )
4009    .await?;
4010    if !git::is_clean(&winner.worktree).await? {
4011        let from = state
4012            .land_rebase_from
4013            .as_deref()
4014            .context("landing worktree is dirty")?;
4015        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4016        anyhow::ensure!(head != from, "landing worktree is dirty");
4017        for args in [
4018            vec!["diff", "--cached", "--quiet", from],
4019            vec!["diff", "--quiet"],
4020        ] {
4021            anyhow::ensure!(
4022                git::git_raw(&winner.worktree, &args).await?.ok(),
4023                "landing worktree has edits beyond the interrupted rebase"
4024            );
4025        }
4026        // Preserve build artifacts and local files, including ignored ones.
4027        // read-tree can overwrite ignored paths, so reject collisions with
4028        // the new tree (including file/directory conflicts) before updating.
4029        let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
4030        let tracked = git::git(
4031            &winner.worktree,
4032            &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
4033        )
4034        .await?;
4035        for local in untracked.split('\0').filter(|p| !p.is_empty()) {
4036            anyhow::ensure!(
4037                !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
4038                    path == local
4039                        || path
4040                            .strip_prefix(local)
4041                            .is_some_and(|rest| rest.starts_with('/'))
4042                        || local
4043                            .strip_prefix(path)
4044                            .is_some_and(|rest| rest.starts_with('/'))
4045                }),
4046                "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
4047            );
4048        }
4049        git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
4050    }
4051    crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
4052    state.land_rebase_from = None;
4053    state.save()?;
4054    Ok(())
4055}
4056
4057async fn rebase_landing_worktree(
4058    state: &mut RunState,
4059    onto: &str,
4060) -> Result<crate::rebase::Rebased> {
4061    restore_landing_worktree(state).await?;
4062    let winner = state.winner().cloned().context("landing has no winner")?;
4063    state.land_sync_pending = true;
4064    state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
4065    state.save()?;
4066    let scratch = state.dir().join("rebase");
4067    let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
4068    if result == crate::rebase::Rebased::Applied {
4069        restore_landing_worktree(state).await?;
4070    }
4071    Ok(result)
4072}
4073
4074/// Reconcile a stopped landing before changing its branch. This also handles
4075/// old records whose graph status or merge detail no longer identifies land.
4076async fn resume_landing_head<F: Forge>(
4077    state: &mut RunState,
4078    observed: &str,
4079    pr_url: &str,
4080    forge: &F,
4081) -> Result<String> {
4082    crate::pr_head::ensure_owner(state)?;
4083    let winner = state.winner().cloned().context("landing has no winner")?;
4084    let local = git::rev_parse(&winner.worktree, "HEAD").await?;
4085    let expected = state
4086        .pr_pushed_head
4087        .as_deref()
4088        .or(state.gate_head.as_deref())
4089        .context("missing public head lease")?;
4090    anyhow::ensure!(
4091        observed == expected
4092            || (observed == local
4093                && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
4094                    || git::is_ancestor(&winner.worktree, expected, &local).await)),
4095        "public head moved outside this run; refusing to sync"
4096    );
4097    // An operator may have extended and published the winner while approval
4098    // was pending. Matching local/public history can be adopted as the lease,
4099    // but publish_landing_head must gate its new SHA before proceeding. A
4100    // remote-only or divergent update still cannot be overwritten.
4101    // Save reconciliation before rebasing again; a prior push may have reached
4102    // the remote immediately before the previous driver's last save.
4103    state.pr_pushed_head = Some(observed.to_owned());
4104    state.save()?;
4105    restore_landing_worktree(state).await?;
4106    let remote = state.config.merge.remote.clone();
4107    let base = state.base_branch.clone();
4108    let fetched = git::fetch(&state.repo, &remote, &base).await?;
4109    anyhow::ensure!(
4110        fetched.ok(),
4111        "cannot fetch landing base: {}",
4112        fetched.stderr
4113    );
4114    let onto = format!("{remote}/{base}");
4115    let tip = git::rev_parse(&state.repo, &onto).await?;
4116    if !git::is_ancestor(&state.repo, &tip, &local).await {
4117        match rebase_landing_worktree(state, &onto).await? {
4118            crate::rebase::Rebased::Applied => {}
4119            crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4120        }
4121    }
4122    publish_landing_head(state, observed, pr_url, forge).await
4123}
4124
4125async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4126    crate::pr_head::ensure_owner(state)?;
4127    state.land_unrelated = None;
4128    let reopening_hold = crate::merge_decision::resumable_hold(state);
4129    let repo = state.repo.clone();
4130    let budget = state.config.graph.land_rounds;
4131    let mut round = 0usize;
4132    // Counted apart from `round`: a rebase is not a fix, and a base that
4133    // moved is not the change's fault.
4134    let mut rebases = 0usize;
4135    let mut waited = Duration::ZERO;
4136    // Comment bodies the fixer has already been shown. A comment is
4137    // outstanding until it has been handed over once; after that it is a
4138    // recorded decision, not an open question, and re-feeding it would loop the
4139    // budget away on a comment the fixer already declined with an argument.
4140    let mut shown: BTreeSet<String> = BTreeSet::new();
4141    // Await the last successfully published head, including across resumes.
4142    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4143    // Whether a settled-checks refusal has already been given its one re-look.
4144    let mut rechecked = false;
4145
4146    // Marks the run resumable through exactly this function, not through a
4147    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
4148    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
4149    // status specifically to know a resumed run belongs back in `land`
4150    // rather than at a second `gh pr create`. Set on every entry - fresh or
4151    // resumed - because a resume that parked here again must keep reading
4152    // `Landing`, not whatever a first pass through `merge` left behind.
4153    state.status = RunStatus::Landing;
4154    state.event("land", format!("watching {pr_url}"));
4155    state.save()?;
4156
4157    // An armed merge recorded by an earlier pass may or may not have reached
4158    // the forge (the record is written before the call). It is taken back on
4159    // the first observation, where a pull request is known to stop with.
4160    let mut resumed_armed = state.land_armed_head.is_some();
4161
4162    loop {
4163        let seen = forge.view(&repo, pr_url).await?;
4164        let mut pr = seen.pr.clone();
4165        pr.review_comments.retain(|c| !shown.contains(&c.body));
4166        state.pr = Some(crate::run::PrRecord {
4167            url: pr.url.clone(),
4168            number: pr.number,
4169            state: pr.state.as_str().to_owned(),
4170            checks: pr.checks.as_str().to_owned(),
4171            round,
4172            rounds: budget,
4173            red_at_merge: Vec::new(),
4174        });
4175        state.save()?;
4176
4177        if pr.state == PrLifecycle::Open {
4178            state.observe_branch_owner_holds(&seen.head).await;
4179            state.save()?;
4180        }
4181        crate::pr_head::ensure_owner(state)?;
4182        if reopening_hold {
4183            anyhow::ensure!(
4184                pr.state == PrLifecycle::Open,
4185                "held PR is no longer open; cannot request a fresh decision"
4186            );
4187            // An explicit resume authorizes a fresh question only. Preserve the
4188            // answered card, and never enter repair/rebase/merge on this pass.
4189            disarm(forge, state, &repo, pr.number)
4190                .await
4191                .map_err(anyhow::Error::msg)?;
4192            let previous = state.merge_decision.take();
4193            let binding = state.land_approval.take();
4194            state.config.graph.land_approval = true;
4195            let subject = merge_subject(
4196                crate::graph::landing_title(state, &seen.title),
4197                &crate::graph::landing_subject_source(state),
4198            );
4199            let contested = contested_to_ask(state);
4200            if let Err(error) =
4201                approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4202            {
4203                state.merge_decision = previous;
4204                state.land_approval = binding;
4205                return Err(error);
4206            }
4207            state.end_pass_parked(crate::run::ParkCause::Question);
4208            state.parked = true;
4209            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()));
4210            state.save()?;
4211            return Ok(pr);
4212        }
4213        if owner_stop(state, &mut pr, forge).await? {
4214            return Ok(pr);
4215        }
4216
4217        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4218            // An approval already given for the same head is reused, so
4219            // nothing is asked twice. A failed disable
4220            // stops the run with the record kept: pushing on could change the
4221            // head under an arm that is still live.
4222            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4223                let why = format!(
4224                    "a previous pass may have armed auto-merge and it could not be disabled \
4225                     on resume: {e}"
4226                );
4227                stop(state, &repo, &pr, &why).await?;
4228                return Ok(pr);
4229            }
4230        }
4231
4232        // The head moved away from the one auto-merge was armed on, by
4233        // someone other than this loop. The arm is pinned and would refuse to
4234        // merge the new commit, but the approval was for the old one: take it
4235        // back and go through the normal path again.
4236        if pr.state == PrLifecycle::Open
4237            && !seen.head.is_empty()
4238            && state
4239                .land_armed_head
4240                .as_deref()
4241                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4242        {
4243            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4244                let why = format!(
4245                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
4246                );
4247                stop(state, &repo, &pr, &why).await?;
4248                return Ok(pr);
4249            }
4250        }
4251
4252        if state.land_sync_pending && pr.state == PrLifecycle::Open {
4253            // Disarming can outlive an owner answer. Consume it before our
4254            // own rebase invalidates the head shown on the decision card.
4255            if owner_stop(state, &mut pr, forge).await? {
4256                return Ok(pr);
4257            }
4258            let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4259                binding.head.eq_ignore_ascii_case(&seen.head)
4260                    && ask::Questions::open()
4261                        .get(&binding.question)
4262                        .is_ok_and(|q| {
4263                            q.run == state.id
4264                                && q.node == APPROVAL_NODE
4265                                && q.status == ask::QuestionStatus::Answered
4266                                && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4267                        })
4268            });
4269            if repair_answer {
4270                let subject = merge_subject(
4271                    crate::graph::landing_title(state, &seen.title),
4272                    &crate::graph::landing_subject_source(state),
4273                );
4274                let contested = contested_to_ask(state);
4275                if matches!(
4276                    approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4277                    ApprovalGate::Repair
4278                ) {
4279                    if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4280                        state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4281                        state.save()?;
4282                    }
4283                    return Ok(pr);
4284                }
4285            }
4286            let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4287            // A stop detected before publication must apply its durable
4288            // decision, even when verification returned an error meanwhile.
4289            if owner_stop(state, &mut pr, forge).await? {
4290                return Ok(pr);
4291            }
4292            let head = synced?;
4293            awaiting_head = Some(head);
4294            waited = Duration::ZERO;
4295            rechecked = false;
4296            // Read CI and approvals afresh, never from the pre-push observation.
4297            continue;
4298        }
4299
4300        if pr.state == PrLifecycle::Open {
4301            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4302                if waited >= WAIT_CEILING {
4303                    let want = awaiting_head.as_deref().unwrap_or_default();
4304                    let why = format!(
4305                        "the pull request's checks were still not about one readable head after \
4306                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
4307                         someone may have pushed over it",
4308                        WAIT_CEILING.as_secs() / 60,
4309                        if want.is_empty() { "any" } else { want },
4310                        if seen.head.is_empty() {
4311                            "nothing readable"
4312                        } else {
4313                            &seen.head
4314                        },
4315                        if seen.rollup_head.is_empty() {
4316                            "nothing readable"
4317                        } else {
4318                            &seen.rollup_head
4319                        },
4320                    );
4321                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
4322                    return Ok(pr);
4323                }
4324                waited += POLL;
4325                forge.poll().await;
4326                continue;
4327            }
4328            // Reset once, when the awaited head first shows up; resetting on
4329            // every re-observation would let a standing refusal wait forever.
4330            if awaiting_head.take().is_some() {
4331                // The checks now being read belong to the new head; give them
4332                // the same grace a fresh pull request gets.
4333                waited = Duration::ZERO;
4334            }
4335        }
4336
4337        // The merge-state grace is head-bound and durable. Reruns reset the
4338        // checks clock, but must not extend this deadline across resumes.
4339        let decision_wait = if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid {
4340            let first = state
4341                .land_unsaid
4342                .get_or_insert_with(|| (seen.head.clone(), forge.now()));
4343            if !first.0.eq_ignore_ascii_case(&seen.head) {
4344                *first = (seen.head.clone(), forge.now());
4345            }
4346            let elapsed = Duration::from_secs(forge.now().saturating_sub(first.1).max(0) as u64);
4347            state.save()?;
4348            elapsed
4349        } else {
4350            waited
4351        };
4352        let proposed = decide(&pr, round, budget, decision_wait);
4353        if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid && proposed == Step::Wait {
4354            if waited >= WAIT_CEILING {
4355                stop_disarmed(
4356                    forge,
4357                    state,
4358                    &repo,
4359                    &pr,
4360                    "merge state remained unreadable; refusing to merge on a guess",
4361                )
4362                .await?;
4363                return Ok(pr);
4364            }
4365            waited += POLL;
4366            forge.poll().await;
4367            continue;
4368        }
4369
4370        // A failed workflow run is rerun once, judged by its own run rather
4371        // than by the rollup's aggregate: another run still pending, or a fix
4372        // budget already spent, must not keep a flaky failure from its one
4373        // rerun. It spends no round and no attempt.
4374        if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
4375            let include_optional = pr.checks == Checks::Red
4376                && matches!(proposed, Step::Fix { .. } | Step::GiveUp { .. });
4377            let failing = rerunnable_with(&seen.contexts, include_optional);
4378            let now = forge.now();
4379            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4380                RerunStep::Rerun(runs) => {
4381                    let rec = state.land_reruns.get_or_insert_with(Default::default);
4382                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
4383                        *rec = LandReruns {
4384                            head: seen.head.clone(),
4385                            runs: BTreeMap::new(),
4386                        };
4387                    }
4388                    for run in &runs {
4389                        rec.runs.insert(run.clone(), now);
4390                    }
4391                    // Durable before the call: a restart must not
4392                    // rerun the same run twice. A save that fails
4393                    // means no rerun.
4394                    state.save()?;
4395                    let mut any = false;
4396                    for run in &runs {
4397                        let names = failing
4398                            .iter()
4399                            .filter(|(_, r)| r.as_deref() == Some(run))
4400                            .map(|(l, _)| l.as_str())
4401                            .collect::<Vec<_>>()
4402                            .join(", ");
4403                        match forge.rerun(&repo, run).await {
4404                            Ok(()) => {
4405                                any = true;
4406                                state.event(
4407                                    "land",
4408                                    format!("reran failed check(s) {names} (run {run})"),
4409                                );
4410                            }
4411                            // Stays recorded: never a retry loop.
4412                            Err(e) => state.event(
4413                                "land",
4414                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4415                            ),
4416                        }
4417                    }
4418                    state.save()?;
4419                    if any {
4420                        waited = Duration::ZERO;
4421                        forge.poll().await;
4422                        continue;
4423                    }
4424                }
4425                RerunStep::Grace => {
4426                    if waited >= WAIT_CEILING {
4427                        let why = format!(
4428                            "checks were still failing after a rerun and {} minutes",
4429                            WAIT_CEILING.as_secs() / 60
4430                        );
4431                        stop(state, &repo, &pr, &why).await?;
4432                        return Ok(pr);
4433                    }
4434                    waited += POLL;
4435                    forge.poll().await;
4436                    continue;
4437                }
4438                RerunStep::Fix => {}
4439            }
4440        }
4441
4442        let step = proposed;
4443        // Armed on exactly this head: the forge is doing the waiting. A
4444        // decision to merge (or keep waiting) is only watched, never re-armed
4445        // and never re-approved; anything else - a red check, a comment, a
4446        // conflict - falls through to its own arm, which disarms first.
4447        let armed_here = state
4448            .land_armed_head
4449            .as_deref()
4450            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4451        if armed_here && matches!(step, Step::Merge | Step::Wait) {
4452            if waited >= WAIT_CEILING {
4453                let required = if seen.base.is_empty() {
4454                    None
4455                } else {
4456                    forge.required_contexts(&repo, &seen.base).await
4457                };
4458                let why = format!(
4459                    "auto-merge was armed on {} but the pull request did not merge within {} \
4460                     minutes ({})",
4461                    seen.head,
4462                    WAIT_CEILING.as_secs() / 60,
4463                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4464                );
4465                stop_disarmed(forge, state, &repo, &pr, &why).await?;
4466                return Ok(pr);
4467            }
4468            waited += POLL;
4469            forge.poll().await;
4470            continue;
4471        }
4472        if armed_here && !matches!(step, Step::Done { .. }) {
4473            // Not merging or waiting any more: whatever is next may change the
4474            // head or give up, and neither may leave the arm standing.
4475            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4476                let why = format!("auto-merge could not be disabled: {e}");
4477                stop(state, &repo, &pr, &why).await?;
4478                return Ok(pr);
4479            }
4480        }
4481        if pr.checks == Checks::Red
4482            && matches!(step, Step::Fix { .. } | Step::GiveUp { .. })
4483            && pr.review_comments.is_empty()
4484            && let Some(evidence) = forge.attribution(state, &seen).await
4485            && evidence.unrelated(&seen.head, &pr.failing)
4486        {
4487            if owner_stop(state, &mut pr, forge).await? {
4488                return Ok(pr);
4489            }
4490            // Attribution does not authorize action against an unseen push.
4491            let fresh = forge.view(&repo, pr_url).await?;
4492            if fresh.head != seen.head
4493                || fresh.rollup_head != seen.rollup_head
4494                || fresh.pr.state != PrLifecycle::Open
4495                || fresh.pr.checks != Checks::Red
4496                || fresh.pr.failing != pr.failing
4497                || fresh
4498                    .pr
4499                    .review_comments
4500                    .iter()
4501                    .any(|c| !shown.contains(&c.body))
4502            {
4503                continue;
4504            }
4505            hold_unrelated(state, &pr, evidence).await?;
4506            return Ok(pr);
4507        }
4508        match step {
4509            Step::Wait => {
4510                if waited >= WAIT_CEILING {
4511                    let why = format!(
4512                        "checks were still running after {} minutes",
4513                        WAIT_CEILING.as_secs() / 60
4514                    );
4515                    stop(state, &repo, &pr, &why).await?;
4516                    return Ok(pr);
4517                }
4518                waited += POLL;
4519                forge.poll().await;
4520            }
4521            Step::Done { merged } => {
4522                if merged {
4523                    state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4524                }
4525                // Auto-merge is pinned to the approved head, but the forge's
4526                // own handling of a later push is not something magi can
4527                // see: if the pull request merged on another commit, say so
4528                // loudly rather than record a clean landing.
4529                if let Some(armed) = state.land_armed_head.take() {
4530                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4531                        let msg = format!(
4532                            "{} merged on {} but the owner approved {armed}; review what landed",
4533                            pr.url, seen.head
4534                        );
4535                        tracing::warn!("{msg}");
4536                        state.event("land", msg);
4537                        // Only an arm left by an older build can get here.
4538                        // The wording is fixed so a repeat does not relight
4539                        // the notice.
4540                        crate::notices::raise_with(
4541                            crate::notices::Notice::warn(
4542                                &format!("merged-unapproved-head:{}", state.id),
4543                                "A pull request merged on a commit the owner did not approve; \
4544                                 review what landed",
4545                            )
4546                            .link(crate::notices::Link::Run {
4547                                id: state.id.clone(),
4548                            }),
4549                            &state.config.notify,
4550                        );
4551                    }
4552                }
4553                state.status = if merged {
4554                    RunStatus::Merged
4555                } else {
4556                    RunStatus::Ready
4557                };
4558                let detail = if merged {
4559                    format!("{} was merged", pr.url)
4560                } else {
4561                    format!("{} was closed without merging", pr.url)
4562                };
4563                state.merge = Some(MergeOutcome {
4564                    mode: MergeMode::Pr,
4565                    ok: merged,
4566                    detail: detail.clone(),
4567                    empty: false,
4568                });
4569                state.event("land", detail);
4570                state.save()?;
4571                write_pr_state_through(state, pr.state);
4572                return Ok(pr);
4573            }
4574            Step::Merge => {
4575                state.check_delivery_evidence(&seen.head)?;
4576                let subject = merge_subject(
4577                    crate::graph::landing_title(state, &seen.title),
4578                    &crate::graph::landing_subject_source(state),
4579                );
4580                // The owner sees the panel before the one irreversible step,
4581                // and an unanswered question is a hold: silence never merges.
4582                //
4583                // The dedicated policy includes existing consent and post-repair
4584                // decisions; repair choice availability never grants merge authority.
4585                let contested = contested_to_ask(state);
4586                if !approval_reasons(state).is_empty() {
4587                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4588                        .await?
4589                    {
4590                        ApprovalGate::Approved => {}
4591                        ApprovalGate::Repair => {
4592                            // The driver consumes the saved request under the existing
4593                            // run/task claim. No merge, no second run or task.
4594                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4595                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4596                                state.save()?;
4597                            }
4598                            return Ok(pr);
4599                        }
4600                        ApprovalGate::Held => {
4601                            // Another entry point may have saved a stop after
4602                            // the first read above. Apply its durable action.
4603                            if owner_stop(state, &mut pr, forge).await? {
4604                                return Ok(pr);
4605                            }
4606                            stop(
4607                                state,
4608                                &repo,
4609                                &pr,
4610                                "the owner did not approve the merge (held or unanswered)",
4611                            )
4612                            .await?;
4613                            return Ok(pr);
4614                        }
4615                        // Filed (or still standing from an earlier visit) and
4616                        // not yet answered. Park here rather than wait: the
4617                        // question survives on disk, the daemon hands this
4618                        // run's slot to something else, and a later resume
4619                        // re-enters `land`, finds the same question, and
4620                        // either merges or stops depending on what it says
4621                        // by then.
4622                        ApprovalGate::Pending => {
4623                            state.end_pass_parked(crate::run::ParkCause::Question);
4624                            state.parked = true;
4625                            state.event(
4626                                "land",
4627                                "parked awaiting merge approval - resumes once answered",
4628                            );
4629                            state.save()?;
4630                            return Ok(pr);
4631                        }
4632                    }
4633                }
4634                // Open and past the gate above, so `seen.head` is the commit
4635                // the checks were bound to and the owner approved.
4636                //
4637                // Merge directly, bound to that commit. Auto-merge is not
4638                // armed any more: the forge checks `--match-head-commit` only
4639                // when the request is made, so an arm outlives the head it
4640                // was made for, and a push of another commit whose
4641                // requirements are met first would merge without approval.
4642                // A direct merge is checked by the forge at the moment it
4643                // happens, so only the approved head can land.
4644                let observed_head = seen.head.clone();
4645                // Read the pull request again just before: the state seen
4646                // above can be a moment old.
4647                {
4648                    let fresh = forge.view(&repo, pr_url).await.ok();
4649                    if !direct_merge_is_safe(
4650                        fresh.as_ref(),
4651                        &observed_head,
4652                        &shown,
4653                        round,
4654                        budget,
4655                        waited,
4656                    ) {
4657                        if waited >= WAIT_CEILING {
4658                            let why = "the pull request did not settle on the approved head \
4659                                       before it could be merged";
4660                            stop(state, &repo, &pr, why).await?;
4661                            return Ok(pr);
4662                        }
4663                        state.event(
4664                            "land",
4665                            "the pull request changed before merging; looking again",
4666                        );
4667                        waited += POLL;
4668                        forge.poll().await;
4669                        continue;
4670                    }
4671                }
4672                let argv = merge_argv_at(pr.number, &subject, &observed_head);
4673                let out = {
4674                    let merge_lock = repo_merge_lock(&repo);
4675                    let _merge_slot = merge_lock.lock().await;
4676                    crate::pr_head::ensure_owner(state)?;
4677                    if let Some(task_id) = state
4678                        .origin
4679                        .as_ref()
4680                        .and_then(|origin| origin.task.as_ref())
4681                    {
4682                        let task = crate::queue::Queue::open()
4683                            .get(task_id)
4684                            .context("cannot verify the task owner immediately before merge")?;
4685                        if task.runs.last() != Some(&state.id)
4686                            || task.operator_held()
4687                            || task.status == crate::queue::TaskStatus::Done
4688                        {
4689                            bail!(
4690                                "task ownership or an operator hold superseded this merge approval"
4691                            );
4692                        }
4693                    }
4694                    if let Some(binding) = &state.land_approval {
4695                        let current = ask::Questions::open().get(&binding.question)?;
4696                        if merge_consent_superseded(state, &current)?
4697                            || current.run != state.id
4698                            || !binding.head.eq_ignore_ascii_case(&observed_head)
4699                            || approval(current.resolution().as_deref()) != Approval::Merge
4700                            || current.merge_target.as_ref().is_some_and(|t| {
4701                                t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4702                            })
4703                        {
4704                            bail!(
4705                                "merge decision is stale or no longer authorizes the reviewed head"
4706                            );
4707                        }
4708                    }
4709                    forge.merge(&repo, &argv).await?
4710                };
4711                if out.0 {
4712                    // Exit 0 is not proof of a merge: with a merge queue `gh`
4713                    // enqueues (or reports the pull request already queued)
4714                    // and succeeds while it is still open. Ask the forge; an
4715                    // unreadable answer is not a confirmation either, so it
4716                    // is looked at again rather than recorded as merged.
4717                    let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4718                        if c.pr.state == PrLifecycle::Merged {
4719                            state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4720                            true
4721                        } else {
4722                            false
4723                        }
4724                    });
4725                    if !confirmed {
4726                        if waited >= WAIT_CEILING {
4727                            let why = "the merge request succeeded but the pull request \
4728                                       could not be confirmed merged after waiting";
4729                            stop(state, &repo, &pr, why).await?;
4730                            return Ok(pr);
4731                        }
4732                        state.event(
4733                            "land",
4734                            "merge accepted but the pull request is not confirmed merged yet; waiting",
4735                        );
4736                        state.save()?;
4737                        waited += POLL;
4738                        forge.poll().await;
4739                        continue;
4740                    }
4741                    pr.state = PrLifecycle::Merged;
4742                    state.status = RunStatus::Merged;
4743                    state.merge = Some(MergeOutcome {
4744                        mode: MergeMode::Pr,
4745                        ok: true,
4746                        detail: format!("gh {}", argv.join(" ")),
4747                        empty: false,
4748                    });
4749                    // The last `state.pr` snapshot is whatever the poll before
4750                    // this merge observed - still `open` - and nothing below
4751                    // refreshes it from GitHub again, so the UI's round rail
4752                    // would otherwise keep animating a merged run forever.
4753                    if let Some(pr_record) = state.pr.as_mut() {
4754                        pr_record.state = pr.state.as_str().to_owned();
4755                    }
4756                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4757                    announce_red_merge(state, &pr).await;
4758                    state.save()?;
4759                    write_pr_state_through(state, pr.state);
4760                    return Ok(pr);
4761                }
4762                let after_seen = forge.view(&repo, pr_url).await.ok();
4763                let after = after_seen.as_ref().map(|s| s.pr.state);
4764                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4765                    state.pr_merged_head = after_seen
4766                        .as_ref()
4767                        .map(|s| s.head.clone())
4768                        .filter(|h| !h.is_empty());
4769                    pr.state = PrLifecycle::Merged;
4770                    state.status = RunStatus::Merged;
4771                    state.merge = Some(outcome);
4772                    if let Some(pr_record) = state.pr.as_mut() {
4773                        pr_record.state = pr.state.as_str().to_owned();
4774                    }
4775                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4776                    announce_red_merge(state, &pr).await;
4777                    state.save()?;
4778                    write_pr_state_through(state, pr.state);
4779                    return Ok(pr);
4780                }
4781                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4782                match verdict {
4783                    Refused::Final => {
4784                        let merge_state = after_seen
4785                            .as_ref()
4786                            .map(|s| s.merge_state.as_str())
4787                            .filter(|m| !m.is_empty())
4788                            .unwrap_or("unknown");
4789                        // Which refusal this was decides what a person has to
4790                        // do about it, so the two are worded apart; both carry
4791                        // the forge's own message.
4792                        let why = format!(
4793                            "the merge was refused: {} (merge state: {merge_state})",
4794                            out.1
4795                        );
4796                        stop(state, &repo, &pr, &why).await?;
4797                        return Ok(pr);
4798                    }
4799                    verdict => {
4800                        // Back to the top, which re-decides and passes the
4801                        // approval gate again, a poll later. `waited` is not
4802                        // reset here: only a pushed fix restarts it, or a
4803                        // standing refusal would wait forever.
4804                        if waited >= WAIT_CEILING {
4805                            let why = format!(
4806                                "the merge was still refused after {} minutes: {}",
4807                                WAIT_CEILING.as_secs() / 60,
4808                                out.1
4809                            );
4810                            stop(state, &repo, &pr, &why).await?;
4811                            return Ok(pr);
4812                        }
4813                        if verdict == Refused::Recheck {
4814                            rechecked = true;
4815                        }
4816                        state.event(
4817                            "land",
4818                            "merge refused while the branch policy is not satisfied yet; waiting",
4819                        );
4820                        state.save()?;
4821                        waited += POLL;
4822                        forge.poll().await;
4823                    }
4824                }
4825            }
4826            Step::Rebase => {
4827                // Bounded by the same budget as a fix, because a rebase that
4828                // keeps being needed means the base moves faster than this
4829                // run can land and a person should decide what to do. It
4830                // spends none of that budget: the change is not what is
4831                // wrong.
4832                if rebases >= budget {
4833                    let why = format!(
4834                        "the base moved under this branch {budget} time(s) and it still does \
4835                         not merge; rebasing again would only race it"
4836                    );
4837                    stop(state, &repo, &pr, &why).await?;
4838                    return Ok(pr);
4839                }
4840                rebases += 1;
4841                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4842                    stop(
4843                        state,
4844                        &repo,
4845                        &pr,
4846                        "the pull request conflicts and this run has no winning branch to rebase",
4847                    )
4848                    .await?;
4849                    return Ok(pr);
4850                };
4851                let base = state.base_branch.clone();
4852                state.event(
4853                    "land",
4854                    format!("{} no longer merges; rebasing onto {base}", pr.url),
4855                );
4856                state.save()?;
4857
4858                // Onto the base as the *remote* has it: the local ref may be
4859                // behind, and rebasing onto a stale base produces a branch
4860                // that conflicts all over again.
4861                let remote = state.config.merge.remote.clone();
4862                git::fetch(&repo, &remote, &base).await.ok();
4863                if owner_stop(state, &mut pr, forge).await? {
4864                    return Ok(pr);
4865                }
4866                let onto = format!("{remote}/{base}");
4867                let rebased = match rebase_landing_worktree(state, &onto).await {
4868                    Ok(crate::rebase::Rebased::Applied) => Ok(None),
4869                    Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4870                    Err(e) => Err(e),
4871                };
4872                if owner_stop(state, &mut pr, forge).await? {
4873                    return Ok(pr);
4874                }
4875                match rebased {
4876                    Ok(None) => {
4877                        let published =
4878                            publish_landing_head(state, &seen.head, pr_url, forge).await;
4879                        if owner_stop(state, &mut pr, forge).await? {
4880                            return Ok(pr);
4881                        }
4882                        let head = published?;
4883                        crate::graph::refresh_reviewed_commits(state, &branch).await;
4884                        state.pr_pushed_head = Some(head.clone());
4885                        awaiting_head = Some(head);
4886                        rechecked = false;
4887                        state.event("land", format!("rebased {branch} onto {base}"));
4888                        state.save()?;
4889                        // The forge has to re-run its checks against the
4890                        // rebased head before anything else can be decided.
4891                        waited = Duration::ZERO;
4892                        tokio::time::sleep(POLL).await;
4893                    }
4894                    // The fixer's rounds are spent (or it could not finish):
4895                    // that is a decision for a person.
4896                    Ok(Some(conflict)) => {
4897                        let why = format!(
4898                            "{} conflicts with {base} and the rebase did not apply: {}",
4899                            pr.url,
4900                            conflict.chars().take(600).collect::<String>()
4901                        );
4902                        stop(state, &repo, &pr, &why).await?;
4903                        return Ok(pr);
4904                    }
4905                    Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4906                    Err(e) => {
4907                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
4908                        stop(state, &repo, &pr, &why).await?;
4909                        return Ok(pr);
4910                    }
4911                }
4912            }
4913            Step::GiveUp { reason } => {
4914                stop(state, &repo, &pr, &reason).await?;
4915                return Ok(pr);
4916            }
4917            Step::Fix { reason } => {
4918                round += 1;
4919                waited = Duration::ZERO;
4920                for c in &pr.review_comments {
4921                    shown.insert(c.body.clone());
4922                }
4923                state.event("land", format!("round {round}: {reason}"));
4924                state.save()?;
4925
4926                let logs = failing_logs(&repo, &seen.failing_urls).await;
4927                let was_red = pr.checks == Checks::Red;
4928                if owner_stop(state, &mut pr, forge).await? {
4929                    return Ok(pr);
4930                }
4931                let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4932                if owner_stop(state, &mut pr, forge).await? {
4933                    return Ok(pr);
4934                }
4935                match fixed {
4936                    Fixed::Committed { head } => {
4937                        // Whatever the next look shows may still be the
4938                        // previous head; see `awaiting_new_head`.
4939                        state.pr_pushed_head = Some(head.clone());
4940                        awaiting_head = Some(head);
4941                        rechecked = false;
4942                        waited = Duration::ZERO;
4943                        forge.poll().await;
4944                    }
4945                    Fixed::Declined if was_red => {
4946                        // Review comments may have required this round even
4947                        // when CI was unrelated. Once the fixer has read them,
4948                        // use the same structural attribution before charging
4949                        // a no-commit stop to the task.
4950                        if let Some(evidence) = forge.attribution(state, &seen).await
4951                            && evidence.unrelated(&seen.head, &pr.failing)
4952                        {
4953                            let fresh = forge.view(&repo, pr_url).await?;
4954                            if fresh.head != seen.head
4955                                || fresh.rollup_head != seen.rollup_head
4956                                || fresh.pr.state != PrLifecycle::Open
4957                                || fresh.pr.checks != Checks::Red
4958                                || fresh.pr.failing != pr.failing
4959                                || fresh
4960                                    .pr
4961                                    .review_comments
4962                                    .iter()
4963                                    .any(|c| !shown.contains(&c.body))
4964                            {
4965                                continue;
4966                            }
4967                            if owner_stop(state, &mut pr, forge).await? {
4968                                return Ok(pr);
4969                            }
4970                            hold_unrelated(state, &pr, evidence).await?;
4971                            return Ok(pr);
4972                        }
4973                        let why = format!(
4974                            "the fixer produced no commit while {} check(s) were failing \
4975                             ({}); stopping instead of looping on an unchanged tree",
4976                            pr.failing.len(),
4977                            pr.failing.join(", ")
4978                        );
4979                        stop(state, &repo, &pr, &why).await?;
4980                        return Ok(pr);
4981                    }
4982                    // Comment-driven round with no commit: the fixer read the
4983                    // comments and changed nothing, which is a decision it is
4984                    // allowed to make. The comments are recorded as shown, so
4985                    // the next observation sees a clean pull request.
4986                    Fixed::Declined => state.event(
4987                        "land",
4988                        format!("round {round}: fixer declined the comments, nothing committed"),
4989                    ),
4990                    Fixed::Failed(why) => {
4991                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4992                        return Ok(pr);
4993                    }
4994                }
4995                state.save()?;
4996            }
4997        }
4998    }
4999}
5000
5001/// Owner decision for a CI failure independently reproduced on the base.
5002pub const CI_HOLD_NODE: &str = "land-ci-unrelated";
5003
5004/// Durable machine-hold evidence, bound to one observed PR head.
5005#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
5006pub struct CiHold {
5007    /// Head that exhibited the independently reproduced failures.
5008    pub head: String,
5009    /// Typed owner question controlling resumption.
5010    pub question: String,
5011    /// Complete structured facts used to attribute the failures.
5012    pub evidence: CiEvidence,
5013}
5014
5015/// Only structured forge annotations are compared, never log prose.
5016#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5017struct CiFailure {
5018    path: String,
5019    start_line: u64,
5020    end_line: u64,
5021    title: String,
5022    message: String,
5023}
5024
5025#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
5026struct CiCheckEvidence {
5027    label: String,
5028    head_check: u64,
5029    base_check: u64,
5030    app: u64,
5031    run: String,
5032    base_run: String,
5033    head_failures: Vec<CiFailure>,
5034    base_failures: Vec<CiFailure>,
5035    rerun_exhausted: bool,
5036}
5037
5038/// Pinned Git diff and forge failure annotations used for CI attribution.
5039#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
5040pub struct CiEvidence {
5041    head: String,
5042    base: String,
5043    changed: Vec<String>,
5044    checks: Vec<CiCheckEvidence>,
5045}
5046
5047impl CiEvidence {
5048    /// Every red check must have the same located failure on the base, outside
5049    /// the diff. Changes to CI/build configuration invalidate this inference.
5050    fn unrelated(&self, head: &str, failing: &[String]) -> bool {
5051        if self.head != head
5052            || self.base.is_empty()
5053            || failing.is_empty()
5054            || self.changed.iter().any(|p| {
5055                p.starts_with(".github/")
5056                    || p.starts_with(".config/")
5057                    || p.starts_with(".cargo/")
5058                    || matches!(
5059                        p.rsplit('/').next().unwrap_or(p),
5060                        "Cargo.toml"
5061                            | "Cargo.lock"
5062                            | "Makefile.toml"
5063                            | "build.rs"
5064                            | "rust-toolchain.toml"
5065                    )
5066            })
5067        {
5068            return false;
5069        }
5070        failing.iter().all(|label| {
5071            let matches: Vec<_> = self.checks.iter().filter(|c| &c.label == label).collect();
5072            matches.len() == 1
5073                && matches[0].rerun_exhausted
5074                && !matches[0].head_failures.is_empty()
5075                && matches[0].head_failures.iter().all(|f| {
5076                    // Workflow-level exit-code annotations do not identify a
5077                    // failing test or source file, and prove nothing here.
5078                    !f.path.is_empty()
5079                        && !f.path.starts_with('.')
5080                        && !f.path.contains("..")
5081                        && !f.path.starts_with('/')
5082                        && !self.changed.contains(&f.path)
5083                        && matches[0].base_failures.contains(f)
5084                })
5085        })
5086    }
5087}
5088
5089async fn ci_json(repo: &Path, endpoint: String) -> Option<serde_json::Value> {
5090    let (ok, body) = gh(repo, &["api".into(), endpoint]).await.ok()?;
5091    if !ok {
5092        return None;
5093    }
5094    serde_json::from_str(&body).ok()
5095}
5096
5097async fn ci_annotations(repo: &Path, check: &serde_json::Value) -> Option<Vec<CiFailure>> {
5098    let count = check.pointer("/output/annotations_count")?.as_u64()?;
5099    if count == 0 || count >= 100 {
5100        return None;
5101    }
5102    let id = check.get("id")?.as_u64()?;
5103    let value = ci_json(
5104        repo,
5105        format!("repos/{{owner}}/{{repo}}/check-runs/{id}/annotations?per_page=100"),
5106    )
5107    .await?;
5108    let annotations = value.as_array()?;
5109    if annotations.len() != count as usize {
5110        return None;
5111    }
5112    annotations
5113        .iter()
5114        .filter(|a| a["annotation_level"] == "failure")
5115        .map(|a| {
5116            Some(CiFailure {
5117                path: a["path"].as_str()?.into(),
5118                start_line: a["start_line"].as_u64()?,
5119                end_line: a["end_line"].as_u64()?,
5120                title: a["title"].as_str()?.into(),
5121                message: a["message"].as_str()?.into(),
5122            })
5123        })
5124        .collect()
5125}
5126
5127async fn ci_runs(repo: &Path, sha: &str) -> Option<Vec<serde_json::Value>> {
5128    let value = ci_json(
5129        repo,
5130        format!("repos/{{owner}}/{{repo}}/commits/{sha}/check-runs?per_page=100&filter=latest"),
5131    )
5132    .await?;
5133    let runs = value["check_runs"].as_array()?;
5134    if value["total_count"].as_u64()? != runs.len() as u64 || runs.len() >= 100 {
5135        return None;
5136    }
5137    Some(runs.clone())
5138}
5139
5140async fn collect_ci_evidence(state: &RunState, seen: &Seen) -> Option<CiEvidence> {
5141    let repo = &state.repo;
5142    let base_ref = format!("refs/remotes/{}/{}", state.config.merge.remote, seen.base);
5143    let base = git::git_raw(repo, &["rev-parse", "--verify", &base_ref])
5144        .await
5145        .ok()?;
5146    if !base.ok() {
5147        return None;
5148    }
5149    let range = format!("{}...{}", base.stdout, seen.head);
5150    // No rename detection: both old and new paths must count as changed.
5151    let diff = git::git_raw(
5152        repo,
5153        &["diff", "--no-renames", "--name-only", "-z", &range, "--"],
5154    )
5155    .await
5156    .ok()?;
5157    if !diff.ok() {
5158        return None;
5159    }
5160    let head_runs = ci_runs(repo, &seen.head).await?;
5161    let base_runs = ci_runs(repo, &base.stdout).await?;
5162    let mut checks = Vec::new();
5163    for label in &seen.pr.failing {
5164        let matching = |runs: &[serde_json::Value]| {
5165            runs.iter()
5166                .filter(|r| r["name"].as_str() == Some(label))
5167                .cloned()
5168                .collect::<Vec<_>>()
5169        };
5170        let heads = matching(&head_runs);
5171        let bases = matching(&base_runs);
5172        if heads.len() != 1 || bases.len() != 1 {
5173            return None;
5174        }
5175        let h = &heads[0];
5176        let b = &bases[0];
5177        if h["conclusion"] != "failure"
5178            || b["conclusion"] != "failure"
5179            || h["status"] != "completed"
5180            || b["status"] != "completed"
5181            || h["head_sha"].as_str()? != seen.head
5182            || b["head_sha"].as_str()? != base.stdout
5183            || h.pointer("/app/id")?.as_u64()? != b.pointer("/app/id")?.as_u64()?
5184        {
5185            return None;
5186        }
5187        let run = run_of(h["details_url"].as_str()?)?;
5188        let contexts: Vec<_> = seen.contexts.iter().filter(|c| &c.label == label).collect();
5189        if contexts.len() != 1
5190            || contexts[0].run.as_ref() != Some(&run)
5191            || contexts[0].verdict != Verdict::Fail
5192        {
5193            return None;
5194        }
5195        // A persisted request alone is not a completed rerun: require the
5196        // workflow's structured attempt number as well.
5197        let workflow =
5198            ci_json(repo, format!("repos/{{owner}}/{{repo}}/actions/runs/{run}")).await?;
5199        let base_run = run_of(b["details_url"].as_str()?)?;
5200        let base_workflow = ci_json(
5201            repo,
5202            format!("repos/{{owner}}/{{repo}}/actions/runs/{base_run}"),
5203        )
5204        .await?;
5205        if base_workflow["head_sha"].as_str()? != base.stdout
5206            || base_workflow["head_branch"].as_str()? != seen.base
5207            || base_workflow["status"] != "completed"
5208            || base_workflow["conclusion"] != "failure"
5209            || base_workflow["workflow_id"].as_u64()? != workflow["workflow_id"].as_u64()?
5210        {
5211            return None;
5212        }
5213        let exhausted = state
5214            .land_reruns
5215            .as_ref()
5216            .is_some_and(|r| r.head == seen.head && r.runs.contains_key(&run))
5217            && workflow["head_sha"].as_str()? == seen.head
5218            && workflow["run_attempt"].as_u64()? > 1
5219            && workflow["status"] == "completed";
5220        checks.push(CiCheckEvidence {
5221            label: label.clone(),
5222            head_check: h["id"].as_u64()?,
5223            base_check: b["id"].as_u64()?,
5224            app: h.pointer("/app/id")?.as_u64()?,
5225            run,
5226            base_run,
5227            head_failures: ci_annotations(repo, h).await?,
5228            base_failures: ci_annotations(repo, b).await?,
5229            rerun_exhausted: exhausted,
5230        });
5231    }
5232    Some(CiEvidence {
5233        head: seen.head.clone(),
5234        base: base.stdout,
5235        changed: diff
5236            .stdout
5237            .split('\0')
5238            .filter(|p| !p.is_empty())
5239            .map(str::to_owned)
5240            .collect(),
5241        checks,
5242    })
5243}
5244
5245async fn hold_unrelated(state: &mut RunState, pr: &PrState, evidence: CiEvidence) -> Result<()> {
5246    let detail = serde_json::to_string(&evidence)?;
5247    let resume = "daemon: CI を確認して同じ run を再開する".to_owned();
5248    let hold = "operator: 保留して後で判断する".to_owned();
5249    let mut q = ask::Question::new(
5250        state.id.clone(),
5251        CI_HOLD_NODE.into(),
5252        "land".into(),
5253        "base でも再現する CI の失敗を検出したため、試行を消費せず保留しました".into(),
5254        format!(
5255            "必須チェックや承認は省略しません。再開時にも同じ workflow run は自動再実行しません。\n構造化された判定根拠:\n{detail}"
5256        ),
5257        vec![resume.clone(), hold.clone()],
5258    );
5259    q.actions.insert(
5260        resume,
5261        ask::ChoiceAction::Resume {
5262            run: state.id.clone(),
5263        },
5264    );
5265    q.actions.insert(hold, ask::ChoiceAction::Hold);
5266    ask::Questions::open().put_executable(&mut q)?;
5267    state.land_unrelated = Some(CiHold {
5268        head: evidence.head.clone(),
5269        question: q.id.clone(),
5270        evidence,
5271    });
5272    state.event("land", format!("[ci-unrelated] base reproduces every located failure after rerun; owner question {}: {detail}", q.id));
5273    state.end_pass_parked(crate::run::ParkCause::Question);
5274    state.parked = true;
5275    state.save()?;
5276    if let Err(e) = ask::notify(&state.config.notify, &q).await {
5277        tracing::warn!("could not notify about CI hold: {e:#}");
5278    }
5279    // A durable question and evidence are sufficient even if commenting fails.
5280    let body = stop_comment(
5281        &state.id,
5282        "CI failures independently reproduced on the base; awaiting an owner decision without charging the task attempt",
5283    );
5284    let (_, body) = crate::github_text::prepare(state, "", &body);
5285    gh(
5286        &state.repo,
5287        &[
5288            "pr".into(),
5289            "comment".into(),
5290            pr.number.to_string(),
5291            "--body".into(),
5292            body,
5293        ],
5294    )
5295    .await
5296    .ok();
5297    Ok(())
5298}
5299
5300/// One observation, plus the two things [`PrState`] deliberately does not carry:
5301/// the title (needed for the squash subject) and where the failing checks'
5302/// logs live.
5303#[derive(Clone)]
5304struct Seen {
5305    pr: PrState,
5306    title: String,
5307    failing_urls: Vec<(String, String)>,
5308    /// `headRefOid`: the commit this observation, checks included, is about.
5309    head: String,
5310    /// The commit the checks belong to, read from the same GraphQL node as
5311    /// the checks themselves. Empty when unreadable.
5312    rollup_head: String,
5313    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
5314    /// BEHIND and DRAFT together, and a stop reason has to say which.
5315    merge_state: String,
5316    /// Every check of the rollup, for naming what an armed merge waits on.
5317    contexts: Vec<CheckInfo>,
5318    /// `baseRefName`, to look up which contexts the base requires.
5319    base: String,
5320}
5321
5322/// One check of the rollup as far as a stop reason needs it.
5323#[derive(Clone)]
5324struct CheckInfo {
5325    label: String,
5326    verdict: Verdict,
5327    required: Option<bool>,
5328    /// The workflow run the check belongs to; `None` for an external status.
5329    run: Option<String>,
5330}
5331
5332/// What to do with a red head before a fix round is spent on it.
5333#[derive(Debug, Clone, PartialEq, Eq)]
5334enum RerunStep {
5335    /// Rerun these workflow runs (each not yet rerun for this head).
5336    Rerun(Vec<String>),
5337    /// Reran already and the forge may still report the old failure: wait.
5338    Grace,
5339    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
5340    Fix,
5341}
5342
5343/// Failed checks a rerun may help: a failure of a check that blocks (`required`
5344/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
5345/// pending checks in another run do not delay this run's rerun.
5346#[cfg(test)]
5347fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
5348    rerunnable_with(contexts, false)
5349}
5350
5351fn rerunnable_with(
5352    contexts: &[CheckInfo],
5353    include_optional: bool,
5354) -> Vec<(String, Option<String>)> {
5355    contexts
5356        .iter()
5357        .filter(|c| c.verdict == Verdict::Fail && (include_optional || c.required != Some(false)))
5358        // `gh run rerun --failed` refuses a run with a job still going.
5359        .filter(|c| {
5360            c.run.as_ref().is_none_or(|r| {
5361                !contexts.iter().any(|o| {
5362                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
5363                        && o.run.as_ref() == Some(r)
5364                })
5365            })
5366        })
5367        .map(|c| (c.label.clone(), c.run.clone()))
5368        .collect()
5369}
5370
5371/// Pure: rerun once per run id, wait out the grace, then hand over.
5372fn rerun_step(
5373    failing: &[(String, Option<String>)],
5374    record: Option<&LandReruns>,
5375    head: &str,
5376    now: i64,
5377) -> RerunStep {
5378    // A failure with no workflow run (an external status) cannot be rerun, and
5379    // the fixer's push re-runs everything anyway.
5380    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
5381        return RerunStep::Fix;
5382    }
5383    // Reruns belong to the head they were made on.
5384    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
5385    let mut fresh: Vec<String> = Vec::new();
5386    let mut recent = false;
5387    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
5388        match record.and_then(|r| r.runs.get(run)) {
5389            None => {
5390                if !fresh.iter().any(|f| f == run) {
5391                    fresh.push(run.to_owned());
5392                }
5393            }
5394            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
5395            Some(_) => {}
5396        }
5397    }
5398    if !fresh.is_empty() {
5399        RerunStep::Rerun(fresh)
5400    } else if recent {
5401        RerunStep::Grace
5402    } else {
5403        RerunStep::Fix
5404    }
5405}
5406
5407/// Read the pull request: `gh pr view` for the top-level thread and merge
5408/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
5409/// inline review comments `gh pr view` does not report.
5410async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
5411    let view = gh(
5412        repo,
5413        &[
5414            "pr".to_owned(),
5415            "view".to_owned(),
5416            pr_url.to_owned(),
5417            "--json".to_owned(),
5418            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
5419                .to_owned(),
5420        ],
5421    )
5422    .await?;
5423    if !view.0 {
5424        bail!("gh pr view {pr_url}: {}", view.1);
5425    }
5426    let number = parse_pr(&view.1)?.number;
5427    let node = last_commit_node(repo, number).await;
5428    let mut seen = seen_from(&view.1, node.as_deref())?;
5429
5430    let inline = gh(
5431        repo,
5432        &[
5433            "api".to_owned(),
5434            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
5435        ],
5436    )
5437    .await?;
5438    if inline.0 {
5439        match parse_inline_comments(&inline.1) {
5440            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5441            // An unreadable inline thread must not end a landing: the rollup
5442            // and the top-level thread are still real signal.
5443            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5444        }
5445    } else {
5446        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5447    }
5448    Ok(seen)
5449}
5450
5451/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
5452/// response. No I/O.
5453///
5454/// The commit oid and the checks are read from the *same* node, so the rollup
5455/// is bound to the commit it belongs to by construction; two reads that agree
5456/// before and after another response prove nothing about what that response
5457/// held. The view's own rollup is never used. A node that is missing,
5458/// unreadable, carries GraphQL errors, or whose checks run past the page
5459/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
5460/// as "look again" and never as a reason to merge.
5461fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5462    let mut pr = parse_pr(view_json)?;
5463    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5464
5465    let mut rollup_head = String::new();
5466    let mut failing_urls = Vec::new();
5467    let mut contexts = Vec::new();
5468    let mut checks = Checks::Unknown;
5469    let mut failing = Vec::new();
5470    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5471        (checks, failing) = rollup_verdict(&rollup);
5472        failing_urls = rollup
5473            .iter()
5474            .filter(|c| c.verdict() == Verdict::Fail)
5475            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5476            .collect();
5477        contexts = rollup
5478            .iter()
5479            .map(|c| CheckInfo {
5480                label: c.label(),
5481                verdict: c.verdict(),
5482                required: c.is_required,
5483                run: c.url().and_then(run_of),
5484            })
5485            .collect();
5486        rollup_head = oid;
5487    }
5488    pr.checks = checks;
5489    pr.failing = failing;
5490
5491    Ok(Seen {
5492        pr,
5493        title: raw.title,
5494        failing_urls,
5495        head: raw.head_ref_oid,
5496        rollup_head,
5497        merge_state: raw.merge_state_status,
5498        contexts,
5499        base: raw.base_ref_name,
5500    })
5501}
5502
5503/// The last commit's oid and its checks from one GraphQL response, `None`
5504/// when anything about it cannot be trusted.
5505fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5506    let v: serde_json::Value = serde_json::from_str(json).ok()?;
5507    if v.get("errors").is_some_and(|e| !e.is_null()) {
5508        return None;
5509    }
5510    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5511    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5512    let contexts = commit.pointer("/statusCheckRollup/contexts");
5513    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5514        // No rollup at all: the commit has no checks.
5515        return Some((oid.to_owned(), Vec::new()));
5516    };
5517    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5518        return None;
5519    }
5520    let nodes = contexts.get("nodes")?.as_array()?;
5521    let rollup = nodes
5522        .iter()
5523        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5524        .collect::<Result<Vec<_>, _>>()
5525        .ok()?;
5526    Some((oid.to_owned(), rollup))
5527}
5528
5529/// The pull request's last commit and its checks, in one response. `None`
5530/// when the forge could not be asked.
5531async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5532    let out = gh(
5533        repo,
5534        &[
5535            "api".to_owned(),
5536            "graphql".to_owned(),
5537            "-F".to_owned(),
5538            "owner={owner}".to_owned(),
5539            "-F".to_owned(),
5540            "repo={repo}".to_owned(),
5541            "-F".to_owned(),
5542            format!("number={number}"),
5543            "-f".to_owned(),
5544            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5545             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5546             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5547             ... on CheckRun{name status conclusion detailsUrl \
5548             isRequired(pullRequestNumber:$number)} \
5549             ... on StatusContext{context state targetUrl \
5550             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5551                .to_owned(),
5552        ],
5553    )
5554    .await
5555    .ok()?;
5556    out.0.then_some(out.1)
5557}
5558
5559/// What a fix round did.
5560#[doc(hidden)]
5561#[derive(Debug, PartialEq)]
5562pub enum Fixed {
5563    /// The fixer committed something, and this is the head that was pushed.
5564    Committed {
5565        /// The commit now at the tip of the pushed branch.
5566        head: String,
5567    },
5568    /// The fixer ran and chose to change nothing.
5569    Declined,
5570    /// The fixer could not run, or said nothing usable.
5571    Failed(String),
5572}
5573
5574/// Hand the failures and the comments to the fixer, then commit and push.
5575///
5576/// The fixer works in the winner's own worktree so its commits land on the
5577/// branch the pull request is built from, and it runs with `allow_write` for
5578/// the same reason.
5579#[doc(hidden)]
5580pub async fn fix_round(
5581    state: &mut RunState,
5582    pr: &PrState,
5583    round: usize,
5584    budget: usize,
5585    reason: &str,
5586    logs: &str,
5587) -> Result<Fixed> {
5588    let winner = state
5589        .winner()
5590        .cloned()
5591        .context("landing needs a winning candidate; none is recorded on this run")?;
5592    let roles = state
5593        .config
5594        .resolve_roles()
5595        .context("resolve the roster for the fix round")?;
5596    // Same chain as the review loop (see `crate::fixer`): the configured
5597    // fixers in order, otherwise the winner's own author continuing its own
5598    // conversation - the competition is over, so its context is pure benefit.
5599    let attempts = crate::fixer::attempts(state, &roles, &winner);
5600    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5601
5602    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5603    let mut artifacts = agent::artifacts_dir(&state.dir());
5604    if let Some(request) = state
5605        .merge_repairs
5606        .last()
5607        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5608    {
5609        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5610    }
5611    let prompt = if state.config.cache_dir().is_some() {
5612        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5613    } else {
5614        prompt
5615    };
5616    // Read before the fixer runs: it normally commits for itself, so HEAD has
5617    // already moved by the time it returns and a later read would see no
5618    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
5619    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5620    // Each id at most once, forward only: the next one is asked only when the
5621    // call advances, and only the last attempt's result is judged below, so an
5622    // exhausted chain ends as a single failed fixer does.
5623    let mut last = None;
5624    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5625        if owner_requested_stop(state)? {
5626            return Ok(Fixed::Declined);
5627        }
5628        let mut seat = seat_of(state, &seat_key, &spec.id);
5629        let stem = if i == 0 {
5630            format!("land-{round}")
5631        } else {
5632            format!("land-{round}-{}", spec.id)
5633        };
5634        let out = agent::invoke(
5635            &spec,
5636            &mut seat,
5637            &Invocation {
5638                scope: crate::agent::InvocationScope::Repository,
5639                coordination: state.config.coordination.as_ref(),
5640                fetched_base: state.fetched_base.as_ref(),
5641                cwd: &winner.worktree,
5642                prompt: &prompt,
5643                timeout: Duration::from_secs(state.config.graph.timeout_fix),
5644                allow_write: true,
5645                unsandboxed: false,
5646                sessions: state.config.graph.sessions,
5647                artifacts: &artifacts,
5648                stem: &stem,
5649                run: &state.id,
5650                node: "land",
5651                cache_dir: state.config.cache_dir().as_deref(),
5652                attachments: &[],
5653                writable: &[],
5654            },
5655        )
5656        .await;
5657        state.seats.insert(seat.key.clone(), seat);
5658        if let Some(next) = ids.get(i + 1)
5659            && agent::chain_advances(&out)
5660        {
5661            let (class, why) = crate::fixer::failure_of(&out);
5662            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5663            continue;
5664        }
5665        last = Some(out);
5666        break;
5667    }
5668    let out = last.expect("the fixer chain always has an entry");
5669
5670    match out {
5671        Ok(o) if o.quota_exhausted() => {
5672            return Ok(Fixed::Failed(
5673                "rate limited (quota); the fixer could not run".to_owned(),
5674            ));
5675        }
5676        Ok(o) if !o.usable() => {
5677            return Ok(Fixed::Failed(format!(
5678                "the fixer produced nothing usable ({})",
5679                crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5680            )));
5681        }
5682        Ok(_) => {}
5683        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5684        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5685    }
5686
5687    if owner_requested_stop(state)? {
5688        return Ok(Fixed::Declined);
5689    }
5690
5691    // An agent that edited files but never committed would otherwise push
5692    // nothing and look like a refusal.
5693    let wait = crate::repo_lock::WaitPolicy::new(
5694        Duration::from_secs(state.config.graph.timeout_resource_wait),
5695        || false,
5696    );
5697    let coordination = crate::repo_lock::SeatLease::operation_with(
5698        &winner.worktree,
5699        state.config.coordination.as_ref(),
5700        &wait,
5701    )
5702    .await?;
5703    match git::rescue_commit(
5704        &winner.worktree,
5705        &format!("magi: land round {round} fixes (uncommitted work)"),
5706    )
5707    .await
5708    {
5709        Ok(r) => state.note_withheld("land", &r.withheld),
5710        Err(e)
5711            if state
5712                .merge_repairs
5713                .last()
5714                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5715        {
5716            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5717        }
5718        Err(_) => {}
5719    }
5720    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5721    drop(coordination);
5722    if after == before {
5723        return Ok(Fixed::Declined);
5724    }
5725
5726    if owner_requested_stop(state)? {
5727        return Ok(Fixed::Declined);
5728    }
5729    crate::pr_head::ensure_owner(state)?;
5730    let remote = state.config.merge.remote.clone();
5731    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5732    let push = if state.pr_head.is_some() {
5733        let expected = state
5734            .pr_pushed_head
5735            .as_deref()
5736            .context("missing public head lease")?;
5737        git::push_as(
5738            &winner.worktree,
5739            &remote,
5740            &winner.branch,
5741            &public_head,
5742            expected,
5743        )
5744        .await?
5745    } else {
5746        git::push(&winner.worktree, &remote, &winner.branch).await?
5747    };
5748    if !push.ok() {
5749        return Ok(Fixed::Failed(format!(
5750            "pushing {} to {remote} failed: {}",
5751            public_head, push.stderr
5752        )));
5753    }
5754    state.event(
5755        "land",
5756        format!("round {round}: pushed a fix to {public_head}"),
5757    );
5758    state.pr_pushed_head = Some(after.clone());
5759    state.save()?;
5760    Ok(Fixed::Committed { head: after })
5761}
5762
5763/// Fetch or create a seat, keeping its conversation across nodes.
5764pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5765    crate::fixer::seat_for(state, key, agent)
5766}
5767
5768/// Recheck the live PR and local worktree before spending the requested fix.
5769/// A moved/closed PR is held, never repaired using obsolete evidence.
5770pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5771    let seen = observe(&state.repo, url).await?;
5772    let winner = state.winner().context("no winning branch to repair")?;
5773    if seen.pr.state != PrLifecycle::Open || seen.head != head {
5774        anyhow::bail!(
5775            "PR is closed or its head changed; review the current head before requesting repair again"
5776        );
5777    }
5778    if git::rev_parse(&winner.worktree, "HEAD").await? != head
5779        || !git::is_clean(&winner.worktree).await?
5780    {
5781        anyhow::bail!(
5782            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5783        );
5784    }
5785    // Always disable any forge-side reservation, including one not recorded
5786    // by this run. A repair request is never merge authorization.
5787    let view = gh(
5788        &state.repo,
5789        &[
5790            "pr".into(),
5791            "view".into(),
5792            url.into(),
5793            "--json".into(),
5794            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5795        ],
5796    )
5797    .await?;
5798    if !view.0 {
5799        bail!("cannot check auto-merge: {}", view.1);
5800    }
5801    let live: serde_json::Value = serde_json::from_str(&view.1)?;
5802    let public = state.pr_branch().unwrap_or(&winner.branch);
5803    if live["state"] != "OPEN"
5804        || live["headRefOid"] != head
5805        || live["headRefName"] != public
5806        || live["baseRefName"] != state.base_branch
5807        || live["isCrossRepository"] != false
5808    {
5809        bail!("PR changed during repair preflight; review its current head explicitly");
5810    }
5811    let auto_merge = live
5812        .get("autoMergeRequest")
5813        .context("cannot read the PR auto-merge reservation; repair held")?;
5814    if !auto_merge.is_null() {
5815        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5816        if !out.0 {
5817            bail!("cannot disable auto-merge; repair held: {}", out.1);
5818        }
5819    }
5820    Ok(seen.pr)
5821}
5822
5823/// What the fixer is told.
5824fn fix_prompt(
5825    state: &RunState,
5826    pr: &PrState,
5827    round: usize,
5828    budget: usize,
5829    reason: &str,
5830    logs: &str,
5831) -> String {
5832    let mut s = format!(
5833        "Your patch is open as a pull request and it is not landing. Land round \
5834         {round} of {budget}.\n\n\
5835         Pull request: {}\n\n\
5836         What is holding it: {reason}\n\n\
5837         # The task\n\n{}\n",
5838        pr.url, state.instruction
5839    );
5840
5841    if pr.failing.is_empty() {
5842        s.push_str("\n# Failing checks\n\n(none)\n");
5843    } else {
5844        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5845        if logs.trim().is_empty() {
5846            s.push_str(
5847                "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5848            );
5849        } else {
5850            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5851        }
5852    }
5853
5854    if pr.review_comments.is_empty() {
5855        s.push_str("\n# Review comments\n\n(none)\n");
5856    } else {
5857        s.push_str("\n# Review comments\n");
5858        for c in &pr.review_comments {
5859            let where_ = match (&c.path, c.line) {
5860                (Some(p), Some(l)) => format!(" ({p}:{l})"),
5861                (Some(p), None) => format!(" ({p})"),
5862                _ => String::new(),
5863            };
5864            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5865        }
5866    }
5867
5868    s.push_str(
5869        "\n# Rules\n\n\
5870         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5871            failing test; do not silence a lint with an allow attribute; do not \
5872            stretch a timeout to hide a race. If the check is right, the code is \
5873            wrong.\n\
5874         2. Change nothing the checks and the comments did not raise. A \
5875            drive-by refactor turns a one-line fix into a pull request that \
5876            needs reviewing again.\n\
5877         3. If a comment is wrong, say so with a checkable argument and change \
5878            nothing for it. A declined comment with a reason is a correct \
5879            outcome; a change made to appease a reviewer is not.\n\
5880         4. Commit in this worktree. magi pushes to the pull request's branch \
5881            for you; do not push, merge, or close anything yourself.\n\
5882         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5883         # Output\n\n\
5884         Say what you changed and why, and what you declined and why.",
5885    );
5886
5887    let language = &state.config.graph.language;
5888    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5889        let _ = write!(s, "\n\nWrite all prose in {language}.");
5890    }
5891    // After the language line, so the exception is the last word on it.
5892    s.push_str(&crate::prompt::github_rule(
5893        language,
5894        &state.config.graph.github_language,
5895    ));
5896    if let Some(overlay) = state.config.prompts.overlay("fix") {
5897        let _ = write!(s, "\n\n{overlay}");
5898    }
5899    s.push('\n');
5900    s.push_str(crate::prompt::child_run_ownership());
5901    s
5902}
5903
5904/// Failing log tails, the way the operator collects them by hand:
5905/// `gh run view --log-failed`.
5906async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5907    let mut out = String::new();
5908    for (name, url) in failing.iter().take(MAX_LOGS) {
5909        let args = match (job_of(url), run_of(url)) {
5910            (Some(job), _) => vec![
5911                "run".to_owned(),
5912                "view".to_owned(),
5913                "--log-failed".to_owned(),
5914                "--job".to_owned(),
5915                job,
5916            ],
5917            (None, Some(run)) => vec![
5918                "run".to_owned(),
5919                "view".to_owned(),
5920                run,
5921                "--log-failed".to_owned(),
5922            ],
5923            // Not a GitHub Actions check - an external status has no log here.
5924            (None, None) => continue,
5925        };
5926        let (ok, body) = match gh(repo, &args).await {
5927            Ok(v) => v,
5928            Err(e) => (false, format!("{e:#}")),
5929        };
5930        if !ok && body.trim().is_empty() {
5931            continue;
5932        }
5933        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5934    }
5935    out
5936}
5937
5938/// Job id out of a check's `detailsUrl`
5939/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
5940fn job_of(details_url: &str) -> Option<String> {
5941    let after = details_url.split("/job/").nth(1)?;
5942    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5943    (!id.is_empty()).then_some(id)
5944}
5945
5946/// Workflow run id out of a check's `detailsUrl`.
5947pub(crate) fn run_of(details_url: &str) -> Option<String> {
5948    let after = details_url.split("/actions/runs/").nth(1)?;
5949    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5950    (!id.is_empty()).then_some(id)
5951}
5952
5953/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
5954/// it lands on GitHub, not in front of the operator. Pure so a test can hold
5955/// it to that.
5956fn stop_comment(run_id: &str, why: &str) -> String {
5957    format!(
5958        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5959         The branch is untouched and the run is `{run_id}`. Nothing was merged."
5960    )
5961}
5962
5963/// Leave the pull request open, say why on it, and mark the run blocked.
5964///
5965/// The comment is what makes an unattended stop actionable: the operator wakes
5966/// up to a pull request that explains itself rather than to a silent queue.
5967async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5968    let body = stop_comment(&state.id, why);
5969    let (_, mut body) = crate::github_text::prepare(state, "", &body);
5970    if !body.contains(MARKER) {
5971        body = format!("{MARKER}\n{body}");
5972    }
5973    let posted = gh(
5974        repo,
5975        &[
5976            "pr".to_owned(),
5977            "comment".to_owned(),
5978            pr.number.to_string(),
5979            "--body".to_owned(),
5980            body,
5981        ],
5982    )
5983    .await;
5984    match posted {
5985        Ok((true, _)) => {}
5986        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5987        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5988    }
5989    state.status = RunStatus::Blocked;
5990    state.merge = Some(MergeOutcome {
5991        mode: MergeMode::Pr,
5992        ok: false,
5993        detail: why.to_owned(),
5994        empty: false,
5995    });
5996    state.event("land", format!("stopped: {why}"));
5997    state.save()?;
5998    Ok(())
5999}
6000
6001/// Run `gh` in `repo`, returning success and the combined output.
6002///
6003/// Combined because `gh` reports a refused merge on stderr and the pull request
6004/// json on stdout, and both are evidence.
6005///
6006/// `GH_REPO` is stripped from the child's environment: every call site here
6007/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
6008/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
6009/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
6010/// `GH_REPO` the operator happens to have exported for an unrelated script
6011/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
6012/// below) to a different repository than the one actually on disk - which
6013/// for the same-repo guard in [`correct_manual_merge`] would mean the check
6014/// could be made to agree with whatever repository a forged `--merged` URL
6015/// claims, defeating it entirely.
6016pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
6017    let out = tokio::process::Command::new("gh")
6018        .args(args)
6019        .current_dir(cwd)
6020        .env_remove("GH_REPO")
6021        .quiet()
6022        .stdin(std::process::Stdio::null())
6023        .output()
6024        .await
6025        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
6026    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
6027    let err = String::from_utf8_lossy(&out.stderr);
6028    if body.trim().is_empty() {
6029        body = err.into_owned();
6030    } else if !err.trim().is_empty() {
6031        body.push_str(&err);
6032    }
6033    Ok((out.status.success(), body.trim().to_owned()))
6034}
6035
6036/// Verdict of one entry in the status rollup.
6037#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6038pub(crate) enum Verdict {
6039    Pass,
6040    Fail,
6041    Pending,
6042    Unknown,
6043}
6044
6045impl Verdict {
6046    /// Short operator-facing check result.
6047    pub(crate) fn label(self) -> &'static str {
6048        match self {
6049            Self::Pass => "passed",
6050            Self::Fail => "failed",
6051            Self::Pending => "pending",
6052            Self::Unknown => "unknown",
6053        }
6054    }
6055}
6056
6057#[derive(Debug, Deserialize)]
6058#[serde(rename_all = "camelCase")]
6059struct GhPr {
6060    #[serde(default)]
6061    url: String,
6062    #[serde(default)]
6063    number: u64,
6064    #[serde(default)]
6065    state: String,
6066    #[serde(default)]
6067    title: String,
6068    #[serde(default)]
6069    status_check_rollup: Vec<GhCheck>,
6070    /// GitHub's own verdict on whether the pull request can be merged.
6071    ///
6072    /// Worth asking for because it is the only place the *required* check set
6073    /// is applied: the rollup lists every check equally, so a repository that
6074    /// deliberately does not require `coverage` still looks red here. See
6075    /// [`Blocking`].
6076    #[serde(default)]
6077    merge_state_status: String,
6078    /// The commit the pull request currently points at. Compared with the
6079    /// commit a fix round pushed, it is how the loop knows the forge has moved
6080    /// on and the rollup belongs to the new head.
6081    #[serde(default)]
6082    head_ref_oid: String,
6083    #[serde(default)]
6084    base_ref_name: String,
6085    #[serde(default)]
6086    reviews: Vec<GhReview>,
6087    #[serde(default)]
6088    comments: Vec<GhComment>,
6089}
6090
6091/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
6092/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
6093/// status API, which is how CodeRabbit reports - has `context`/`state` and no
6094/// conclusion at all.
6095#[derive(Debug, Deserialize)]
6096#[serde(rename_all = "camelCase")]
6097struct GhCheck {
6098    #[serde(default)]
6099    name: Option<String>,
6100    #[serde(default)]
6101    context: Option<String>,
6102    #[serde(default)]
6103    status: Option<String>,
6104    #[serde(default)]
6105    conclusion: Option<String>,
6106    #[serde(default)]
6107    state: Option<String>,
6108    #[serde(default)]
6109    details_url: Option<String>,
6110    #[serde(default)]
6111    target_url: Option<String>,
6112    /// Whether the base branch requires this check. Only the GraphQL node
6113    /// carries it; `None` is "not read", never "not required".
6114    #[serde(default)]
6115    is_required: Option<bool>,
6116}
6117
6118impl GhCheck {
6119    /// Name to show a human and hand to the fixer.
6120    fn label(&self) -> String {
6121        self.name
6122            .clone()
6123            .or_else(|| self.context.clone())
6124            .unwrap_or_else(|| "(unnamed check)".to_owned())
6125    }
6126
6127    /// Where this check's logs live, when it has any.
6128    fn url(&self) -> Option<&str> {
6129        self.details_url
6130            .as_deref()
6131            .or(self.target_url.as_deref())
6132            .filter(|u| !u.is_empty())
6133    }
6134
6135    /// Did it pass?
6136    ///
6137    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
6138    /// skips release and bot pull requests by design, and a skip that blocked
6139    /// landing would block exactly the pull requests that need no review.
6140    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
6141    /// merging over one is merging over a check that never ran.
6142    fn verdict(&self) -> Verdict {
6143        if let Some(status) = self.status.as_deref() {
6144            if !status.eq_ignore_ascii_case("COMPLETED") {
6145                return Verdict::Pending;
6146            }
6147        }
6148        let outcome = self
6149            .conclusion
6150            .as_deref()
6151            .or(self.state.as_deref())
6152            .unwrap_or("");
6153        match outcome.to_ascii_uppercase().as_str() {
6154            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
6155            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
6156            | "ACTION_REQUIRED" => Verdict::Fail,
6157            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
6158                Verdict::Pending
6159            }
6160            _ => Verdict::Unknown,
6161        }
6162    }
6163}
6164
6165#[derive(Debug, Deserialize)]
6166struct GhAuthor {
6167    #[serde(default)]
6168    login: String,
6169}
6170
6171#[derive(Debug, Deserialize)]
6172struct GhReview {
6173    #[serde(default)]
6174    author: GhAuthor,
6175    #[serde(default)]
6176    body: String,
6177}
6178
6179#[derive(Debug, Deserialize)]
6180struct GhComment {
6181    #[serde(default)]
6182    author: GhAuthor,
6183    #[serde(default)]
6184    body: String,
6185}
6186
6187#[derive(Debug, Deserialize)]
6188struct GhUser {
6189    #[serde(default)]
6190    login: String,
6191}
6192
6193#[derive(Debug, Deserialize)]
6194struct GhInline {
6195    #[serde(default)]
6196    user: GhUser,
6197    #[serde(default)]
6198    path: Option<String>,
6199    #[serde(default)]
6200    line: Option<u64>,
6201    #[serde(default)]
6202    body: String,
6203}
6204
6205impl Default for GhAuthor {
6206    fn default() -> Self {
6207        Self {
6208            login: "(unknown)".to_owned(),
6209        }
6210    }
6211}
6212
6213impl Default for GhUser {
6214    fn default() -> Self {
6215        Self {
6216            login: "(unknown)".to_owned(),
6217        }
6218    }
6219}
6220
6221#[cfg(test)]
6222pub(crate) mod tests {
6223    use super::*;
6224    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
6225
6226    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
6227        format!(
6228            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
6229        )
6230    }
6231
6232    #[test]
6233    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
6234        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
6235        assert_eq!(
6236            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
6237            Ok(())
6238        );
6239        for (json, why) in [
6240            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
6241            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
6242            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
6243            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
6244            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
6245            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
6246        ] {
6247            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
6248                .unwrap_err()
6249                .why;
6250            assert!(err.contains(why), "{json}: {err}");
6251        }
6252        // A head that moved on past what was verified is left alone.
6253        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
6254        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
6255        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
6256        assert!(
6257            !closable(
6258                &head_json("x", "main", "OPEN", false),
6259                "magi/27b2/A",
6260                "main",
6261                &[]
6262            )
6263            .unwrap_err()
6264            .retry
6265        );
6266        assert!(is_forge_url("https://github.com/o/r.git"));
6267        assert!(is_forge_url("git@github.com:o/r.git"));
6268        assert!(!is_forge_url("/tmp/origin.git"));
6269        assert!(!is_forge_url("C:\\work\\origin.git"));
6270        assert!(!is_forge_url("file:///tmp/origin.git"));
6271        assert!(forge_unavailable(
6272            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
6273        ));
6274        assert!(!forge_unavailable(
6275            "gh pr list failed: error connecting to api.github.com"
6276        ));
6277        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
6278        // A record that does not say whether it is a fork is not trusted.
6279        assert!(
6280            closable(
6281                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
6282                "b",
6283                "main",
6284                &["aaa".to_owned()]
6285            )
6286            .is_err()
6287        );
6288    }
6289
6290    #[test]
6291    fn the_close_comment_names_the_commit_on_the_base() {
6292        let e = crate::already::Evidence {
6293            proof: crate::already::Proof::PatchId,
6294            tip: "1234567890".to_owned(),
6295            commits: vec!["0e368de0000".to_owned()],
6296        };
6297        let c = superseded_comment("main", &e);
6298        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
6299    }
6300
6301    /// 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.
6302    const GREEN_OPEN: &str = r####"{
6303  "url": "https://github.com/yukimemi/magi/pull/10",
6304  "number": 10,
6305  "state": "OPEN",
6306  "mergeStateStatus": "CLEAN",
6307  "statusCheckRollup": [
6308    {
6309      "__typename": "CheckRun",
6310      "conclusion": "SKIPPED",
6311      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
6312      "name": "review",
6313      "status": "COMPLETED",
6314      "workflowName": "claude-review"
6315    },
6316    {
6317      "__typename": "CheckRun",
6318      "conclusion": "SUCCESS",
6319      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
6320      "name": "check (ubuntu-latest)",
6321      "status": "COMPLETED",
6322      "workflowName": "CI"
6323    },
6324    {
6325      "__typename": "CheckRun",
6326      "conclusion": "SUCCESS",
6327      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
6328      "name": "rustfmt",
6329      "status": "COMPLETED",
6330      "workflowName": "CI"
6331    },
6332    {
6333      "__typename": "StatusContext",
6334      "context": "CodeRabbit",
6335      "state": "SUCCESS",
6336      "targetUrl": ""
6337    }
6338  ],
6339  "reviews": [],
6340  "comments": [
6341    {
6342      "author": {
6343        "login": "coderabbitai"
6344      },
6345      "authorAssociation": "NONE",
6346      "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"
6347    }
6348  ]
6349}"####;
6350
6351    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
6352    const RED_OPEN: &str = r####"{
6353  "url": "https://github.com/yukimemi/magi/pull/9",
6354  "number": 9,
6355  "state": "OPEN",
6356  "mergeStateStatus": "UNSTABLE",
6357  "statusCheckRollup": [
6358    {
6359      "__typename": "CheckRun",
6360      "conclusion": "SUCCESS",
6361      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6362      "name": "check (ubuntu-latest)",
6363      "status": "COMPLETED",
6364      "workflowName": "CI"
6365    },
6366    {
6367      "__typename": "CheckRun",
6368      "conclusion": "SUCCESS",
6369      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6370      "name": "rustfmt",
6371      "status": "COMPLETED",
6372      "workflowName": "CI"
6373    },
6374    {
6375      "__typename": "CheckRun",
6376      "conclusion": "FAILURE",
6377      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6378      "name": "editorconfig",
6379      "status": "COMPLETED",
6380      "workflowName": "CI"
6381    },
6382    {
6383      "__typename": "StatusContext",
6384      "context": "CodeRabbit",
6385      "state": "SUCCESS",
6386      "targetUrl": ""
6387    }
6388  ],
6389  "reviews": [],
6390  "comments": [
6391    {
6392      "author": {
6393        "login": "coderabbitai"
6394      },
6395      "authorAssociation": "NONE",
6396      "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"
6397    }
6398  ]
6399}"####;
6400
6401    /// 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.
6402    const PENDING_OPEN: &str = r####"{
6403  "url": "https://github.com/yukimemi/magi/pull/9",
6404  "number": 9,
6405  "state": "OPEN",
6406  "statusCheckRollup": [
6407    {
6408      "__typename": "CheckRun",
6409      "conclusion": "SUCCESS",
6410      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6411      "name": "check (ubuntu-latest)",
6412      "status": "COMPLETED",
6413      "workflowName": "CI"
6414    },
6415    {
6416      "__typename": "CheckRun",
6417      "conclusion": "SUCCESS",
6418      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6419      "name": "rustfmt",
6420      "status": "COMPLETED",
6421      "workflowName": "CI"
6422    },
6423    {
6424      "__typename": "CheckRun",
6425      "conclusion": null,
6426      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6427      "name": "editorconfig",
6428      "status": "IN_PROGRESS",
6429      "workflowName": "CI"
6430    },
6431    {
6432      "__typename": "StatusContext",
6433      "context": "CodeRabbit",
6434      "state": "SUCCESS",
6435      "targetUrl": ""
6436    }
6437  ],
6438  "reviews": [],
6439  "comments": []
6440}"####;
6441
6442    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
6443    const MERGED: &str = r####"{
6444  "url": "https://github.com/yukimemi/magi/pull/16",
6445  "number": 16,
6446  "state": "MERGED",
6447  "statusCheckRollup": [
6448    {
6449      "__typename": "CheckRun",
6450      "conclusion": "SUCCESS",
6451      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6452      "name": "check (ubuntu-latest)",
6453      "status": "COMPLETED",
6454      "workflowName": "CI"
6455    },
6456    {
6457      "__typename": "CheckRun",
6458      "conclusion": "SUCCESS",
6459      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6460      "name": "review",
6461      "status": "COMPLETED",
6462      "workflowName": "claude-review"
6463    }
6464  ],
6465  "reviews": [],
6466  "comments": []
6467}"####;
6468
6469    /// 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.
6470    const REVIEWED_OPEN: &str = r####"{
6471  "url": "https://github.com/yukimemi/magi/pull/12",
6472  "number": 12,
6473  "state": "OPEN",
6474  "statusCheckRollup": [
6475    {
6476      "__typename": "CheckRun",
6477      "conclusion": "SUCCESS",
6478      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6479      "name": "check (ubuntu-latest)",
6480      "status": "COMPLETED",
6481      "workflowName": "CI"
6482    },
6483    {
6484      "__typename": "CheckRun",
6485      "conclusion": "SUCCESS",
6486      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6487      "name": "review",
6488      "status": "COMPLETED",
6489      "workflowName": "claude-review"
6490    }
6491  ],
6492  "reviews": [
6493    {
6494      "author": {
6495        "login": "claude"
6496      },
6497      "state": "COMMENTED",
6498      "body": ""
6499    }
6500  ],
6501  "comments": [
6502    {
6503      "author": {
6504        "login": "coderabbitai"
6505      },
6506      "authorAssociation": "NONE",
6507      "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"
6508    },
6509    {
6510      "author": {
6511        "login": "claude"
6512      },
6513      "authorAssociation": "NONE",
6514      "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"
6515    }
6516  ]
6517}"####;
6518
6519    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
6520    const INLINE: &str = r####"[
6521  {
6522    "user": {
6523      "login": "claude[bot]"
6524    },
6525    "path": "src/graph.rs",
6526    "line": 231,
6527    "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"
6528  }
6529]"####;
6530
6531    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
6532    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6533<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6534
6535> [!IMPORTANT]
6536> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6537> 
6538> This repository does not receive automatic reviews because it has fewer than 10 stars.
6539> 
6540> <details>
6541> <summary>⚙️ Run configuration</summary>
6542> 
6543> **Configuration used**: defaults
6544> 
6545> **Review profile**: CHILL
6546> 
6547> **Plan**: Team
6548> 
6549> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6550> 
6551> </details>
6552
6553<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6554
6555<!-- tips_start -->
6556
6557---
6558
6559Thanks 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.
6560
6561<details>
6562<summary>❤️ Share</summary>
6563
6564- [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"####;
6565
6566    /// 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.
6567    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6568
6569---
6570### Reviewing PR #16
6571
6572- [x] Read AGENTS.md conventions
6573- [x] Review `src/daemon.rs` changes
6574- [x] Review `src/main.rs` changes (new `doctor` reporting)
6575- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6576- [x] Check test coverage for new behavior
6577- [x] Run verification commands (blocked — see note)
6578- [x] Post findings"####;
6579
6580    /// The same job's real comment on pull request #12 once it had something to say.
6581    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6582
6583---
6584### Review: `magi review <branch>` — cheap-half-only graph
6585
6586Read 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.
6587
6588**Correctness**
6589
6590- 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"####;
6591
6592    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6593        PrState {
6594            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6595            number: 16,
6596            state: PrLifecycle::Open,
6597            checks,
6598            // These tests are about red-means-fix, so a red here is one the
6599            // forge gates on. Without saying so they would assert the new
6600            // "merge past a check nobody requires" path by accident.
6601            blocking: if matches!(checks, Checks::Red) {
6602                Blocking::Yes
6603            } else {
6604                Blocking::No
6605            },
6606            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6607            review_comments: (0..comments)
6608                .map(|i| ReviewComment {
6609                    author: "coderabbitai".to_owned(),
6610                    path: Some("src/graph.rs".to_owned()),
6611                    line: Some(231),
6612                    body: format!("finding {i}"),
6613                })
6614                .collect(),
6615        }
6616    }
6617
6618    #[test]
6619    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6620        use CiExpectation::{Absent, Expected};
6621        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6622            let p = pr(checks, &["x"], 0);
6623            for waited in [Duration::ZERO, CHECKS_GRACE] {
6624                assert_eq!(
6625                    decide_with(&p, 0, 4, waited, Expected),
6626                    decide(&p, 0, 4, waited)
6627                );
6628            }
6629        }
6630        // Nothing will ever report: no wait, no give-up, no fix round.
6631        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6632            let p = pr(checks, &["x"], 0);
6633            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6634            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6635        }
6636        // A conflict is not a check: it still wants the rebase.
6637        let mut p = pr(Checks::Unknown, &[], 0);
6638        p.blocking = Blocking::Conflict;
6639        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6640        // And a pull request that left our hands is still done.
6641        p.state = PrLifecycle::Merged;
6642        assert_eq!(
6643            decide_with(&p, 0, 4, Duration::ZERO, Absent),
6644            Step::Done { merged: true }
6645        );
6646    }
6647
6648    #[test]
6649    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6650        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6651        assert_eq!(state.number, 10);
6652        assert_eq!(state.state, PrLifecycle::Open);
6653        assert_eq!(state.checks, Checks::Green);
6654        assert!(state.failing.is_empty());
6655        assert!(
6656            state.review_comments.is_empty(),
6657            "the only comment is CodeRabbit's trigger notice: {:?}",
6658            state.review_comments
6659        );
6660        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6661    }
6662
6663    #[test]
6664    fn a_failing_check_parses_as_red_and_is_named() {
6665        let state = parse_pr(RED_OPEN).expect("red fixture parses");
6666        assert_eq!(state.checks, Checks::Red);
6667        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6668        // The captured payload says `UNSTABLE` - mergeable, with a check
6669        // nobody requires red - which is exactly the shape that had to be
6670        // merged by hand. Asserted separately, in
6671        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
6672        // test is about is that a red check is *named*, so the reason a fixer
6673        // is handed says which one; so it asks the blocking question here.
6674        let mut blocking = state.clone();
6675        blocking.blocking = Blocking::Yes;
6676        match decide(&blocking, 0, 4, Duration::ZERO) {
6677            Step::Fix { reason } => {
6678                assert!(reason.contains("editorconfig"), "reason: {reason}");
6679                assert!(reason.contains("failing"), "reason: {reason}");
6680            }
6681            other => panic!("expected a fix round, got {other:?}"),
6682        }
6683    }
6684
6685    #[test]
6686    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6687        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6688        assert_eq!(state.checks, Checks::Pending);
6689        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6690    }
6691
6692    #[test]
6693    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6694        let state = parse_pr(MERGED).expect("merged fixture parses");
6695        assert_eq!(state.state, PrLifecycle::Merged);
6696        assert_eq!(
6697            decide(&state, 0, 4, Duration::ZERO),
6698            Step::Done { merged: true }
6699        );
6700    }
6701
6702    #[test]
6703    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6704        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6705        assert_eq!(state.checks, Checks::Green);
6706        let authors: Vec<&str> = state
6707            .review_comments
6708            .iter()
6709            .map(|c| c.author.as_str())
6710            .collect();
6711        assert_eq!(
6712            authors,
6713            vec!["claude"],
6714            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6715        );
6716        match decide(&state, 0, 4, Duration::ZERO) {
6717            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6718            other => panic!("expected a fix round, got {other:?}"),
6719        }
6720    }
6721
6722    #[test]
6723    fn inline_review_comments_keep_their_file_and_line() {
6724        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6725        assert_eq!(comments.len(), 1);
6726        assert_eq!(comments[0].author, "claude[bot]");
6727        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6728        assert_eq!(comments[0].line, Some(231));
6729        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6730    }
6731
6732    #[test]
6733    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6734        assert!(
6735            is_noise(CODERABBIT_TRIGGER),
6736            "CodeRabbit's trigger notice declares itself not a review"
6737        );
6738        assert!(
6739            is_noise(CLAUDE_CHECKLIST),
6740            "a progress checklist asks for nothing"
6741        );
6742        assert!(
6743            !is_noise(CLAUDE_FINDING),
6744            "a review that names a bug is input, not noise"
6745        );
6746
6747        let mut clean = pr(Checks::Green, &[], 0);
6748        clean.review_comments.push(ReviewComment {
6749            author: "coderabbitai".to_owned(),
6750            path: None,
6751            line: None,
6752            body: CODERABBIT_TRIGGER.to_owned(),
6753        });
6754        clean.review_comments.retain(|c| !is_noise(&c.body));
6755        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6756
6757        let mut found = pr(Checks::Green, &[], 0);
6758        found.review_comments.push(ReviewComment {
6759            author: "claude".to_owned(),
6760            path: None,
6761            line: None,
6762            body: CLAUDE_FINDING.to_owned(),
6763        });
6764        found.review_comments.retain(|c| !is_noise(&c.body));
6765        assert!(matches!(
6766            decide(&found, 0, 4, Duration::ZERO),
6767            Step::Fix { .. }
6768        ));
6769    }
6770
6771    #[test]
6772    fn the_policy_table_holds_for_every_combination_that_matters() {
6773        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6774            (
6775                "pending checks are waited for, even on the last round",
6776                pr(Checks::Pending, &[], 0),
6777                4,
6778                4,
6779                Duration::ZERO,
6780                Step::Wait,
6781            ),
6782            (
6783                "red checks are fixed",
6784                pr(Checks::Red, &["editorconfig"], 0),
6785                0,
6786                4,
6787                Duration::ZERO,
6788                Step::Fix {
6789                    reason: "1 check(s) failing: editorconfig".to_owned(),
6790                },
6791            ),
6792            (
6793                "green with comments is fixed, not merged",
6794                pr(Checks::Green, &[], 2),
6795                1,
6796                4,
6797                Duration::ZERO,
6798                Step::Fix {
6799                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6800                        .to_owned(),
6801                },
6802            ),
6803            (
6804                "green and clean merges",
6805                pr(Checks::Green, &[], 0),
6806                3,
6807                4,
6808                Duration::ZERO,
6809                Step::Merge,
6810            ),
6811            (
6812                "an unreadable rollup is waited on while the grace lasts",
6813                pr(Checks::Unknown, &[], 0),
6814                0,
6815                4,
6816                Duration::ZERO,
6817                Step::Wait,
6818            ),
6819            (
6820                "an unreadable rollup is never merged once the grace is spent",
6821                pr(Checks::Unknown, &[], 0),
6822                0,
6823                4,
6824                CHECKS_GRACE,
6825                Step::GiveUp {
6826                    reason: "no check status is readable on the pull request after 3 minute(s); \
6827                             refusing to merge on a guess"
6828                        .to_owned(),
6829                },
6830            ),
6831        ];
6832        for (what, state, round, budget, waited, want) in cases {
6833            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6834        }
6835    }
6836
6837    #[test]
6838    fn the_forge_verdict_survives_the_round_trip_from_gh() {
6839        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
6840        // requested but never parsed is the kind of thing that looks wired up
6841        // and answers `Unsaid` forever.
6842        let green = parse_pr(GREEN_OPEN).expect("parse");
6843        assert_eq!(green.blocking, Blocking::No);
6844        let red = parse_pr(RED_OPEN).expect("parse");
6845        assert_eq!(
6846            red.blocking,
6847            Blocking::No,
6848            "`UNSTABLE` is mergeable: the red check is one nobody requires"
6849        );
6850        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6851        // A payload from an older `gh` has no such field at all.
6852        let quiet =
6853            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6854        assert_eq!(quiet.blocking, Blocking::Unsaid);
6855    }
6856
6857    #[test]
6858    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6859        // Pull request 37's only red check was `editorconfig`, failing
6860        // because the action could not fetch its own binary after
6861        // editorconfig-checker v4 renamed its release assets. The repository
6862        // does not require it. magi answered by asking a fixer to repair a
6863        // change that was fine, and the pull request had to be merged by hand.
6864        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6865        nonblocking.blocking = Blocking::No;
6866        assert_eq!(
6867            decide(&nonblocking, 0, 4, Duration::ZERO),
6868            Step::Merge,
6869            "the forge says nothing is in the way, so nothing is"
6870        );
6871
6872        // The same red, gated on: that is a fix round, as before.
6873        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6874        blocking.blocking = Blocking::Yes;
6875        assert!(matches!(
6876            decide(&blocking, 0, 4, Duration::ZERO),
6877            Step::Fix { .. }
6878        ));
6879
6880        // A review comment still outranks green-enough: a non-required red
6881        // must not become a way to merge past an unanswered reviewer.
6882        let mut commented = pr(Checks::Red, &["coverage"], 1);
6883        commented.blocking = Blocking::No;
6884        assert!(matches!(
6885            decide(&commented, 0, 4, Duration::ZERO),
6886            Step::Fix { .. }
6887        ));
6888
6889        // And silence from the forge is not consent.
6890        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6891        unsaid.blocking = Blocking::Unsaid;
6892        assert_eq!(decide(&unsaid, 0, 4, Duration::ZERO), Step::Wait);
6893        assert!(matches!(
6894            decide(&unsaid, 0, 4, CHECKS_GRACE),
6895            Step::Fix { .. }
6896        ));
6897    }
6898
6899    #[test]
6900    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6901        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6902        red.blocking = Blocking::No;
6903        assert_eq!(
6904            decide(&red, 0, 4, Duration::ZERO),
6905            Step::Merge,
6906            "announcing must not change the decision"
6907        );
6908        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6909        assert!(said.contains("yukimemi/magi"), "{said}");
6910        assert!(said.contains("#16"), "{said}");
6911        assert!(
6912            said.contains("https://github.com/yukimemi/magi/pull/16"),
6913            "{said}"
6914        );
6915        assert!(
6916            said.contains("test (windows-latest)") && said.contains("coverage"),
6917            "{said}"
6918        );
6919
6920        // `failing` can be left over on a green observation; only `checks` counts.
6921        let green = pr(Checks::Green, &["stale"], 0);
6922        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6923    }
6924
6925    #[test]
6926    fn red_merge_notices_omit_empty_check_details() {
6927        for checks in [vec![], vec!["", "  "]] {
6928            let red = pr(Checks::Red, &checks, 0);
6929            let summary = red_merge_summary("owner/repo", &red).unwrap();
6930            let notice = crate::notices::merged_red("run-1", &summary);
6931            assert_eq!(
6932                notice.message,
6933                format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6934            );
6935            assert_eq!(notice.severity, crate::notices::Severity::Warn);
6936        }
6937        let red = pr(Checks::Red, &["", " test ", " "], 0);
6938        let summary = red_merge_summary("owner/repo", &red).unwrap();
6939        assert!(summary.contains("with red checks: test ("), "{summary}");
6940    }
6941
6942    #[test]
6943    fn the_repo_label_comes_from_the_pull_request_url() {
6944        let p = Path::new("/tmp/checkout");
6945        assert_eq!(
6946            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6947            "yukimemi/magi"
6948        );
6949        assert_eq!(repo_label(p, "not a url"), "checkout");
6950    }
6951
6952    #[test]
6953    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6954        // Pull requests 35 and 37 were both rebased by hand: a competition
6955        // that runs for two hours against a repository merging pull requests
6956        // all day conflicts on the way in, and that is arithmetic rather
6957        // than a defect in the change.
6958        let mut conflicted = pr(Checks::Green, &[], 0);
6959        conflicted.blocking = Blocking::Conflict;
6960        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6961
6962        // Decided before the checks, and even with the rounds spent: every
6963        // check on a branch that cannot land is an answer about a state that
6964        // cannot land, and a conflict is not the change's fault.
6965        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6966        red.blocking = Blocking::Conflict;
6967        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6968
6969        // The lifecycle still wins over everything, conflict included.
6970        let mut merged = pr(Checks::Red, &[], 0);
6971        merged.blocking = Blocking::Conflict;
6972        merged.state = PrLifecycle::Merged;
6973        assert_eq!(
6974            decide(&merged, 0, 4, Duration::ZERO),
6975            Step::Done { merged: true }
6976        );
6977    }
6978
6979    #[test]
6980    fn the_forge_verdict_is_read_off_merge_state_status() {
6981        // The spellings that mean "mergeable". `UNSTABLE` is the one that
6982        // matters: mergeable, with a non-required check red or still running.
6983        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6984            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6985            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6986        }
6987        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6988        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6989        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6990        // An older `gh`, or a token without the scope, says nothing - and
6991        // refusing to guess is the rule everywhere else in this module.
6992        for quiet in ["", "UNKNOWN"] {
6993            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6994            assert!(Blocking::of(quiet).stops_a_merge());
6995        }
6996    }
6997
6998    #[test]
6999    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
7000        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
7001        // The exact stderr from run ec12, in a jj-colocated repository.
7002        let jj = "could not determine current branch: failed to run git: not on any branch";
7003
7004        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
7005            .expect("the forge says merged, so it merged");
7006        assert!(landed.ok);
7007        assert!(
7008            landed.detail.contains("but the pull request is merged"),
7009            "the record must not read as a clean success: {}",
7010            landed.detail
7011        );
7012        assert!(
7013            landed.detail.contains("not on any branch"),
7014            "and it must keep what the command actually said: {}",
7015            landed.detail
7016        );
7017
7018        // A pull request still open means the merge really failed.
7019        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
7020        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
7021        // And an unreadable answer is not evidence of success.
7022        assert!(merged_after_all(&argv, jj, None).is_none());
7023    }
7024
7025    #[test]
7026    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
7027        let mut state = pr(Checks::Red, &["editorconfig"], 3);
7028        state.state = PrLifecycle::Closed;
7029        assert_eq!(
7030            decide(&state, 0, 4, Duration::ZERO),
7031            Step::Done { merged: false },
7032            "a human closing the pull request ends the loop, whatever CI says"
7033        );
7034    }
7035
7036    #[test]
7037    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
7038        let red = decide(
7039            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
7040            4,
7041            4,
7042            Duration::ZERO,
7043        );
7044        match red {
7045            Step::GiveUp { reason } => {
7046                assert!(reason.contains("editorconfig"), "reason: {reason}");
7047                assert!(reason.contains("test (macos)"), "reason: {reason}");
7048                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
7049            }
7050            other => panic!("expected a give-up, got {other:?}"),
7051        }
7052
7053        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
7054        match commented {
7055            Step::GiveUp { reason } => {
7056                assert!(reason.contains("unresolved"), "reason: {reason}");
7057                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
7058            }
7059            other => panic!("expected a give-up, got {other:?}"),
7060        }
7061    }
7062
7063    #[test]
7064    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
7065        let candidate_commit = "magi: candidate A (uncommitted work)";
7066        let subject = merge_subject(candidate_commit, "add retries to the uploader");
7067        let argv = merge_argv(16, &subject);
7068
7069        assert!(argv.contains(&"--squash".to_owned()));
7070        assert!(argv.contains(&"--delete-branch".to_owned()));
7071        assert!(argv.contains(&"--subject".to_owned()));
7072        assert_eq!(
7073            argv.last().map(String::as_str),
7074            Some("add retries to the uploader"),
7075            "the subject must not be the candidate commit message"
7076        );
7077        assert_ne!(subject, candidate_commit);
7078    }
7079
7080    #[test]
7081    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
7082        assert_eq!(
7083            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
7084            "feat: a queue, an unattended loop, and a phone UI"
7085        );
7086        assert_eq!(
7087            merge_subject("", "# port the retry logic\n\ndetails"),
7088            "port the retry logic",
7089            "an empty title falls back to the task's first line, heading marks stripped"
7090        );
7091    }
7092
7093    #[test]
7094    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
7095        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
7096        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
7097        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
7098        assert_eq!(job_of("https://coderabbit.ai/status"), None);
7099        assert_eq!(run_of(""), None);
7100    }
7101
7102    #[test]
7103    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
7104        let mut out = Vec::new();
7105        push_if_outstanding(
7106            &mut out,
7107            ReviewComment {
7108                author: "yukimemi".to_owned(),
7109                path: None,
7110                line: None,
7111                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
7112            },
7113        );
7114        assert!(out.is_empty());
7115    }
7116
7117    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
7118    /// falls back to the repository - which keeps these tests free of a
7119    /// worktree, a `git` invocation and a network.
7120    fn run_state() -> RunState {
7121        let mut state = RunState::new(
7122            std::path::PathBuf::from("/repo/magi"),
7123            "main".to_owned(),
7124            "abcdef1234".to_owned(),
7125            "add retries to the uploader".to_owned(),
7126            crate::config::Config::default(),
7127        );
7128        // Tests run in parallel against one persistent home and the ids
7129        // `RunState::new` draws from the clock can repeat, so two tests would
7130        // share a run's questions. The home also outlives the process, so the
7131        // counter alone would reuse an earlier run's ids.
7132        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7133        let nanos = std::time::SystemTime::now()
7134            .duration_since(std::time::UNIX_EPOCH)
7135            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
7136        state.id = format!(
7137            "20261004-{nanos:06}-{:04x}",
7138            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
7139        );
7140        state
7141    }
7142
7143    fn green_pr() -> PrState {
7144        PrState {
7145            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
7146            number: 42,
7147            state: PrLifecycle::Open,
7148            checks: Checks::Green,
7149            // The forge sees nothing in the way unless a test says otherwise.
7150            blocking: Blocking::No,
7151            failing: Vec::new(),
7152            review_comments: vec![ReviewComment {
7153                author: "coderabbitai".to_owned(),
7154                path: Some("src/land.rs".to_owned()),
7155                line: Some(212),
7156                body: "this branch never checks the exit code".to_owned(),
7157            }],
7158        }
7159    }
7160
7161    #[test]
7162    fn land_fixer_defers_review_and_verification_without_a_cache() {
7163        let state = run_state();
7164        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7165        assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
7166        assert!(p.contains("Commit in this worktree"), "{p}");
7167    }
7168
7169    #[test]
7170    fn github_facing_land_text_is_english_whatever_the_language() {
7171        let mut state = run_state();
7172        state.config.graph.language = "ja".to_owned();
7173        let comment = stop_comment(&state.id, "checks are still red");
7174        assert!(crate::github_text::check("", &comment).is_empty());
7175        assert!(comment.is_ascii(), "{comment}");
7176        assert!(comment.starts_with(MARKER));
7177
7178        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7179        let ja_at = p.find("Write all prose in ja").unwrap();
7180        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
7181        assert!(ja_at < rule_at, "{p}");
7182        assert!(p.contains("stays in Japanese"), "{p}");
7183
7184        state.config.graph.language = "en".to_owned();
7185        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7186        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
7187        assert!(!p.contains("does not apply"), "{p}");
7188    }
7189
7190    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
7191
7192    fn panel() -> String {
7193        approval_panel(
7194            &run_state(),
7195            &green_pr(),
7196            NUMSTAT,
7197            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
7198            &[
7199                "land: ask before merging".to_owned(),
7200                "land: colour the diff".to_owned(),
7201            ],
7202            "feat: merge approval from the phone",
7203        )
7204    }
7205
7206    #[test]
7207    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
7208        let html = panel();
7209        for needle in [
7210            "42",
7211            "main",
7212            "src/land.rs",
7213            "src/web.rs",
7214            "assets/logo.png",
7215            "feat: merge approval from the phone",
7216            "land: ask before merging",
7217            "land: colour the diff",
7218            "coderabbitai",
7219            "this branch never checks the exit code",
7220            "green",
7221        ] {
7222            assert!(html.contains(needle), "the panel must state `{needle}`");
7223        }
7224    }
7225
7226    async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
7227        crate::run::pin_test_home();
7228        let tmp = tempfile::tempdir().unwrap();
7229        let repo = tmp.path().join("repo");
7230        let remote = tmp.path().join("remote.git");
7231        std::fs::create_dir(&repo).unwrap();
7232        git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
7233            .await
7234            .unwrap();
7235        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
7236        git::git(&repo, &["config", "user.name", "Test"])
7237            .await
7238            .unwrap();
7239        git::git(&repo, &["config", "user.email", "test@example.test"])
7240            .await
7241            .unwrap();
7242        std::fs::write(repo.join("base"), "base").unwrap();
7243        git::git(&repo, &["add", "."]).await.unwrap();
7244        git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
7245        git::git(
7246            &repo,
7247            &["remote", "add", "origin", remote.to_str().unwrap()],
7248        )
7249        .await
7250        .unwrap();
7251        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
7252        git::git(&repo, &["checkout", "-b", "winner"])
7253            .await
7254            .unwrap();
7255        std::fs::write(repo.join("change"), "change").unwrap();
7256        git::git(&repo, &["add", "."]).await.unwrap();
7257        git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
7258        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
7259        git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
7260            .await
7261            .unwrap();
7262        let mut state = run_state();
7263        state.repo = repo.clone();
7264        state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
7265        state.config.graph.land = true;
7266        state.config.graph.land_approval = true;
7267        state.pr_head = Some("task-head".into());
7268        state.pr_pushed_head = Some(head.clone());
7269        state.candidates = vec![winning_candidate("change")];
7270        state.candidates[0].worktree = repo;
7271        state.candidates[0].branch = "winner".into();
7272        state.tally = Some(uncontested_tally());
7273        state.gate_ran = true;
7274        state.gate_head = Some(head.clone());
7275        state.land_sync_pending = true;
7276        state.save().unwrap();
7277        (tmp, state, head)
7278    }
7279
7280    async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
7281        let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
7282        resume_landing_head(state, head, &green_pr().url, &forge).await
7283    }
7284
7285    async fn advance_landing_base(state: &RunState) {
7286        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7287        std::fs::write(state.repo.join("advance"), "advance").unwrap();
7288        git::git(&state.repo, &["add", "."]).await.unwrap();
7289        git::git(&state.repo, &["commit", "-m", "advance base"])
7290            .await
7291            .unwrap();
7292        git::git(&state.repo, &["push", "origin", "main"])
7293            .await
7294            .unwrap();
7295        git::git(&state.repo, &["checkout", "winner"])
7296            .await
7297            .unwrap();
7298    }
7299
7300    #[tokio::test]
7301    async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
7302        let (_tmp, mut state, old) = landing_git_fixture().await;
7303        advance_landing_base(&state).await;
7304        state.config.verify.gate = vec!["test -f advance".into()];
7305        let head = resume_observed_head(&mut state, &old).await.unwrap();
7306        assert_ne!(head, old);
7307        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7308        assert!(state.gate_allows_progress());
7309        assert_eq!(
7310            git::remote_head(&state.repo, "origin", "task-head")
7311                .await
7312                .unwrap(),
7313            Some(head.clone())
7314        );
7315        assert_eq!(state.pr_pushed_head, Some(head));
7316        assert!(!state.land_sync_pending);
7317        assert!(state.config.graph.land_approval);
7318    }
7319
7320    fn landing_gate_fixer(state: &mut RunState, script: &str) {
7321        state.config.disk.min_free_bytes = 0;
7322        state.config.graph.gate_fix_rounds = 1;
7323        state.config.agents = vec![crate::config::AgentSpec {
7324            id: "mock".into(),
7325            kind: crate::config::AgentKind::Command,
7326            model: None,
7327            command: vec!["sh".into(), "-c".into(), script.into()],
7328            extra_args: Vec::new(),
7329            env: Default::default(),
7330            prompt_delivery: None,
7331            max_turn_secs: None,
7332        }];
7333        state.candidates[0].agent = "mock".into();
7334    }
7335
7336    #[tokio::test]
7337    async fn landing_resume_revalidates_an_operator_published_winner_extension() {
7338        let (_tmp, mut state, old) = landing_git_fixture().await;
7339        std::fs::write(state.repo.join("change"), "operator update").unwrap();
7340        git::commit_all(&state.repo, "extend winner").await.unwrap();
7341        let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7342        git::git(
7343            &state.repo,
7344            &["push", "origin", "HEAD:refs/heads/task-head"],
7345        )
7346        .await
7347        .unwrap();
7348        state.config.verify.gate = vec!["exit 1".into()];
7349        state.config.graph.gate_fix_rounds = 0;
7350        assert!(resume_observed_head(&mut state, &updated).await.is_err());
7351        assert_ne!(old, updated);
7352        assert_eq!(state.gate_head.as_ref(), Some(&updated));
7353        assert!(!state.gate_allows_progress());
7354        assert!(state.land_sync_pending);
7355        state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
7356        let head = resume_observed_head(&mut state, &updated).await.unwrap();
7357        assert_eq!(head, updated);
7358        assert!(state.gate_allows_progress());
7359        assert!(!state.land_sync_pending);
7360        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7361        land_with(&mut state, &green_pr().url, &forge)
7362            .await
7363            .unwrap();
7364        assert!(state.parked);
7365        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7366        assert!(forge.argvs().is_empty());
7367    }
7368
7369    #[tokio::test]
7370    async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
7371        for divergent in [false, true] {
7372            let (_tmp, mut state, old) = landing_git_fixture().await;
7373            if divergent {
7374                git::git(&state.repo, &["reset", "--hard", "origin/main"])
7375                    .await
7376                    .unwrap();
7377            }
7378            std::fs::write(state.repo.join("external"), "external").unwrap();
7379            git::commit_all(&state.repo, "external update")
7380                .await
7381                .unwrap();
7382            let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7383            git::git(
7384                &state.repo,
7385                &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
7386            )
7387            .await
7388            .unwrap();
7389            if !divergent {
7390                git::git(&state.repo, &["reset", "--hard", &old])
7391                    .await
7392                    .unwrap();
7393            }
7394            let error = resume_observed_head(&mut state, &external)
7395                .await
7396                .unwrap_err();
7397            assert!(
7398                error
7399                    .to_string()
7400                    .contains("public head moved outside this run")
7401            );
7402            assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
7403            assert_eq!(
7404                git::remote_head(&state.repo, "origin", "task-head")
7405                    .await
7406                    .unwrap(),
7407                Some(external)
7408            );
7409        }
7410    }
7411
7412    #[tokio::test]
7413    async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
7414        let (_tmp, mut state, old) = landing_git_fixture().await;
7415        advance_landing_base(&state).await;
7416        landing_gate_fixer(
7417            &mut state,
7418            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7419        );
7420        state.config.verify.gate =
7421            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7422        state.config.verify.e2e = vec!["test -f advance".into()];
7423        // First save a red gate without spending a fixer round. Resume must
7424        // still offer the remaining budget for this exact same failed head.
7425        state.config.graph.gate_fix_rounds = 0;
7426        assert!(resume_observed_head(&mut state, &old).await.is_err());
7427        assert_eq!(
7428            git::remote_head(&state.repo, "origin", "task-head")
7429                .await
7430                .unwrap(),
7431            Some(old.clone())
7432        );
7433        state.config.graph.gate_fix_rounds = 1;
7434        let head = resume_observed_head(&mut state, &old).await.unwrap();
7435        assert_eq!(state.gate_fixes.len(), 1);
7436        assert!(state.gate_fixes[0].committed);
7437        assert!(state.gate_allows_progress());
7438        assert_eq!(state.gate_head.as_ref(), Some(&head));
7439        assert_eq!(
7440            git::remote_head(&state.repo, "origin", "task-head")
7441                .await
7442                .unwrap(),
7443            Some(head.clone())
7444        );
7445        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7446        land_with(&mut state, &green_pr().url, &forge)
7447            .await
7448            .unwrap();
7449        assert!(state.parked);
7450        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7451        assert!(forge.argvs().is_empty());
7452    }
7453
7454    #[tokio::test]
7455    async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7456        let (_tmp, mut state, old) = landing_git_fixture().await;
7457        advance_landing_base(&state).await;
7458        landing_gate_fixer(
7459            &mut state,
7460            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7461        );
7462        state.config.verify.gate = vec!["echo still red; exit 1".into()];
7463        for _ in 0..2 {
7464            assert!(resume_observed_head(&mut state, &old).await.is_err());
7465            assert_eq!(state.gate_fixes.len(), 1);
7466            assert_eq!(
7467                git::remote_head(&state.repo, "origin", "task-head")
7468                    .await
7469                    .unwrap(),
7470                Some(old.clone())
7471            );
7472        }
7473    }
7474
7475    #[tokio::test]
7476    async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7477        let (_tmp, mut state, old) = landing_git_fixture().await;
7478        advance_landing_base(&state).await;
7479        landing_gate_fixer(
7480            &mut state,
7481            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7482        );
7483        state.config.verify.gate =
7484            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7485        // The repair makes the gate green while breaking an e2e invariant.
7486        state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7487        for resume in 0..3 {
7488            assert!(resume_observed_head(&mut state, &old).await.is_err());
7489            state = RunState::load(&state.id).unwrap();
7490            let evidence = state.gate_repair_e2e.as_ref().unwrap();
7491            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7492            assert_eq!(evidence.head, head);
7493            assert!(!evidence.passed);
7494            assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7495            assert_eq!(state.gate_fixes.len(), 1);
7496            assert_eq!(
7497                git::remote_head(&state.repo, "origin", "task-head")
7498                    .await
7499                    .unwrap(),
7500                Some(old.clone())
7501            );
7502            if resume > 0 {
7503                assert!(
7504                    state.gate_allows_progress(),
7505                    "a green gate must not bypass red e2e"
7506                );
7507            }
7508        }
7509        // Once e2e genuinely succeeds on the same SHA, the saved requirement
7510        // can be satisfied without another fixer or approval bypass.
7511        state.config.verify.e2e = vec!["grep -q fixed change".into()];
7512        let head = resume_observed_head(&mut state, &old).await.unwrap();
7513        assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7514        assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7515        assert_eq!(state.gate_fixes.len(), 1);
7516        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7517        land_with(&mut state, &green_pr().url, &forge)
7518            .await
7519            .unwrap();
7520        assert!(state.parked);
7521        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7522        assert!(forge.argvs().is_empty());
7523    }
7524
7525    #[tokio::test]
7526    async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7527        for phase in 0..3 {
7528            let (_tmp, mut state, old) = landing_git_fixture().await;
7529            advance_landing_base(&state).await;
7530            let head = resume_observed_head(&mut state, &old).await.unwrap();
7531            // Persist the shapes left by interruption, resource deferral,
7532            // and a successful check for a different (older) SHA.
7533            state.land_sync_pending = true;
7534            state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7535            state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7536                head: if phase == 2 {
7537                    old.clone()
7538                } else {
7539                    head.clone()
7540                },
7541                passed: phase == 2,
7542                outcomes: if phase == 1 {
7543                    vec![crate::run::CommandOutcome {
7544                        command: state.config.verify.e2e[0].clone(),
7545                        code: None,
7546                        output_tail: "verification resource unavailable".into(),
7547                        duration_ms: 0,
7548                        resource_blocked: true,
7549                    }]
7550                } else {
7551                    Vec::new()
7552                },
7553            });
7554            state.save().unwrap();
7555            for _ in 0..2 {
7556                state = RunState::load(&state.id).unwrap();
7557                assert!(resume_observed_head(&mut state, &head).await.is_err());
7558                let evidence = state.gate_repair_e2e.as_ref().unwrap();
7559                assert_eq!(evidence.head, head);
7560                assert!(!evidence.passed);
7561                assert!(!evidence.outcomes.is_empty());
7562                assert_eq!(
7563                    git::remote_head(&state.repo, "origin", "task-head")
7564                        .await
7565                        .unwrap(),
7566                    Some(head.clone())
7567                );
7568                assert!(state.land_sync_pending);
7569            }
7570        }
7571    }
7572
7573    async fn interrupt_landing_rebase(state: &mut RunState) {
7574        state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7575        state.save().unwrap();
7576        let scratch = state.dir().join("rebase");
7577        assert_eq!(
7578            crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7579                .await
7580                .unwrap(),
7581            crate::rebase::Rebased::Applied
7582        );
7583        assert!(!git::is_clean(&state.repo).await.unwrap());
7584    }
7585
7586    async fn landing_setup(state: &mut RunState) {
7587        git::git(
7588            &state.repo,
7589            &["config", "extensions.worktreeConfig", "true"],
7590        )
7591        .await
7592        .unwrap();
7593        state.config.worktree.setup =
7594            vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7595        crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7596            .await
7597            .unwrap();
7598        state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7599    }
7600
7601    #[tokio::test]
7602    async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7603        for phase in 0..3 {
7604            let (_tmp, mut state, old) = landing_git_fixture().await;
7605            advance_landing_base(&state).await;
7606            landing_setup(&mut state).await;
7607            if phase > 0 {
7608                interrupt_landing_rebase(&mut state).await;
7609                if phase == 2 {
7610                    // Crash after clean removed setup products, before setup.
7611                    git::sync_to_head(&state.repo).await.unwrap();
7612                    assert!(!state.repo.join(".env").exists());
7613                }
7614                state = RunState::load(&state.id).unwrap();
7615            }
7616            let head = resume_observed_head(&mut state, &old).await.unwrap();
7617            assert_ne!(head, old);
7618            assert!(state.gate_allows_progress());
7619            assert!(state.land_rebase_from.is_none());
7620            assert_eq!(
7621                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7622                "secret"
7623            );
7624            assert!(git::is_clean(&state.repo).await.unwrap());
7625            assert!(
7626                !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7627                    .await
7628                    .unwrap()
7629                    .contains(".env")
7630            );
7631        }
7632    }
7633
7634    #[tokio::test]
7635    async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7636        let (_tmp, mut state, _) = landing_git_fixture().await;
7637        advance_landing_base(&state).await;
7638        landing_setup(&mut state).await;
7639        interrupt_landing_rebase(&mut state).await;
7640        // Exercise unusual names without asking Windows to create an invalid
7641        // control-character filename. Unix also covers embedded newlines.
7642        let locals = [
7643            "local notes",
7644            #[cfg(unix)]
7645            "local\nnotes",
7646        ];
7647        let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7648            .await
7649            .unwrap();
7650        std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7651        for local in locals {
7652            std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7653            assert!(
7654                git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7655                    .await
7656                    .unwrap()
7657                    .ok()
7658            );
7659        }
7660        restore_landing_worktree(&mut state).await.unwrap();
7661        for local in locals {
7662            assert_eq!(
7663                std::fs::read_to_string(state.repo.join(local)).unwrap(),
7664                "keep these notes"
7665            );
7666        }
7667        assert_eq!(
7668            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7669            "secret"
7670        );
7671        assert!(git::is_clean(&state.repo).await.unwrap());
7672        assert!(state.land_rebase_from.is_none());
7673    }
7674
7675    #[tokio::test]
7676    async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7677        for local in ["advance", "advance/notes", "new-directory"] {
7678            let (_tmp, mut state, _) = landing_git_fixture().await;
7679            advance_landing_base(&state).await;
7680            // Also cover a local file blocking a new tracked directory.
7681            git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7682            std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7683            std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7684            git::git(&state.repo, &["add", "."]).await.unwrap();
7685            git::git(&state.repo, &["commit", "-m", "add directory"])
7686                .await
7687                .unwrap();
7688            git::git(&state.repo, &["push", "origin", "main"])
7689                .await
7690                .unwrap();
7691            git::git(&state.repo, &["checkout", "winner"])
7692                .await
7693                .unwrap();
7694            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7695                .await
7696                .unwrap();
7697            std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7698            let path = state.repo.join(local);
7699            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7700            std::fs::write(&path, "local notes").unwrap();
7701            interrupt_landing_rebase(&mut state).await;
7702            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7703            let checkpoint = state.land_rebase_from.clone();
7704            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7705            assert!(
7706                error.to_string().contains("conflicting with HEAD"),
7707                "{error:#}"
7708            );
7709            assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7710            assert_eq!(
7711                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7712                before
7713            );
7714            assert_eq!(state.land_rebase_from, checkpoint);
7715        }
7716    }
7717
7718    #[tokio::test]
7719    async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7720        for interrupted in [false, true] {
7721            let (_tmp, mut state, old) = landing_git_fixture().await;
7722            assert!(state.config.worktree.setup.is_empty());
7723            // A gate product exists before the base moves, without setup metadata.
7724            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7725                .await
7726                .unwrap();
7727            std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7728            state.config.verify.gate =
7729                vec!["mkdir -p target && printf artifact > target/probe".into()];
7730            state.gate_ran = false;
7731            resume_observed_head(&mut state, &old).await.unwrap();
7732            assert_eq!(
7733                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7734                "artifact"
7735            );
7736            advance_landing_base(&state).await;
7737            if interrupted {
7738                interrupt_landing_rebase(&mut state).await;
7739                state = RunState::load(&state.id).unwrap();
7740            }
7741            state.config.verify.gate =
7742                vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7743            let head = resume_observed_head(&mut state, &old).await.unwrap();
7744            assert_ne!(head, old);
7745            assert!(state.gate_allows_progress());
7746            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7747            assert_eq!(
7748                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7749                "artifact"
7750            );
7751            assert!(state.land_rebase_from.is_none());
7752            assert_eq!(
7753                git::remote_head(&state.repo, "origin", "task-head")
7754                    .await
7755                    .unwrap(),
7756                Some(head.clone())
7757            );
7758            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7759            land_with(&mut state, &green_pr().url, &forge)
7760                .await
7761                .unwrap();
7762            assert!(state.parked);
7763            assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7764            assert!(forge.argvs().is_empty());
7765        }
7766    }
7767
7768    #[tokio::test]
7769    #[cfg(unix)]
7770    async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7771        use std::os::fd::AsRawFd as _;
7772
7773        for interrupted in [false, true] {
7774            let (_tmp, mut state, _) = landing_git_fixture().await;
7775            advance_landing_base(&state).await;
7776            landing_setup(&mut state).await;
7777            if interrupted {
7778                interrupt_landing_rebase(&mut state).await;
7779            }
7780            state.config.worktree.setup =
7781                vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7782            let common = crate::repo_lock::common_dir(&state.repo)
7783                .await
7784                .unwrap()
7785                .unwrap();
7786            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7787            state.config.coordination = crate::config::test_coordination();
7788            state.config.graph.timeout_resource_wait = 0;
7789            let external = std::fs::OpenOptions::new()
7790                .read(true)
7791                .write(true)
7792                .create(true)
7793                .truncate(false)
7794                .open(common.join("updater.lock"))
7795                .unwrap();
7796            // SAFETY: the descriptor belongs to `external` and remains open.
7797            let locked =
7798                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7799            assert_eq!(locked, 0);
7800            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7801            let checkpoint = state.land_rebase_from.clone();
7802            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7803            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7804            assert_eq!(
7805                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7806                before
7807            );
7808            assert_eq!(state.land_rebase_from, checkpoint);
7809            assert_eq!(
7810                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7811                "secret"
7812            );
7813
7814            drop(external);
7815            state.config.graph.timeout_resource_wait = 2;
7816            restore_landing_worktree(&mut state).await.unwrap();
7817            assert!(git::is_clean(&state.repo).await.unwrap());
7818            assert!(state.land_rebase_from.is_none());
7819            assert_eq!(
7820                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7821                "restored"
7822            );
7823        }
7824    }
7825
7826    #[tokio::test]
7827    #[cfg(unix)]
7828    async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7829        use std::sync::atomic::{AtomicBool, Ordering};
7830
7831        let (_tmp, mut state, _) = landing_git_fixture().await;
7832        advance_landing_base(&state).await;
7833        landing_setup(&mut state).await;
7834        interrupt_landing_rebase(&mut state).await;
7835        let common = crate::repo_lock::common_dir(&state.repo)
7836            .await
7837            .unwrap()
7838            .unwrap();
7839        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7840        state.config.coordination = crate::config::test_coordination();
7841        let holder = crate::repo_lock::SeatLease::for_checkout(
7842            &state.repo,
7843            true,
7844            state.config.coordination.as_ref(),
7845        )
7846        .await
7847        .unwrap();
7848        assert!(holder.is_some());
7849        let cancelled = Arc::new(AtomicBool::new(false));
7850        let signal = cancelled.clone();
7851        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7852            signal.load(Ordering::SeqCst)
7853        });
7854        let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7855        let checkpoint = state.land_rebase_from.clone();
7856        let (result, ()) = tokio::join!(
7857            tokio::time::timeout(
7858                Duration::from_secs(2),
7859                wait.scope(restore_landing_worktree(&mut state)),
7860            ),
7861            async {
7862                tokio::time::sleep(Duration::from_millis(100)).await;
7863                cancelled.store(true, Ordering::SeqCst);
7864            }
7865        );
7866        let error = result
7867            .expect("cancellation must release the waiting driver")
7868            .unwrap_err();
7869        assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7870        assert_eq!(
7871            git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7872            before
7873        );
7874        assert_eq!(state.land_rebase_from, checkpoint);
7875        assert_eq!(
7876            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7877            "secret"
7878        );
7879        drop(holder);
7880        restore_landing_worktree(&mut state).await.unwrap();
7881        assert!(state.land_rebase_from.is_none());
7882        assert!(git::is_clean(&state.repo).await.unwrap());
7883    }
7884
7885    #[tokio::test]
7886    #[cfg(unix)]
7887    async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
7888        use std::sync::atomic::{AtomicBool, Ordering};
7889
7890        for e2e in [false, true] {
7891            let (_tmp, mut state, head) = landing_git_fixture().await;
7892            let common = crate::repo_lock::common_dir(&state.repo)
7893                .await
7894                .unwrap()
7895                .unwrap();
7896            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7897            state.config.coordination = crate::config::test_coordination();
7898            if e2e {
7899                state.config.verify.e2e = vec!["true".into()];
7900                state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7901                    head: head.clone(),
7902                    passed: false,
7903                    outcomes: Vec::new(),
7904                });
7905            } else {
7906                state.config.verify.gate = vec!["true".into()];
7907                state.gate_ran = false;
7908            }
7909            let holder = crate::repo_lock::SeatLease::for_checkout(
7910                &state.repo,
7911                true,
7912                state.config.coordination.as_ref(),
7913            )
7914            .await
7915            .unwrap();
7916            assert!(holder.is_some());
7917            let cancelled = Arc::new(AtomicBool::new(false));
7918            let signal = cancelled.clone();
7919            let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7920                signal.load(Ordering::SeqCst)
7921            });
7922            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7923            let url = green_pr().url;
7924            let (result, ()) = tokio::join!(
7925                tokio::time::timeout(
7926                    Duration::from_secs(2),
7927                    wait.scope(publish_landing_head(&mut state, &head, &url, &forge)),
7928                ),
7929                async {
7930                    tokio::time::sleep(Duration::from_millis(100)).await;
7931                    cancelled.store(true, Ordering::SeqCst);
7932                }
7933            );
7934            let error = result
7935                .expect("cancellation must release the verification waiter")
7936                .unwrap_err();
7937            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7938            assert!(forge.argvs().is_empty());
7939            assert_eq!(
7940                git::remote_head(&state.repo, "origin", "task-head")
7941                    .await
7942                    .unwrap(),
7943                Some(head.clone())
7944            );
7945            drop(holder);
7946            // Cancellation must release its reservation so another attempt can verify.
7947            assert_eq!(
7948                publish_landing_head(&mut state, &head, &url, &forge)
7949                    .await
7950                    .unwrap(),
7951                head
7952            );
7953        }
7954    }
7955
7956    #[tokio::test]
7957    async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
7958        for staged in [false, true] {
7959            let (_tmp, mut state, old) = landing_git_fixture().await;
7960            advance_landing_base(&state).await;
7961            interrupt_landing_rebase(&mut state).await;
7962            std::fs::write(state.repo.join("change"), "user edit").unwrap();
7963            if staged {
7964                git::git(&state.repo, &["add", "change"]).await.unwrap();
7965            }
7966            assert!(resume_observed_head(&mut state, &old).await.is_err());
7967            assert_eq!(
7968                std::fs::read_to_string(state.repo.join("change")).unwrap(),
7969                "user edit"
7970            );
7971            assert_eq!(
7972                git::remote_head(&state.repo, "origin", "task-head")
7973                    .await
7974                    .unwrap(),
7975                Some(old)
7976            );
7977        }
7978    }
7979
7980    #[tokio::test]
7981    async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
7982        let (_tmp, mut state, head) = landing_git_fixture().await;
7983        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7984        let id = state.id.clone();
7985        let pr = land_with(&mut state, &green_pr().url, &forge)
7986            .await
7987            .unwrap();
7988        assert_eq!(pr.state, PrLifecycle::Open);
7989        assert_eq!(state.id, id);
7990        assert!(state.parked);
7991        assert_eq!(state.status, RunStatus::Landing);
7992        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7993        assert!(forge.argvs().is_empty());
7994
7995        state.land_sync_pending = true;
7996        std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
7997        let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
7998        closed.pr.state = PrLifecycle::Closed;
7999        let forge = Scripted::new(vec![closed], vec![]);
8000        assert_eq!(
8001            land_with(&mut state, &green_pr().url, &forge)
8002                .await
8003                .unwrap()
8004                .state,
8005            PrLifecycle::Closed
8006        );
8007        assert_eq!(
8008            std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
8009            "do not touch"
8010        );
8011        assert!(forge.argvs().is_empty());
8012    }
8013
8014    #[tokio::test]
8015    async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
8016        let (_tmp, mut state, old) = landing_git_fixture().await;
8017        advance_landing_base(&state).await;
8018        state.config.verify.gate = vec!["exit 1".into()];
8019        assert!(resume_observed_head(&mut state, &old).await.is_err());
8020        assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
8021        assert!(!state.gate_allows_progress());
8022        assert_eq!(
8023            git::remote_head(&state.repo, "origin", "task-head")
8024                .await
8025                .unwrap(),
8026            Some(old)
8027        );
8028        assert!(state.land_sync_pending);
8029    }
8030
8031    #[tokio::test]
8032    async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
8033        let (_tmp, mut state, old) = landing_git_fixture().await;
8034        advance_landing_base(&state).await;
8035        let pushed = resume_observed_head(&mut state, &old).await.unwrap();
8036        // Model a crash after the push but before saving its published SHA.
8037        state.pr_pushed_head = Some(old.clone());
8038        state.land_sync_pending = true;
8039        assert_eq!(
8040            resume_observed_head(&mut state, &pushed).await.unwrap(),
8041            pushed
8042        );
8043        // A writer races the observed PR snapshot: the explicit remote lease
8044        // must reject the push even though preflight saw our expected head.
8045        git::git(
8046            &state.repo,
8047            &["push", "--force", "origin", "main:refs/heads/task-head"],
8048        )
8049        .await
8050        .unwrap();
8051        assert!(
8052            publish_landing_head(
8053                &mut state,
8054                &pushed,
8055                &green_pr().url,
8056                &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
8057            )
8058            .await
8059            .unwrap_err()
8060            .to_string()
8061            .contains("publishing gated")
8062        );
8063        assert!(
8064            resume_observed_head(&mut state, "foreign-head")
8065                .await
8066                .is_err()
8067        );
8068    }
8069    #[tokio::test]
8070    async fn landing_publication_refuses_a_pr_closed_during_gate() {
8071        let (_tmp, mut state, head) = landing_git_fixture().await;
8072        let mut closed = seen(&head, Checks::Green, "CLEAN", false);
8073        closed.pr.state = PrLifecycle::Closed;
8074        let forge = Scripted::new(vec![closed], vec![]);
8075        assert!(
8076            publish_landing_head(&mut state, &head, &green_pr().url, &forge)
8077                .await
8078                .unwrap_err()
8079                .to_string()
8080                .contains("PR closed or merged")
8081        );
8082    }
8083
8084    /// A candidate selected by the uncontested tally.
8085    fn winning_candidate(summary: &str) -> Candidate {
8086        Candidate {
8087            index: 0,
8088            label: 'A',
8089            agent: "opus".to_owned(),
8090            branch: "magi/x/A".to_owned(),
8091            worktree: PathBuf::from("/wt/A"),
8092            summary: summary.to_owned(),
8093            stat: String::new(),
8094            files: 1,
8095            commits: 1,
8096            empty: false,
8097            failed: None,
8098            verified_noop: None,
8099            duration_ms: 0,
8100            folded: false,
8101        }
8102    }
8103
8104    fn uncontested_tally() -> Tally {
8105        Tally {
8106            first_choice: BTreeMap::from([('A', 1)]),
8107            borda: BTreeMap::new(),
8108            winner: 'A',
8109            rankings: 1,
8110            unanimous_initial: true,
8111            deliberated: false,
8112            changed_votes: 0,
8113            unanimous_final: true,
8114            tie_break: None,
8115            judges: 1,
8116            present: 1,
8117            quorum: 1,
8118            met_quorum: true,
8119            uncontested: None,
8120        }
8121    }
8122
8123    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
8124        ReviewRecord {
8125            attempts: 0,
8126            reviewer,
8127            agent: agent.to_owned(),
8128            summary: summary.to_owned(),
8129            findings: Vec::new(),
8130            vote: None,
8131            failed: None,
8132            duration_ms: 0,
8133        }
8134    }
8135
8136    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
8137        let answered = reviews.len();
8138        ReviewRound {
8139            round,
8140            head: "abc1234".to_owned(),
8141            verified_head: None,
8142            verified_at: None,
8143            reviews,
8144            e2e: Vec::new(),
8145            verify_retried: false,
8146            e2e_deferred: false,
8147            e2e_defer_reason: None,
8148            fix: None,
8149            blocking: 0,
8150            answered,
8151            expected: answered,
8152            clean: true,
8153            progressed: false,
8154            vote_split: false,
8155            reconsideration: Vec::new(),
8156            verdict: None,
8157        }
8158    }
8159
8160    #[test]
8161    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
8162        let en = panel();
8163        assert!(en.contains("Task"), "{en}");
8164        assert!(en.contains("add retries to the uploader"), "{en}");
8165
8166        let mut state = run_state();
8167        state.config.graph.language = "ja".to_owned();
8168        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8169        assert!(ja.contains("タスク"), "{ja}");
8170        assert!(
8171            ja.contains("add retries to the uploader"),
8172            "the task itself is not translated: {ja}"
8173        );
8174    }
8175
8176    #[test]
8177    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
8178        // `run_state()` has no candidates, no tally and no reviews - exactly
8179        // the shape a run has before anything has judged or reviewed it, and
8180        // the panel must not print an empty box for either.
8181        let html = panel();
8182        assert!(!html.contains("What changed"), "{html}");
8183        assert!(!html.contains("Review verdict"), "{html}");
8184    }
8185
8186    #[test]
8187    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
8188        let mut state = run_state();
8189        state.candidates = vec![winning_candidate("")];
8190        state.tally = Some(uncontested_tally());
8191        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8192        assert!(
8193            !html.contains("What changed"),
8194            "an empty summary must not render an empty box: {html}"
8195        );
8196    }
8197
8198    #[test]
8199    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
8200        let mut state = run_state();
8201        state.candidates = vec![winning_candidate(
8202            "Added a retry loop around the uploader PUT call.",
8203        )];
8204        state.tally = Some(uncontested_tally());
8205        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8206        assert!(en.contains("What changed"), "{en}");
8207        assert!(
8208            en.contains("Added a retry loop around the uploader PUT call."),
8209            "{en}"
8210        );
8211
8212        state.config.graph.language = "ja".to_owned();
8213        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8214        assert!(ja.contains("変更内容"), "{ja}");
8215        assert!(
8216            ja.contains("Added a retry loop around the uploader PUT call."),
8217            "{ja}"
8218        );
8219    }
8220
8221    #[test]
8222    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
8223        let mut state = run_state();
8224        state.reviews = vec![
8225            review_round(
8226                1,
8227                vec![review_record(1, "alpha", "found a race, sent back")],
8228            ),
8229            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
8230        ];
8231        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8232        assert!(en.contains("Review verdict"), "{en}");
8233        assert!(en.contains("race is fixed, clean"), "{en}");
8234        assert!(
8235            !en.contains("found a race, sent back"),
8236            "only the round that actually cleared the merge should show: {en}"
8237        );
8238
8239        state.config.graph.language = "ja".to_owned();
8240        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8241        assert!(ja.contains("レビューの結論"), "{ja}");
8242        assert!(ja.contains("レビュアー"), "{ja}");
8243        assert!(ja.contains("race is fixed, clean"), "{ja}");
8244    }
8245
8246    /// The `incomplete_review = "warn"` policy (see
8247    /// `graph::Runner::review_loop`) can push a `clean` round to
8248    /// `state.reviews` while one seat's own record still has `failed: Some`
8249    /// and an empty `summary` - a seat that never answered, not one that
8250    /// answered with nothing to say.
8251    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
8252        ReviewRecord {
8253            attempts: 0,
8254            reviewer,
8255            agent: agent.to_owned(),
8256            summary: String::new(),
8257            findings: Vec::new(),
8258            vote: None,
8259            failed: Some(reason.to_owned()),
8260            duration_ms: 0,
8261        }
8262    }
8263
8264    #[test]
8265    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
8266        let mut state = run_state();
8267        state.reviews = vec![review_round(
8268            1,
8269            vec![
8270                review_record(1, "alpha", "clean, nothing to add"),
8271                unanswered_review_record(2, "beta", "timed out"),
8272            ],
8273        )];
8274        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8275        assert!(en.contains("clean, nothing to add"), "{en}");
8276        assert!(
8277            en.contains("produced no answer: timed out"),
8278            "a seat that never answered must say so, not render a blank box: {en}"
8279        );
8280        assert!(
8281            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
8282            "no reviewer box may be left empty: {en}"
8283        );
8284
8285        state.config.graph.language = "ja".to_owned();
8286        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8287        assert!(ja.contains("回答なし: timed out"), "{ja}");
8288    }
8289
8290    #[test]
8291    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
8292        let html = panel();
8293        assert!(!html.contains("<script"), "no script survives the csp");
8294        assert!(!html.contains("<form"), "form-action is 'none'");
8295        let pr = green_pr();
8296        assert_eq!(
8297            html.matches("http").count(),
8298            html.matches(pr.url.as_str()).count(),
8299            "the only http url in the panel is the pull request's own link"
8300        );
8301        assert!(
8302            html.contains("target=\"_blank\""),
8303            "the PR link must open in a new tab so it escapes the iframe"
8304        );
8305        assert!(
8306            html.contains("rel=\"noopener noreferrer\""),
8307            "external links opening in new tabs must carry noopener noreferrer"
8308        );
8309    }
8310
8311    #[test]
8312    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
8313        let html = panel();
8314        assert!(
8315            html.contains(">+</span>"),
8316            "an added line carries a `+` in the gutter, not only a background"
8317        );
8318        assert!(
8319            html.contains(">-</span>"),
8320            "a removed line carries a `-` in the gutter, not only a background"
8321        );
8322        assert!(
8323            html.contains(">new line</span>"),
8324            "the marker is moved to the gutter, so the body is printed once without it"
8325        );
8326    }
8327
8328    #[test]
8329    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
8330        let total = DIFF_MAX_LINES + 100;
8331        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
8332        let html = approval_panel(
8333            &run_state(),
8334            &green_pr(),
8335            NUMSTAT,
8336            &diff,
8337            &[],
8338            "feat: something long",
8339        );
8340        assert!(
8341            html.contains(&format!("100 of {total} diff lines omitted")),
8342            "the note must say exactly how much was cut"
8343        );
8344        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
8345        assert!(
8346            !html.contains(&format!("line {DIFF_MAX_LINES}")),
8347            "nothing past the threshold is rendered"
8348        );
8349        assert!(
8350            html.contains("/repo/magi"),
8351            "the note says where the rest is"
8352        );
8353    }
8354
8355    #[test]
8356    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
8357        let html = approval_panel(
8358            &run_state(),
8359            &green_pr(),
8360            "1\t2\tsrc/<b>&\"x\"'.rs",
8361            "",
8362            &[],
8363            "subject",
8364        );
8365        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
8366        assert!(
8367            !html.contains("<b>"),
8368            "an agent-influenced path must never become markup"
8369        );
8370    }
8371
8372    #[tokio::test]
8373    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
8374        let a = std::path::PathBuf::from("/repo/a");
8375        let b = std::path::PathBuf::from("/repo/b");
8376
8377        let held = repo_merge_lock(&a).lock_owned().await;
8378
8379        // A second, concurrent land run against the *same* repository must
8380        // wait - `try_lock` fails while `held` is alive.
8381        assert!(
8382            repo_merge_lock(&a).try_lock().is_err(),
8383            "a second merge into the same repository must not proceed concurrently"
8384        );
8385
8386        // A run against a *different* repository must not be blocked by it -
8387        // this is what keeps a slow rebase or `gh pr merge` in one
8388        // repository from also stalling a land-approval resume in another.
8389        assert!(
8390            repo_merge_lock(&b).try_lock().is_ok(),
8391            "a different repository's merge lock must be independent"
8392        );
8393
8394        drop(held);
8395        assert!(
8396            repo_merge_lock(&a).try_lock().is_ok(),
8397            "the lock is released once the holder is done"
8398        );
8399    }
8400
8401    pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
8402        let mut state = landing_state();
8403        let mut rec = review_record(1, "alpha", "precise rejection reason");
8404        rec.vote = Some(if reject {
8405            crate::verdict::ReviewVote::Reject
8406        } else {
8407            crate::verdict::ReviewVote::Approve
8408        });
8409        if major {
8410            rec.findings = contested().findings;
8411        }
8412        let mut round = review_round(1, vec![rec]);
8413        round.clean = false;
8414        round.blocking = usize::from(major);
8415        state.reviews.push(round);
8416        state.config.graph.review_rounds = 1;
8417        state
8418    }
8419
8420    #[test]
8421    fn approval_policy_controls_are_independent_and_keep_all_reasons() {
8422        for every in [false, true] {
8423            for contested in [false, true] {
8424                for major in [false, true] {
8425                    for reject in [false, true] {
8426                        let mut state = repair_state(major, reject);
8427                        state.config.graph.land_approval = every;
8428                        state.config.graph.hold_contested_merge = contested;
8429                        let reasons = approval_reasons(&state);
8430                        assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
8431                        assert_eq!(
8432                            reasons.contains(&ApprovalReason::OpenBlockingFindings),
8433                            contested && major
8434                        );
8435                        assert_eq!(
8436                            reasons.contains(&ApprovalReason::FinalReject),
8437                            contested && reject
8438                        );
8439                        assert_eq!(
8440                            reasons.len(),
8441                            usize::from(every)
8442                                + usize::from(contested && major)
8443                                + usize::from(contested && reject)
8444                        );
8445                    }
8446                }
8447            }
8448        }
8449    }
8450
8451    #[test]
8452    fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8453        use crate::verdict::ReviewVote::{Approve, Reject};
8454        for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8455            let mut state = repair_state(false, initial_reject);
8456            state.reviews[0]
8457                .reconsideration
8458                .push(crate::run::ReviewRevoteRecord {
8459                    reviewer: 1,
8460                    agent: "alpha".into(),
8461                    vote: Some(final_vote),
8462                    reason: "changed after reading the panel <evidence>".into(),
8463                    failed: None,
8464                });
8465            assert_eq!(
8466                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8467                final_vote == Reject
8468            );
8469            for language in ["en", "ja"] {
8470                state.config.graph.language = language.into();
8471                let evidence = approval_evidence(&state);
8472                assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8473                assert!(evidence.contains("changed after reading the panel <evidence>"));
8474                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8475                assert!(html.contains("changed after reading the panel &lt;evidence&gt;"));
8476                assert!(!html.contains("<evidence>"));
8477            }
8478            state.reviews[0].reconsideration[0].vote = None;
8479            state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8480            assert_eq!(
8481                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8482                initial_reject
8483            );
8484            assert!(approval_evidence(&state).contains("timed out"));
8485        }
8486    }
8487
8488    #[tokio::test]
8489    async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8490        for every in [false, true] {
8491            for contested in [false, true] {
8492                for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8493                {
8494                    let mut state = repair_state(major, reject);
8495                    state.config.graph.land_approval = every;
8496                    state.config.graph.hold_contested_merge = contested;
8497                    let forge = Scripted::new(
8498                        vec![seen("abc", Checks::Green, "CLEAN", false)],
8499                        vec![(true, "")],
8500                    );
8501                    land_with(&mut state, URL, &forge).await.unwrap();
8502                    let expected = every || (contested && (major || reject));
8503                    assert_eq!(state.land_approval.is_some(), expected);
8504                    assert_eq!(forge.calls().contains(&"merge"), !expected);
8505                    if expected {
8506                        assert!(state.parked);
8507                    }
8508                }
8509            }
8510        }
8511    }
8512
8513    #[tokio::test]
8514    async fn reviewed_branch_hold_is_read_before_landing_decision() {
8515        for (base, control) in [
8516            (false, "land_approval"),
8517            (true, "land_approval"),
8518            (false, "hold_contested_merge"),
8519        ] {
8520            let (_tmp, mut state, _) = landing_git_fixture().await;
8521            state.config.graph.land_approval = base;
8522            state.config.graph.hold_contested_merge = false;
8523            state.reviewed_commits = Some(Vec::new());
8524            if control == "hold_contested_merge" {
8525                state.reviews = repair_state(false, true).reviews;
8526            }
8527            std::fs::write(
8528                state.repo.join("magi.toml"),
8529                format!("[graph]\n{control} = {}\n", !base),
8530            )
8531            .unwrap();
8532            git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8533            git::git(&state.repo, &["commit", "-m", "approval policy"])
8534                .await
8535                .unwrap();
8536            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8537            state.land_sync_pending = false;
8538            state.pr_pushed_head = Some(head.clone());
8539            state.gate_head = Some(head.clone());
8540            let forge = Scripted::new(
8541                vec![seen(&head, Checks::Green, "CLEAN", false)],
8542                vec![(true, "")],
8543            );
8544            land_with(&mut state, URL, &forge).await.unwrap();
8545            assert!(
8546                state.land_approval.is_some(),
8547                "base={base}, control={control}"
8548            );
8549            assert!(!forge.calls().contains(&"merge"));
8550            if !base {
8551                let question = ask::Questions::open()
8552                    .get(&state.land_approval.as_ref().unwrap().question)
8553                    .unwrap();
8554                assert!(question.detail.contains(&format!(
8555                    "{control}=true from the reviewed branch's magi.toml"
8556                )));
8557            }
8558        }
8559    }
8560
8561    #[tokio::test]
8562    async fn reviewed_branch_hold_asks_and_survives_removal_and_resume() {
8563        let (_tmp, mut state, _) = landing_git_fixture().await;
8564        state.config.graph.land_approval = false;
8565        state.config.graph.hold_contested_merge = false;
8566        state.reviewed_commits = Some(Vec::new());
8567        std::fs::write(
8568            state.repo.join("magi.toml"),
8569            "[graph]\nland_approval = true\n[verify]\ngate = ['untrusted']\n",
8570        )
8571        .unwrap();
8572        git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8573        git::git(&state.repo, &["commit", "-m", "require approval"])
8574            .await
8575            .unwrap();
8576        let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8577        let gate = state.config.verify.gate.clone();
8578        state.observe_branch_owner_holds(&head).await;
8579        assert_eq!(state.config.verify.gate, gate);
8580        assert_eq!(approval_reasons(&state), [ApprovalReason::EveryMerge]);
8581        git::git(&state.repo, &["rm", "magi.toml"]).await.unwrap();
8582        git::git(&state.repo, &["commit", "-m", "remove config"])
8583            .await
8584            .unwrap();
8585        let later = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8586        state = serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
8587        state.observe_branch_owner_holds(&later).await;
8588        assert_eq!(state.branch_owner_holds[0].head, head);
8589        state.land_sync_pending = false;
8590        state.pr_pushed_head = Some(later.clone());
8591        state.gate_head = Some(later.clone());
8592        let forge = Scripted::new(
8593            vec![seen(&later, Checks::Green, "CLEAN", false)],
8594            vec![(true, "")],
8595        );
8596        land_with(&mut state, URL, &forge).await.unwrap();
8597        assert!(!forge.calls().contains(&"merge"));
8598        let question = ask::Questions::open()
8599            .get(&state.land_approval.as_ref().unwrap().question)
8600            .unwrap();
8601        assert!(question.detail.contains("Approval required because:"));
8602        assert!(
8603            question
8604                .detail
8605                .contains("land_approval=true from the reviewed branch's magi.toml")
8606        );
8607    }
8608
8609    #[tokio::test]
8610    async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8611        let mut state = repair_state(false, true);
8612        state.reviews[0].clean = true;
8613        let mut finding = contested().findings.remove(0);
8614        finding.severity = crate::verdict::Severity::Minor;
8615        state.reviews[0].reviews[0].findings.push(finding);
8616        assert!(state.open_findings().is_empty());
8617        let mut pr = green_pr();
8618        pr.review_comments.clear();
8619        for language in ["en", "ja"] {
8620            state.config.graph.language = language.into();
8621            let html = approval_panel(&state, &pr, "", "", &[], "subject");
8622            assert!(html.contains("[R3-1-1] minor"));
8623            assert!(html.contains(if language == "ja" {
8624                "内部指摘の解決を意味するものではありません"
8625            } else {
8626                "This does not mean internal findings are resolved"
8627            }));
8628            assert!(
8629                html.find(if language == "ja" {
8630                    "承認が必要な理由:"
8631                } else {
8632                    "Approval required because:"
8633                })
8634                .unwrap()
8635                    < html.find("[R3-1-1]").unwrap()
8636            );
8637        }
8638        state.config.graph.language = "en".into();
8639        let observed = seen("abc", Checks::Green, "CLEAN", false);
8640        assert_eq!(
8641            approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8642                .await
8643                .unwrap(),
8644            ApprovalGate::Pending
8645        );
8646        let id = state.land_approval.as_ref().unwrap().question.clone();
8647        let q = ask::Questions::open().get(&id).unwrap();
8648        assert!(q.detail.starts_with("Approval required because:"));
8649        assert!(q.detail.contains("final reviewer reject"));
8650        assert!(q.detail.contains("[R3-1-1] minor"));
8651        assert!(
8652            q.detail
8653                .contains("No adoption or resolution evidence recorded")
8654        );
8655        state.config.graph.land_approval = false;
8656        state.config.graph.hold_contested_merge = false;
8657        state.reviews.clear();
8658        assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8659        assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8660        assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8661        let forge = Scripted::new(vec![observed], vec![]);
8662        land_with(&mut state, URL, &forge).await.unwrap();
8663        assert!(!forge.calls().contains(&"merge"));
8664        assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8665        assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8666    }
8667
8668    #[tokio::test]
8669    async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8670        let mut state = repair_state(false, true);
8671        state.config.graph.land_approval = false;
8672        let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8673        let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8674        let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8675        state.reviews[0].reviews[0].summary = long_reason.clone();
8676        for i in 1..=10 {
8677            let mut finding = contested().findings.remove(0);
8678            finding.id = format!("R1-1-{i}");
8679            finding.severity = if i == 9 {
8680                crate::verdict::Severity::Major
8681            } else {
8682                crate::verdict::Severity::Minor
8683            };
8684            finding.title = long_title.clone();
8685            finding.detail = long_detail.clone();
8686            state.reviews[0].reviews[0].findings.push(finding);
8687        }
8688        state.reviews[0].fix = Some(crate::run::FixRecord {
8689            agent: "fixer".into(),
8690            addressed: vec!["R1-1-9".into()],
8691            rejected: vec![crate::verdict::Rejection {
8692                id: "R1-1-10".into(),
8693                why: long_reason.clone(),
8694            }],
8695            notes: long_detail.clone(),
8696            committed: false,
8697            failed: None,
8698            duration_ms: 0,
8699            continuation: None,
8700        });
8701        assert_eq!(
8702            approval_reasons(&state),
8703            [
8704                ApprovalReason::OpenBlockingFindings,
8705                ApprovalReason::FinalReject
8706            ]
8707        );
8708        let excerpt = approval_excerpt(&long_reason, 300);
8709        assert_eq!(excerpt.chars().count(), 300);
8710        assert!(excerpt.ends_with('…'));
8711        let boundary = "拒".repeat(300);
8712        assert_eq!(approval_excerpt(&boundary, 300), boundary);
8713        for language in ["en", "ja"] {
8714            state.config.graph.language = language.into();
8715            for revote in [
8716                None,
8717                Some(crate::verdict::ReviewVote::Reject),
8718                Some(crate::verdict::ReviewVote::Approve),
8719            ] {
8720                state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8721                state.reviews[0].reconsideration.clear();
8722                if let Some(vote) = revote {
8723                    state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8724                    state.reviews[0]
8725                        .reconsideration
8726                        .push(crate::run::ReviewRevoteRecord {
8727                            reviewer: 1,
8728                            agent: "alpha".into(),
8729                            vote: Some(vote),
8730                            reason: long_reason.clone(),
8731                            failed: None,
8732                        });
8733                }
8734                let evidence = approval_evidence(&state);
8735                let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8736                assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8737                assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8738                assert_eq!(
8739                    approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8740                    final_vote == crate::verdict::ReviewVote::Reject
8741                );
8742                assert!(evidence.contains(&excerpt));
8743                assert!(evidence.contains("[R1-1-8] minor"));
8744                assert!(evidence.contains("[R1-1-9] major"));
8745                assert!(evidence.contains("[R1-1-10] minor"));
8746                for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8747                    assert!(!evidence.contains(tail));
8748                }
8749                assert!(evidence.contains(if language == "ja" {
8750                    "採用申告(解決未確認)"
8751                } else {
8752                    "claimed addressed (resolution unconfirmed)"
8753                }));
8754                assert!(evidence.contains(if language == "ja" {
8755                    "不採用理由"
8756                } else {
8757                    "declined"
8758                }));
8759                let major_evidence = evidence
8760                    .split("[R1-1-9] major")
8761                    .nth(1)
8762                    .unwrap()
8763                    .split("[R1-1-10]")
8764                    .next()
8765                    .unwrap();
8766                assert!(major_evidence.contains(if language == "ja" {
8767                    "採用申告(解決未確認)"
8768                } else {
8769                    "claimed addressed (resolution unconfirmed)"
8770                }));
8771                let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8772                assert!(last_evidence.contains(if language == "ja" {
8773                    "不採用理由"
8774                } else {
8775                    "declined"
8776                }));
8777                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8778                assert!(html.contains(&esc(&evidence)));
8779                assert_eq!(
8780                    approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8781                        .await
8782                        .unwrap(),
8783                    ApprovalGate::Pending
8784                );
8785                let id = &state.land_approval.take().unwrap().question;
8786                let q = ask::Questions::open().get(id).unwrap();
8787                assert!(q.detail.starts_with(&evidence));
8788            }
8789            state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8790        }
8791    }
8792
8793    #[test]
8794    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8795        for (major, reject) in [(true, false), (false, true), (true, true)] {
8796            let state = repair_state(major, reject);
8797            assert!(repair_available(&state));
8798            assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8799        }
8800        assert!(!repair_available(&repair_state(false, false)));
8801        let mut clean = repair_state(true, false);
8802        clean.reviews[0].clean = true;
8803        assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8804        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8805    }
8806
8807    #[tokio::test]
8808    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8809        let mut state = repair_state(true, false);
8810        let pr = green_pr();
8811        approval_gate(&mut state, &pr, "feat: x", None, "abc")
8812            .await
8813            .unwrap();
8814        let store = ask::Questions::open();
8815        let id = state.land_approval.as_ref().unwrap().question.clone();
8816        // Simulate an unanswered two-choice question created by an older build.
8817        store
8818            .update(&id, |q| {
8819                q.choices = vec![APPROVE.into(), HOLD.into()];
8820                Ok(())
8821            })
8822            .unwrap();
8823        let upgraded = store.get(&id).unwrap();
8824        assert_eq!(upgraded.choices, approval_choices(&state));
8825        assert!(!upgraded.actions.contains_key(HOLD));
8826        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
8827        assert!(
8828            store
8829                .list()
8830                .iter()
8831                .any(|q| q.id == id && q.choices == upgraded.choices)
8832        );
8833        assert!(
8834            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
8835        );
8836        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
8837        store
8838            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
8839            .unwrap();
8840        // An earlier build may have recorded the repair answer and stopped
8841        // before any driver consumed it. Reconcile that durable decision too.
8842        store
8843            .update(&id, |q| {
8844                q.actions.clear();
8845                Ok(())
8846            })
8847            .unwrap();
8848        assert_eq!(
8849            store.get(&id).unwrap().chosen_action(),
8850            Some(&ask::ChoiceAction::Resume {
8851                run: state.id.clone()
8852            })
8853        );
8854        let binding = state.land_approval.clone();
8855        assert_eq!(
8856            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8857                .await
8858                .unwrap(),
8859            ApprovalGate::Repair
8860        );
8861        assert!(state.land_approval.is_none());
8862        assert_eq!(state.merge_repairs.len(), 1);
8863        let request = &state.merge_repairs[0];
8864        assert_eq!(request.question, id);
8865        assert_eq!(request.head, "abc");
8866        assert_eq!(request.pr, pr.url);
8867        assert_eq!(
8868            request.reviews[0].reviews[0].summary,
8869            "precise rejection reason"
8870        );
8871        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
8872        assert_eq!(
8873            request.reviews[0].reviews[0].findings[0].detail,
8874            state.reviews[0].reviews[0].findings[0].detail
8875        );
8876        // A repeated consumption of the same answer never dispatches another repair.
8877        state.land_approval = binding;
8878        assert_eq!(
8879            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8880                .await
8881                .unwrap(),
8882            ApprovalGate::Repair
8883        );
8884        assert_eq!(state.merge_repairs.len(), 1);
8885        state.land_approval = None;
8886        state.config.graph.land_approval = false;
8887        state.config.graph.hold_contested_merge = false;
8888        state.reviews.clear();
8889        assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
8890        assert_eq!(
8891            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8892                .await
8893                .unwrap(),
8894            ApprovalGate::Pending
8895        );
8896        let fresh_id = &state.land_approval.as_ref().unwrap().question;
8897        assert_ne!(fresh_id, &id);
8898        let fresh = store.get(fresh_id).unwrap();
8899        assert!(
8900            fresh
8901                .detail
8902                .contains("fresh owner approval after merge repair")
8903        );
8904        assert!(fresh.detail.contains("No review recorded"));
8905        assert!(!fresh.detail.contains("precise rejection reason"));
8906    }
8907
8908    #[tokio::test]
8909    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
8910        for choice in [REPAIR, HOLD] {
8911            let mut state = repair_state(false, true);
8912            let observed = seen("abc", Checks::Green, "CLEAN", false);
8913            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
8914                .await
8915                .unwrap();
8916            let id = state.land_approval.as_ref().unwrap().question.clone();
8917            ask::Questions::open()
8918                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
8919                .unwrap();
8920            let forge = Scripted::new(vec![observed], vec![(true, "")]);
8921            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8922                .await
8923                .unwrap();
8924            assert!(
8925                forge
8926                    .argvs()
8927                    .iter()
8928                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
8929            );
8930            assert!(!forge.calls().contains(&"fix"));
8931            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
8932            assert_ne!(state.status, RunStatus::Merged);
8933        }
8934    }
8935
8936    #[test]
8937    fn only_the_merge_choice_merges_and_silence_holds() {
8938        let table = [
8939            (None, Approval::Hold),
8940            (Some("merge"), Approval::Merge),
8941            (Some(" merge\n"), Approval::Merge),
8942            (Some("hold"), Approval::Hold),
8943            (Some(""), Approval::Hold),
8944            (Some("yes"), Approval::Hold),
8945        ];
8946        for (answer, want) in table {
8947            assert_eq!(
8948                approval(answer),
8949                want,
8950                "answer {answer:?} must resolve to {want:?}"
8951            );
8952        }
8953    }
8954
8955    #[tokio::test]
8956    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
8957        let mut state = landing_state();
8958        state.config.graph.land_approval = true;
8959        let pr = green_pr();
8960
8961        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8962            .await
8963            .unwrap();
8964        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
8965        assert!(
8966            !state.parked,
8967            "approval_gate itself never sets `parked`; only its caller does"
8968        );
8969
8970        let store = ask::Questions::open();
8971        let filed: Vec<_> = store
8972            .list()
8973            .into_iter()
8974            .filter(|q| q.run == state.id)
8975            .collect();
8976        assert_eq!(filed.len(), 1, "exactly one question is filed");
8977        assert_eq!(filed[0].node, APPROVAL_NODE);
8978        assert_eq!(filed[0].choices, approval_choices(&state));
8979        assert!(filed[0].status.open());
8980
8981        // A second visit - standing in for a resumed run whose slot the
8982        // daemon handed to something else while nobody had answered - must
8983        // find the same question rather than filing a second one.
8984        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8985            .await
8986            .unwrap();
8987        assert_eq!(again, ApprovalGate::Pending);
8988        let still_one = store
8989            .list()
8990            .into_iter()
8991            .filter(|q| q.run == state.id)
8992            .count();
8993        assert_eq!(
8994            still_one, 1,
8995            "asking twice must not double-file the question"
8996        );
8997    }
8998
8999    #[tokio::test]
9000    async fn approving_the_existing_question_is_read_back_as_approved() {
9001        crate::run::pin_test_home();
9002        let mut state = run_state();
9003        state.config.graph.land_approval = true;
9004        let pr = green_pr();
9005        assert_eq!(
9006            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9007                .await
9008                .unwrap(),
9009            ApprovalGate::Pending
9010        );
9011
9012        let store = ask::Questions::open();
9013        let mut q = store
9014            .list()
9015            .into_iter()
9016            .find(|q| q.run == state.id)
9017            .expect("filed above");
9018        let talks = crate::talk::Talks::open();
9019        let cfg = crate::config::Config {
9020            agents: vec![crate::config::AgentSpec {
9021                id: "mock".into(),
9022                kind: crate::config::AgentKind::Command,
9023                model: None,
9024                command: vec!["true".into()],
9025                extra_args: Vec::new(),
9026                env: Default::default(),
9027                prompt_delivery: None,
9028                max_turn_secs: None,
9029            }],
9030            ..crate::config::Config::default()
9031        };
9032        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
9033        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
9034        crate::talk::drain(&mut talk, &talks).unwrap();
9035        assert_eq!(
9036            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9037                .await
9038                .unwrap(),
9039            ApprovalGate::Pending,
9040            "consultation cannot approve or hold the merge"
9041        );
9042        q = store.get(&q.id).unwrap();
9043        assert!(q.answer.is_none());
9044        talk.turns.push(crate::talk::Turn {
9045            sender: None,
9046            breaks: Some(Vec::new()),
9047            messages: Vec::new(),
9048            posted_by: None,
9049            who: crate::talk::Who::Operator,
9050            body: "merge abc".into(),
9051            at: Timestamp::now(),
9052            attachments: Vec::new(),
9053            usage: None,
9054        });
9055        talks.put(&mut talk).unwrap();
9056        crate::consult::validate_answer(
9057            &q,
9058            &talks,
9059            &talk.id,
9060            crate::queue::CHAT_NODE,
9061            APPROVE,
9062            Some("merge abc"),
9063        )
9064        .unwrap();
9065        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9066        store.put(&mut q).unwrap();
9067
9068        assert_eq!(
9069            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9070                .await
9071                .unwrap(),
9072            ApprovalGate::Approved
9073        );
9074    }
9075
9076    #[tokio::test]
9077    async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
9078        crate::run::pin_test_home();
9079        let store = ask::Questions::open();
9080        for via_list in [false, true] {
9081            let mut state = repair_state(false, true);
9082            let pr = green_pr();
9083            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9084                .await
9085                .unwrap();
9086            let id = state.land_approval.as_ref().unwrap().question.clone();
9087            store
9088                .update(&id, |q| {
9089                    q.actions.insert(
9090                        HOLD.to_owned(),
9091                        ask::ChoiceAction::Resume {
9092                            run: state.id.clone(),
9093                        },
9094                    );
9095                    Ok(())
9096                })
9097                .unwrap();
9098            let q = if via_list {
9099                store.list().into_iter().find(|q| q.id == id).unwrap()
9100            } else {
9101                store.get(&id).unwrap()
9102            };
9103            assert!(!q.actions.contains_key(HOLD));
9104            store
9105                .update(&id, |q| {
9106                    // Check the persisted migration, without another read upgrade.
9107                    assert!(!q.actions.contains_key(HOLD));
9108                    assert!(!upgrade_repair_choice(q, &state));
9109                    q.answer(ask::Answer::Choice(HOLD.to_owned()))
9110                })
9111                .unwrap();
9112            assert!(store.get(&id).unwrap().chosen_action().is_none());
9113            assert_eq!(
9114                approval_gate(&mut state, &pr, "feat: x", None, "abc")
9115                    .await
9116                    .unwrap(),
9117                ApprovalGate::Held
9118            );
9119        }
9120    }
9121
9122    #[tokio::test]
9123    async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
9124        crate::run::pin_test_home();
9125        let store = ask::Questions::open();
9126
9127        let mut held_state = run_state();
9128        held_state.config.graph.land_approval = true;
9129        let pr = green_pr();
9130        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9131            .await
9132            .unwrap();
9133        let mut q = store
9134            .list()
9135            .into_iter()
9136            .find(|q| q.run == held_state.id)
9137            .expect("filed above");
9138        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
9139        store.put(&mut q).unwrap();
9140        assert_eq!(
9141            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9142                .await
9143                .unwrap(),
9144            ApprovalGate::Held
9145        );
9146
9147        let mut abandoned_state = run_state();
9148        abandoned_state.config.graph.land_approval = true;
9149        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9150            .await
9151            .unwrap();
9152        let mut q = store
9153            .list()
9154            .into_iter()
9155            .find(|q| q.run == abandoned_state.id)
9156            .expect("filed above");
9157        q.abandon("no answer within the timeout");
9158        store.put(&mut q).unwrap();
9159        assert_eq!(
9160            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9161                .await
9162                .unwrap(),
9163            ApprovalGate::Pending,
9164            "an expired approval must ask again without merging"
9165        );
9166        assert_eq!(
9167            store.get(&q.id).unwrap().status,
9168            ask::QuestionStatus::Abandoned
9169        );
9170        assert_ne!(
9171            abandoned_state.land_approval.as_ref().unwrap().question,
9172            q.id
9173        );
9174    }
9175
9176    fn contested() -> ContestedHandoff {
9177        let finding = |id: &str, n: u32| crate::verdict::Finding {
9178            id: id.to_owned(),
9179            severity: crate::verdict::Severity::Major,
9180            file: Some("src/a.rs".to_owned()),
9181            line: Some(n),
9182            title: format!("problem {id}"),
9183            detail: String::new(),
9184        };
9185        ContestedHandoff {
9186            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
9187            rejecters: vec![(1, "alpha".to_owned())],
9188        }
9189    }
9190
9191    #[test]
9192    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
9193        let mut state = run_state();
9194        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
9195        state.contested_handoff = Some(contested());
9196        assert!(contested_to_ask(&state).is_some());
9197        state.config.graph.hold_contested_merge = false;
9198        assert!(
9199            contested_to_ask(&state).is_none(),
9200            "the switch restores today"
9201        );
9202    }
9203
9204    #[test]
9205    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
9206        let q = ask::Question::new(
9207            "run-1".to_owned(),
9208            APPROVAL_NODE.to_owned(),
9209            "land".to_owned(),
9210            "Merge?".to_owned(),
9211            String::new(),
9212            vec![APPROVE.to_owned(), HOLD.to_owned()],
9213        );
9214        let none = deputy_brief(&q, None);
9215        assert!(none.contains("could not be read"), "{none}");
9216        assert!(none.contains("Silence is a hold"), "{none}");
9217
9218        let mut state = run_state();
9219        state.pr = Some(crate::run::PrRecord {
9220            url: "https://example.test/pull/7".to_owned(),
9221            number: 7,
9222            state: "open".to_owned(),
9223            checks: "green".to_owned(),
9224            round: 0,
9225            rounds: 3,
9226            red_at_merge: Vec::new(),
9227        });
9228        state.contested_handoff = Some(contested());
9229        let b = deputy_brief(&q, Some(&state));
9230        assert!(b.contains("https://example.test/pull/7"), "{b}");
9231        assert!(b.contains("not recorded in this legacy question"), "{b}");
9232        assert!(!b.contains("This question was filed although"));
9233        state.contested_handoff = None;
9234        assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
9235    }
9236
9237    #[test]
9238    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
9239        for lang in ["en", "ja"] {
9240            let mut cfg = crate::config::Config::default();
9241            cfg.graph.language = lang.to_owned();
9242            let w = words(&cfg.graph.language);
9243            let text = w.approval_detail(
9244                "https://github.com/yukimemi/magi/pull/42",
9245                "main",
9246                "feat: x",
9247                Some(&contested()),
9248            );
9249            assert!(text.contains("pull/42"), "{text}");
9250            assert!(
9251                text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
9252                "{text}"
9253            );
9254            assert!(text.contains("R3-1-5"), "{text}");
9255            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
9256            assert!(text.contains("2"), "the rest are counted: {text}");
9257            assert!(text.contains("#1 (alpha)"), "{text}");
9258        }
9259        let plain = words("en").approval_detail("u", "main", "s", None);
9260        assert!(!plain.contains("reject"), "{plain}");
9261    }
9262
9263    #[tokio::test]
9264    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
9265        crate::run::pin_test_home();
9266        let mut state = run_state();
9267        state.config.graph.land_approval = false;
9268        state.contested_handoff = Some(contested());
9269        let pr = green_pr();
9270        let c = contested_to_ask(&state);
9271        assert_eq!(
9272            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9273                .await
9274                .unwrap(),
9275            ApprovalGate::Pending,
9276            "silence is a hold"
9277        );
9278        let store = ask::Questions::open();
9279        let filed: Vec<_> = store
9280            .list()
9281            .into_iter()
9282            .filter(|q| q.run == state.id)
9283            .collect();
9284        assert_eq!(filed.len(), 1);
9285        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
9286
9287        assert_eq!(
9288            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9289                .await
9290                .unwrap(),
9291            ApprovalGate::Pending
9292        );
9293        let mut q = store
9294            .list()
9295            .into_iter()
9296            .find(|q| q.run == state.id)
9297            .unwrap();
9298        assert_eq!(q.id, filed[0].id, "the same question after a resume");
9299        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9300        store.put(&mut q).unwrap();
9301        assert_eq!(
9302            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9303                .await
9304                .unwrap(),
9305            ApprovalGate::Approved
9306        );
9307    }
9308
9309    #[test]
9310    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
9311        let rows = parse_numstat(NUMSTAT);
9312        assert_eq!(
9313            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
9314            ["src/web.rs", "src/land.rs", "assets/logo.png"]
9315        );
9316        assert_eq!(rows[2].added, None, "a binary file has no line counts");
9317    }
9318    #[test]
9319    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
9320        // Reported from a real run: the merge question arrived in English on a
9321        // repository with `language = "ja"`. magi's own strings have to follow
9322        // that setting too - "it is a literal in Rust" is not an answer.
9323        let mut state = run_state();
9324        state.config.graph.language = "ja".to_owned();
9325        let pr = green_pr();
9326        let commits = ["c1".to_owned()];
9327
9328        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9329        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
9330        assert!(ja.contains("squash されるコミット"), "{ja}");
9331        assert!(ja.contains("レビューコメント"), "{ja}");
9332        assert!(ja.contains("差分"), "{ja}");
9333        assert!(
9334            !ja.contains("Commits being squashed"),
9335            "no English left over"
9336        );
9337
9338        let w = words("ja");
9339        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
9340        assert!(
9341            w.approval_detail("http://x/1", "main", "feat: x", None)
9342                .contains("パネル")
9343        );
9344
9345        // The evidence itself is language-neutral and must survive either way.
9346        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
9347        assert!(ja.contains("feat: x"), "nor is the merge subject");
9348
9349        // English stays the default, and a language magi cannot check falls
9350        // back to it rather than shipping a guess.
9351        state.config.graph.language = "en".to_owned();
9352        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9353        assert!(en.contains("Commits being squashed"), "{en}");
9354        assert_eq!(words("Klingon").html_lang, "en");
9355    }
9356
9357    /// A `gh pr list` result naming exactly one pull request whose base and
9358    /// merge time both fit the run is exactly the case
9359    /// [`find_external_merge`] exists to act on.
9360    #[test]
9361    fn pick_open_pr_classifies_by_count_and_base() {
9362        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
9363        assert_eq!(
9364            pick_open_pr(one, "main").unwrap(),
9365            OpenPr::One {
9366                url: "https://x/pull/58".into(),
9367                title: "t".into()
9368            }
9369        );
9370        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
9371        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
9372        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
9373                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
9374        assert_eq!(
9375            pick_open_pr(two, "main").unwrap(),
9376            OpenPr::Many(vec!["u1".into(), "u2".into()])
9377        );
9378        // A fork's same-named branch is not a pull request for this head.
9379        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
9380        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
9381        assert!(pick_open_pr("not json", "main").is_err());
9382        // An incomplete record is an error, never "nothing open".
9383        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
9384        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
9385    }
9386
9387    #[test]
9388    fn pick_merged_pr_picks_the_unique_match() {
9389        let json = r#"[
9390            {"url": "https://github.com/o/r/pull/42", "number": 42,
9391             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
9392        ]"#;
9393        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9394        let found = pick_merged_pr(json, "main", created_at)
9395            .expect("valid json")
9396            .expect("one unambiguous match");
9397        assert_eq!(found.url, "https://github.com/o/r/pull/42");
9398        assert_eq!(found.number, 42);
9399    }
9400
9401    /// Two candidates surviving the filter is exactly as uninformative as
9402    /// zero — a branch name can be reused across runs — so neither is
9403    /// preferred over the other and nothing is recorded automatically.
9404    #[test]
9405    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
9406        let json = r#"[
9407            {"url": "https://github.com/o/r/pull/42", "number": 42,
9408             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
9409            {"url": "https://github.com/o/r/pull/43", "number": 43,
9410             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
9411        ]"#;
9412        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9413        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9414    }
9415
9416    /// A pull request that targets a different base branch cannot be this
9417    /// run's, whatever its head branch is named — a reused branch name from
9418    /// an unrelated task must not be recorded as this run's merge.
9419    #[test]
9420    fn pick_merged_pr_ignores_a_different_base_branch() {
9421        let json = r#"[
9422            {"url": "https://github.com/o/r/pull/42", "number": 42,
9423             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
9424        ]"#;
9425        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9426        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9427    }
9428
9429    /// A pull request merged before this run was even created cannot be this
9430    /// run's winner, no matter how its head branch is spelled.
9431    #[test]
9432    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
9433        let json = r#"[
9434            {"url": "https://github.com/o/r/pull/42", "number": 42,
9435             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
9436        ]"#;
9437        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9438        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9439    }
9440
9441    #[test]
9442    fn slug_of_pr_url_reads_host_owner_and_repo() {
9443        assert_eq!(
9444            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
9445            Some("github.com/yukimemi/shun")
9446        );
9447    }
9448
9449    #[test]
9450    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
9451        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
9452        assert_eq!(slug_of_pr_url("not a url at all"), None);
9453        assert_eq!(slug_of_pr_url("https://github.com"), None);
9454    }
9455
9456    #[test]
9457    fn slug_of_repo_url_reads_host_owner_and_repo() {
9458        assert_eq!(
9459            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
9460            Some("github.com/yukimemi/magi")
9461        );
9462        assert_eq!(slug_of_repo_url("https://github.com"), None);
9463    }
9464
9465    #[test]
9466    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
9467        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
9468            .expect("same repo, different case");
9469    }
9470
9471    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
9472    /// own in-progress run and rewrote its status from a pull request in a
9473    /// completely different repository. This is the guard that must catch
9474    /// that even when an explicit (but wrong) id is given.
9475    #[test]
9476    fn ensure_same_repo_refuses_a_different_repo() {
9477        let err =
9478            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
9479        let msg = format!("{err:#}");
9480        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9481        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
9482    }
9483
9484    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
9485    /// instance mirroring a `github.com` repository's name, say) must not be
9486    /// treated as the same repository just because the trailing path
9487    /// matches.
9488    #[test]
9489    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
9490        let err = ensure_same_repo(
9491            "github.com/yukimemi/magi",
9492            "github.example.com/yukimemi/magi",
9493        )
9494        .unwrap_err();
9495        let msg = format!("{err:#}");
9496        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9497        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
9498    }
9499
9500    /// No winner decided yet means there is no branch to ask GitHub about at
9501    /// all — `find_external_merge` must return `None` without ever spawning
9502    /// `gh`, which this proves by never providing a real repository to spawn
9503    /// it in.
9504    #[tokio::test]
9505    async fn find_external_merge_returns_none_without_a_winner() {
9506        let state = RunState::new(
9507            PathBuf::from("/no/such/repo"),
9508            "main".to_owned(),
9509            "0000000000000000000000000000000000000000".to_owned(),
9510            "irrelevant".to_owned(),
9511            crate::config::Config::default(),
9512        );
9513        assert_eq!(find_external_merge(&state).await.unwrap(), None);
9514    }
9515
9516    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
9517        let mut run = RunState::new(
9518            PathBuf::from(repo),
9519            "main".to_owned(),
9520            "abcdef1234".to_owned(),
9521            "x".to_owned(),
9522            crate::config::Config::default(),
9523        );
9524        run.id = id.to_owned();
9525        run.status = status;
9526        run.driver_exited = true;
9527        run.pr = Some(crate::run::PrRecord {
9528            number: url.rsplit('/').next().unwrap().parse().unwrap(),
9529            url: url.to_owned(),
9530            state: state.to_owned(),
9531            checks: "red".to_owned(),
9532            round: 0,
9533            rounds: 2,
9534            red_at_merge: Vec::new(),
9535        });
9536        run.save_under(home).unwrap();
9537    }
9538
9539    fn recorded(home: &Path, id: &str) -> String {
9540        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9541        serde_json::from_str::<RunState>(&body)
9542            .unwrap()
9543            .pr
9544            .unwrap()
9545            .state
9546    }
9547
9548    const PR: &str = "https://github.com/o/r/pull/7";
9549
9550    #[test]
9551    fn write_through_updates_predecessors_and_siblings_only() {
9552        let tmp = tempfile::tempdir().unwrap();
9553        let h = tmp.path();
9554        pr_run(
9555            h,
9556            "20261004-100000-aaaa",
9557            "/repo/r",
9558            RunStatus::Superseded,
9559            PR,
9560            "open",
9561        );
9562        pr_run(
9563            h,
9564            "20261004-100100-bbbb",
9565            "/repo/r",
9566            RunStatus::Blocked,
9567            PR,
9568            "open",
9569        );
9570        // Not terminal: a driver may be writing it.
9571        pr_run(
9572            h,
9573            "20261004-100200-cccc",
9574            "/repo/r",
9575            RunStatus::Landing,
9576            PR,
9577            "open",
9578        );
9579        // Another repository's pull request with the same number.
9580        pr_run(
9581            h,
9582            "20261004-100300-dddd",
9583            "/repo/other",
9584            RunStatus::Blocked,
9585            "https://github.com/o/other/pull/7",
9586            "open",
9587        );
9588        // A different pull request of the same repository.
9589        pr_run(
9590            h,
9591            "20261004-100400-eeee",
9592            "/repo/r",
9593            RunStatus::Blocked,
9594            "https://github.com/o/r/pull/8",
9595            "open",
9596        );
9597        pr_run(
9598            h,
9599            "20261004-100500-ffff",
9600            "/repo/r",
9601            RunStatus::Merged,
9602            PR,
9603            "open",
9604        );
9605        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9606
9607        assert_eq!(
9608            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9609            2
9610        );
9611        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9612        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9613        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9614        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9615        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9616        // The source's own record is the caller's to write.
9617        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9618        // Idempotent.
9619        assert_eq!(
9620            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9621            0
9622        );
9623        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9624        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9625    }
9626
9627    #[test]
9628    fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9629        let home = tempfile::tempdir().unwrap();
9630        let live_id = "20261004-100000-aaaa";
9631        let unknown_id = "20261004-100100-bbbb";
9632        for id in [live_id, unknown_id] {
9633            pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9634            let mut s = RunState::load_under(id, home.path()).unwrap();
9635            s.driver_exited = false;
9636            // Model legacy driver evidence, not a newly reserved unstarted run.
9637            s.driver_claimed = None;
9638            if id == live_id {
9639                s.driver_pid = Some(std::process::id());
9640                s.driver_started_at = crate::proc::process_started_at(std::process::id());
9641            }
9642            s.save_under(home.path()).unwrap();
9643            if id == unknown_id {
9644                assert_eq!(
9645                    s.liveness_under(home.path(), false),
9646                    crate::run::Liveness::Unknown
9647                );
9648            }
9649        }
9650        let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9651        assert_eq!(apply_pr_states(home.path(), &known), 0);
9652        for id in [live_id, unknown_id] {
9653            assert_eq!(recorded(home.path(), id), "open");
9654        }
9655    }
9656
9657    #[test]
9658    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9659        let tmp = tempfile::tempdir().unwrap();
9660        let h = tmp.path();
9661        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9662        pr_run(
9663            h,
9664            "20261004-100000-aaaa",
9665            "/repo/r",
9666            RunStatus::Superseded,
9667            &url(1),
9668            "open",
9669        );
9670        pr_run(
9671            h,
9672            "20261004-100100-bbbb",
9673            "/repo/r",
9674            RunStatus::Blocked,
9675            &url(2),
9676            "open",
9677        );
9678        pr_run(
9679            h,
9680            "20261004-100200-cccc",
9681            "/repo/r",
9682            RunStatus::Ready,
9683            &url(3),
9684            "open",
9685        );
9686        pr_run(
9687            h,
9688            "20261004-100300-dddd",
9689            "/repo/r",
9690            RunStatus::Ready,
9691            &url(4),
9692            "open",
9693        );
9694        pr_run(
9695            h,
9696            "20261004-100400-eeee",
9697            "/repo/r",
9698            RunStatus::Implementing,
9699            &url(1),
9700            "open",
9701        );
9702        assert_eq!(stale_open_prs(h).len(), 4);
9703
9704        let mut known = BTreeMap::new();
9705        known.insert(url(1), PrLifecycle::Merged);
9706        known.insert(url(2), PrLifecycle::Closed);
9707        known.insert(url(3), PrLifecycle::Open);
9708        // #4: the forge could not be read, so it has no answer.
9709        assert_eq!(apply_pr_states(h, &known), 2);
9710        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9711        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9712        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9713        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9714        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9715        assert_eq!(apply_pr_states(h, &known), 0);
9716    }
9717
9718    // --- the land loop against a scripted forge -------------------------
9719
9720    use std::collections::VecDeque;
9721    use std::sync::Mutex;
9722
9723    /// Answers views from a script (the last one repeats), merges from a
9724    /// queue, and records every call so a test can assert the order.
9725    struct Scripted {
9726        evidence: Mutex<Option<CiEvidence>>,
9727        views: Mutex<VecDeque<Seen>>,
9728        merges: Mutex<VecDeque<(bool, String)>>,
9729        fix: Mutex<Option<Fixed>>,
9730        log: Mutex<Vec<&'static str>>,
9731        argvs: Mutex<Vec<Vec<String>>>,
9732        required: Mutex<Option<BTreeSet<String>>>,
9733        /// Set once a merge answered ok: the forge then reports `merged`,
9734        /// unless `queued` says the merge only entered a queue.
9735        merged: Mutex<bool>,
9736        queued: Mutex<bool>,
9737        /// Views fail once a merge answered ok.
9738        unreadable_after_merge: Mutex<bool>,
9739        /// Workflow runs asked to rerun, and whether that call fails.
9740        reruns: Mutex<Vec<String>>,
9741        rerun_fails: Mutex<bool>,
9742        /// Unix seconds; every poll moves it on by `POLL`.
9743        clock: Mutex<i64>,
9744    }
9745
9746    impl Scripted {
9747        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9748            Self {
9749                evidence: Mutex::new(None),
9750                views: Mutex::new(views.into()),
9751                merges: Mutex::new(
9752                    merges
9753                        .into_iter()
9754                        .map(|(ok, m)| (ok, m.to_owned()))
9755                        .collect(),
9756                ),
9757                fix: Mutex::new(None),
9758                log: Mutex::new(Vec::new()),
9759                argvs: Mutex::new(Vec::new()),
9760                required: Mutex::new(None),
9761                merged: Mutex::new(false),
9762                queued: Mutex::new(false),
9763                unreadable_after_merge: Mutex::new(false),
9764                reruns: Mutex::new(Vec::new()),
9765                rerun_fails: Mutex::new(false),
9766                clock: Mutex::new(1_000),
9767            }
9768        }
9769        fn argvs(&self) -> Vec<Vec<String>> {
9770            self.argvs.lock().unwrap().clone()
9771        }
9772        fn calls(&self) -> Vec<&'static str> {
9773            self.log.lock().unwrap().clone()
9774        }
9775    }
9776
9777    impl Forge for Scripted {
9778        async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
9779            self.evidence.lock().unwrap().clone()
9780        }
9781        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9782            self.log.lock().unwrap().push("view");
9783            if *self.unreadable_after_merge.lock().unwrap()
9784                && !self.argvs.lock().unwrap().is_empty()
9785            {
9786                anyhow::bail!("forge unreachable");
9787            }
9788            let mut v = self.views.lock().unwrap();
9789            let mut seen = if v.len() > 1 {
9790                v.pop_front().unwrap()
9791            } else {
9792                v[0].clone()
9793            };
9794            if *self.merged.lock().unwrap() {
9795                seen.pr.state = PrLifecycle::Merged;
9796            }
9797            Ok(seen)
9798        }
9799        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9800            self.log.lock().unwrap().push("merge");
9801            self.argvs.lock().unwrap().push(argv.to_vec());
9802            let out = self
9803                .merges
9804                .lock()
9805                .unwrap()
9806                .pop_front()
9807                .expect("unscripted merge");
9808            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9809            {
9810                *self.merged.lock().unwrap() = true;
9811            }
9812            Ok(out)
9813        }
9814        async fn poll(&self) {
9815            self.log.lock().unwrap().push("poll");
9816            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9817        }
9818        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9819            self.log.lock().unwrap().push("rerun");
9820            self.reruns.lock().unwrap().push(run.to_owned());
9821            if *self.rerun_fails.lock().unwrap() {
9822                anyhow::bail!("gh run rerun: no");
9823            }
9824            Ok(())
9825        }
9826        fn now(&self) -> i64 {
9827            *self.clock.lock().unwrap()
9828        }
9829        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9830            self.required.lock().unwrap().clone()
9831        }
9832        async fn fix(
9833            &self,
9834            _state: &mut RunState,
9835            _pr: &PrState,
9836            _round: usize,
9837            _budget: usize,
9838            _reason: &str,
9839            _logs: &str,
9840        ) -> Result<Fixed> {
9841            self.log.lock().unwrap().push("fix");
9842            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
9843        }
9844    }
9845
9846    const REFUSED: &str =
9847        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
9848
9849    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
9850        let mut pr = green_pr();
9851        pr.checks = checks;
9852        pr.blocking = Blocking::of(merge_state);
9853        if !comments {
9854            pr.review_comments.clear();
9855        }
9856        Seen {
9857            pr,
9858            title: "feat: x".to_owned(),
9859            failing_urls: Vec::new(),
9860            head: head.to_owned(),
9861            rollup_head: head.to_owned(),
9862            merge_state: merge_state.to_owned(),
9863            contexts: Vec::new(),
9864            base: "main".to_owned(),
9865        }
9866    }
9867
9868    fn landing_state() -> RunState {
9869        crate::run::pin_test_home();
9870        let mut state = run_state();
9871        state.config.graph.land_approval = false;
9872        state
9873    }
9874
9875    #[test]
9876    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
9877        assert!(!awaiting_new_head(None, "aaa"));
9878        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
9879        assert!(awaiting_new_head(Some("abc123"), "def456"));
9880        assert!(awaiting_new_head(Some("abc123"), ""));
9881    }
9882
9883    #[test]
9884    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
9885        let open = |c, m: &str| seen("a", c, m, false);
9886        let table = [
9887            (None, false, Refused::Pending),
9888            (
9889                Some(open(Checks::Pending, "BLOCKED")),
9890                false,
9891                Refused::Pending,
9892            ),
9893            (
9894                Some(open(Checks::Unknown, "BLOCKED")),
9895                false,
9896                Refused::Pending,
9897            ),
9898            (
9899                Some(open(Checks::Green, "UNKNOWN")),
9900                false,
9901                Refused::Pending,
9902            ),
9903            (Some(open(Checks::Green, "")), false, Refused::Pending),
9904            (
9905                Some(open(Checks::Green, "BLOCKED")),
9906                false,
9907                Refused::Recheck,
9908            ),
9909            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
9910        ];
9911        for (after, rechecked, want) in table {
9912            assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
9913        }
9914        let mut closed = open(Checks::Green, "CLEAN");
9915        closed.pr.state = PrLifecycle::Closed;
9916        assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
9917    }
9918
9919    #[tokio::test]
9920    async fn a_normal_landing_merges_on_the_first_look() {
9921        let mut state = landing_state();
9922        let forge = Scripted::new(
9923            vec![seen("a", Checks::Green, "CLEAN", false)],
9924            vec![(true, "")],
9925        );
9926        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9927            .await
9928            .unwrap();
9929        // One fresh read, then the head-bound merge.
9930        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9931        assert_eq!(state.status, RunStatus::Merged);
9932    }
9933
9934    #[tokio::test]
9935    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
9936        let mut state = landing_state();
9937        let forge = Scripted::new(
9938            vec![seen("a", Checks::Green, "CLEAN", false)],
9939            std::iter::repeat_n((true, ""), 100).collect(),
9940        );
9941        *forge.queued.lock().unwrap() = true;
9942        let task = async {
9943            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9944                .await
9945                .unwrap();
9946        };
9947        // Still open after the command succeeded: it keeps watching and
9948        // never records a merge (it stops at the wait ceiling instead).
9949        task.await;
9950        assert_ne!(state.status, RunStatus::Merged);
9951    }
9952
9953    #[tokio::test]
9954    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
9955        let mut state = landing_state();
9956        let forge = Scripted::new(
9957            vec![seen("a", Checks::Green, "CLEAN", false)],
9958            std::iter::repeat_n((true, ""), 100).collect(),
9959        );
9960        *forge.unreadable_after_merge.lock().unwrap() = true;
9961        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9962            .await
9963            .ok();
9964        assert_ne!(state.status, RunStatus::Merged);
9965    }
9966
9967    #[tokio::test]
9968    async fn a_task_public_head_is_awaited_on_land_entry() {
9969        let mut state = landing_state();
9970        state.pr_head = Some("magi/task-abcd-1234".into());
9971        state.pr_pushed_head = Some("new".into());
9972        let forge = Scripted::new(
9973            vec![
9974                seen("old", Checks::Green, "CLEAN", false),
9975                seen("new", Checks::Green, "CLEAN", false),
9976            ],
9977            vec![(true, "")],
9978        );
9979        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9980            .await
9981            .unwrap();
9982        assert_eq!(
9983            forge.calls(),
9984            ["view", "poll", "view", "view", "merge", "view"]
9985        );
9986        assert_eq!(state.status, RunStatus::Merged);
9987    }
9988
9989    #[tokio::test]
9990    async fn a_superseded_public_head_owner_cannot_reenter_land() {
9991        let mut state = landing_state();
9992        state.pr_taken_over_by = Some("next".into());
9993        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
9994        assert!(
9995            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9996                .await
9997                .is_err()
9998        );
9999        assert!(forge.calls().is_empty());
10000    }
10001
10002    #[tokio::test]
10003    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
10004        let mut state = landing_state();
10005        let forge = Scripted::new(
10006            vec![
10007                seen("old", Checks::Green, "CLEAN", true),
10008                // The forge has not moved to the new head yet: still green.
10009                seen("old", Checks::Green, "CLEAN", true),
10010                seen("new", Checks::Pending, "BLOCKED", true),
10011                seen("new", Checks::Green, "CLEAN", true),
10012            ],
10013            vec![(true, "")],
10014        );
10015        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10016            head: "NEW".to_owned(),
10017        });
10018        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10019            .await
10020            .unwrap();
10021        assert_eq!(
10022            forge.calls(),
10023            [
10024                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
10025                "view"
10026            ]
10027        );
10028        assert_eq!(state.status, RunStatus::Merged);
10029    }
10030
10031    #[tokio::test]
10032    async fn a_head_that_never_arrives_stops_naming_both_commits() {
10033        let mut state = landing_state();
10034        let forge = Scripted::new(
10035            vec![
10036                seen("old", Checks::Green, "CLEAN", true),
10037                seen("someone-elses", Checks::Green, "CLEAN", true),
10038            ],
10039            vec![],
10040        );
10041        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10042            head: "mine".to_owned(),
10043        });
10044        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10045            .await
10046            .unwrap();
10047        assert!(!forge.calls().contains(&"merge"));
10048        let why = state.merge.as_ref().unwrap().detail.clone();
10049        assert!(
10050            why.contains("mine") && why.contains("someone-elses"),
10051            "{why}"
10052        );
10053        assert_eq!(state.status, RunStatus::Blocked);
10054    }
10055
10056    #[tokio::test]
10057    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
10058        let mut state = landing_state();
10059        let forge = Scripted::new(
10060            vec![
10061                seen("a", Checks::Green, "CLEAN", false),
10062                seen("a", Checks::Green, "CLEAN", false),
10063                seen("a", Checks::Pending, "BLOCKED", false),
10064                seen("a", Checks::Pending, "BLOCKED", false),
10065                seen("a", Checks::Green, "CLEAN", false),
10066            ],
10067            vec![(false, REFUSED), (true, "")],
10068        );
10069        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10070            .await
10071            .unwrap();
10072        assert_eq!(
10073            forge.calls(),
10074            [
10075                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
10076                "view"
10077            ]
10078        );
10079        assert_eq!(state.status, RunStatus::Merged);
10080    }
10081
10082    #[tokio::test]
10083    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
10084        let mut state = landing_state();
10085        let forge = Scripted::new(
10086            vec![
10087                seen("a", Checks::Green, "CLEAN", false),
10088                seen("a", Checks::Green, "BLOCKED", false),
10089            ],
10090            vec![(false, REFUSED), (false, REFUSED)],
10091        );
10092        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10093            .await
10094            .unwrap();
10095        // One re-look is allowed for the forge's own lag, then it is final.
10096        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
10097        let why = state.merge.as_ref().unwrap().detail.clone();
10098        assert!(
10099            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
10100            "{why}"
10101        );
10102        assert_eq!(state.status, RunStatus::Blocked);
10103    }
10104
10105    #[test]
10106    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
10107        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
10108        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
10109        // The pull request is still on the commit before the push.
10110        assert_eq!(bound_head("old", "old", Some("new")), None);
10111        // The checks are the previous commit's.
10112        assert_eq!(bound_head("new", "old", Some("new")), None);
10113        assert_eq!(bound_head("new", "old", None), None);
10114        // Nothing readable.
10115        assert_eq!(bound_head("", "", None), None);
10116        assert_eq!(bound_head("", "", Some("new")), None);
10117        assert_eq!(bound_head("abc", "", None), None);
10118    }
10119
10120    fn view_json(head: &str) -> String {
10121        format!(
10122            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
10123            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
10124            "reviews":[],"comments":[]}}"#
10125        )
10126    }
10127
10128    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
10129        format!(
10130            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
10131            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
10132            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
10133            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
10134        )
10135    }
10136
10137    #[test]
10138    fn rollup_is_bound_to_the_commit_in_the_same_node() {
10139        // The view already points at the new head, but the node still answers
10140        // for the old commit with its red check: the head stays unbound.
10141        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
10142        assert_eq!(s.rollup_head, "old");
10143        assert_eq!(s.pr.checks, Checks::Red);
10144        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
10145        assert_eq!(s.failing_urls.len(), 1);
10146    }
10147
10148    #[test]
10149    fn checks_come_from_the_node_not_the_view() {
10150        let view = view_json("new").replace(
10151            r#""reviews""#,
10152            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
10153        );
10154        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
10155        assert_eq!(s.pr.checks, Checks::Green);
10156        assert!(s.pr.failing.is_empty());
10157        assert_eq!(
10158            bound_head(&s.head, &s.rollup_head, Some("new")),
10159            Some("new")
10160        );
10161    }
10162
10163    #[test]
10164    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
10165        for node in [
10166            None,
10167            Some("not json".to_owned()),
10168            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
10169            Some(node_json("new", "SUCCESS", true)),
10170        ] {
10171            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
10172            assert!(s.rollup_head.is_empty());
10173            assert_eq!(s.pr.checks, Checks::Unknown);
10174            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
10175        }
10176    }
10177
10178    #[test]
10179    fn the_merge_command_is_pinned_to_the_observed_head() {
10180        let argv = merge_argv_at(7, "feat: x", "deadbeef");
10181        let at = argv
10182            .iter()
10183            .position(|a| a == "--match-head-commit")
10184            .unwrap();
10185        assert_eq!(argv[at + 1], "deadbeef");
10186    }
10187
10188    #[tokio::test]
10189    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
10190        let mut state = landing_state();
10191        // The pull request is on the pushed head but the rollup is still the
10192        // previous commit's red, non-required result.
10193        let mut stale = seen("new", Checks::Red, "CLEAN", false);
10194        stale.rollup_head = "old".to_owned();
10195        let forge = Scripted::new(
10196            vec![seen("old", Checks::Green, "CLEAN", true), stale],
10197            vec![],
10198        );
10199        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10200            head: "new".to_owned(),
10201        });
10202        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10203            .await
10204            .unwrap();
10205        assert!(!forge.calls().contains(&"merge"));
10206        assert_eq!(state.status, RunStatus::Blocked);
10207        let why = state.merge.as_ref().unwrap().detail.clone();
10208        assert!(why.contains("new") && why.contains("old"), "{why}");
10209    }
10210
10211    #[test]
10212    fn a_refusal_read_against_another_commits_checks_is_pending() {
10213        let mut after = seen("a", Checks::Green, "BLOCKED", false);
10214        after.rollup_head = "old".to_owned();
10215        assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
10216    }
10217
10218    #[tokio::test]
10219    async fn a_matching_head_with_red_non_required_checks_still_merges() {
10220        let mut state = landing_state();
10221        let forge = Scripted::new(
10222            vec![seen("a", Checks::Red, "CLEAN", false)],
10223            vec![(true, "")],
10224        );
10225        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10226            .await
10227            .unwrap();
10228        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10229        assert_eq!(state.status, RunStatus::Merged);
10230    }
10231
10232    #[tokio::test]
10233    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
10234        let mut state = landing_state();
10235        let forge = Scripted::new(
10236            vec![
10237                seen("a", Checks::Green, "CLEAN", false),
10238                seen("a", Checks::Green, "CLEAN", false),
10239                // Re-viewed after the refusal: someone pushed.
10240                seen("b", Checks::Green, "BLOCKED", false),
10241                seen("b", Checks::Green, "CLEAN", false),
10242            ],
10243            vec![(false, REFUSED), (true, "")],
10244        );
10245        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10246            .await
10247            .unwrap();
10248        assert_eq!(
10249            forge.calls(),
10250            [
10251                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
10252            ]
10253        );
10254        assert_eq!(state.status, RunStatus::Merged);
10255    }
10256
10257    fn merged_view(head: &str) -> Seen {
10258        let mut m = seen(head, Checks::Green, "CLEAN", false);
10259        m.pr.state = PrLifecycle::Merged;
10260        m
10261    }
10262
10263    fn has(argv: &[String], flag: &str) -> bool {
10264        argv.iter().any(|a| a == flag)
10265    }
10266
10267    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
10268        let at = argv.iter().position(|a| a == flag)?;
10269        argv.get(at + 1).map(String::as_str)
10270    }
10271
10272    const URL: &str = "https://github.com/o/r/pull/42";
10273
10274    #[tokio::test]
10275    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
10276        let mut state = landing_state();
10277        let forge = Scripted::new(
10278            vec![
10279                seen("abc", Checks::Green, "CLEAN", false),
10280                seen("abc", Checks::Green, "CLEAN", false),
10281            ],
10282            vec![(true, "")],
10283        );
10284        land_with(&mut state, URL, &forge).await.unwrap();
10285        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10286        let argv = &forge.argvs()[0];
10287        assert!(has(argv, "--squash") && has(argv, "--subject"));
10288        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
10289        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
10290        assert_eq!(state.status, RunStatus::Merged);
10291        assert!(state.land_armed_head.is_none());
10292    }
10293
10294    #[tokio::test]
10295    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
10296        let mut state = landing_state();
10297        state.land_armed_head = Some("a".to_owned());
10298        let forge = Scripted::new(
10299            vec![seen("a", Checks::Green, "CLEAN", false)],
10300            vec![(true, ""), (true, "")],
10301        );
10302        land_with(&mut state, URL, &forge).await.unwrap();
10303        let argvs = forge.argvs();
10304        assert!(has(&argvs[0], "--disable-auto"));
10305        assert!(!has(&argvs[1], "--auto"));
10306        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
10307        assert_eq!(state.status, RunStatus::Merged);
10308        assert!(state.land_armed_head.is_none());
10309    }
10310
10311    #[tokio::test]
10312    async fn an_approval_never_carries_over_to_a_new_head() {
10313        crate::run::pin_test_home();
10314        let mut state = run_state();
10315        state.config.graph.land_approval = true;
10316        let pr = green_pr();
10317        let store = ask::Questions::open();
10318
10319        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
10320            .await
10321            .unwrap();
10322        let mut q = store
10323            .list()
10324            .into_iter()
10325            .find(|q| q.run == state.id)
10326            .unwrap();
10327        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10328        store.put(&mut q).unwrap();
10329        assert_eq!(
10330            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
10331                .await
10332                .unwrap(),
10333            ApprovalGate::Approved,
10334            "the same head keeps its approval"
10335        );
10336
10337        // A new head is a new question, not the old word.
10338        assert_eq!(
10339            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10340                .await
10341                .unwrap(),
10342            ApprovalGate::Pending
10343        );
10344        let all: Vec<_> = store
10345            .list()
10346            .into_iter()
10347            .filter(|q| q.run == state.id)
10348            .collect();
10349        assert_eq!(all.len(), 2);
10350
10351        // A question recorded before heads were tracked is not reused either.
10352        state.land_approval = None;
10353        assert_eq!(
10354            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10355                .await
10356                .unwrap(),
10357            ApprovalGate::Pending
10358        );
10359        let open = store
10360            .list()
10361            .into_iter()
10362            .filter(|q| q.run == state.id && q.status.open())
10363            .count();
10364        assert_eq!(open, 1, "the superseded question was retired");
10365    }
10366
10367    #[tokio::test]
10368    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
10369        let mut state = landing_state();
10370        let forge = Scripted::new(
10371            vec![
10372                seen("a", Checks::Green, "CLEAN", false),
10373                // The fresh read before the merge: someone pushed.
10374                seen("b", Checks::Green, "CLEAN", false),
10375            ],
10376            vec![(true, "")],
10377        );
10378        land_with(&mut state, URL, &forge).await.unwrap();
10379        let argvs = forge.argvs();
10380        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
10381        assert!(!has(&argvs[0], "--auto"));
10382        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
10383        assert_eq!(state.status, RunStatus::Merged);
10384    }
10385
10386    fn passing(label: &str) -> CheckInfo {
10387        CheckInfo {
10388            label: label.to_owned(),
10389            verdict: Verdict::Pass,
10390            required: Some(false),
10391            run: None,
10392        }
10393    }
10394
10395    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
10396        (label.to_owned(), run.map(str::to_owned))
10397    }
10398
10399    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
10400        LandReruns {
10401            head: head.to_owned(),
10402            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
10403        }
10404    }
10405
10406    #[test]
10407    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
10408        let f = [failing("test (macos)", Some("7"))];
10409        assert_eq!(
10410            rerun_step(&f, None, "h", 100),
10411            RerunStep::Rerun(vec!["7".to_owned()])
10412        );
10413        let rec = reran("h", &[("7", 100)]);
10414        let grace = crate::release_watch::RERUN_GRACE;
10415        assert_eq!(
10416            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
10417            RerunStep::Grace
10418        );
10419        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
10420    }
10421
10422    #[test]
10423    fn a_failure_without_a_run_id_goes_to_the_fixer() {
10424        let f = [failing("a", Some("7")), failing("ci/external", None)];
10425        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
10426        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
10427    }
10428
10429    #[test]
10430    fn a_moved_head_forgets_the_reruns() {
10431        let f = [failing("a", Some("7"))];
10432        let rec = reran("old", &[("7", 100)]);
10433        assert_eq!(
10434            rerun_step(&f, Some(&rec), "new", 101),
10435            RerunStep::Rerun(vec!["7".to_owned()])
10436        );
10437    }
10438
10439    #[test]
10440    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
10441        let f = [
10442            failing("a", Some("7")),
10443            failing("b", Some("7")),
10444            failing("c", Some("8")),
10445        ];
10446        assert_eq!(
10447            rerun_step(&f, None, "h", 0),
10448            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
10449        );
10450        let rec = reran("h", &[("7", 0)]);
10451        assert_eq!(
10452            rerun_step(&f, Some(&rec), "h", 1),
10453            RerunStep::Rerun(vec!["8".to_owned()])
10454        );
10455    }
10456
10457    #[test]
10458    fn only_blocking_failures_are_candidates_for_a_rerun() {
10459        let check = |label: &str, verdict, required, run: &str| CheckInfo {
10460            label: label.to_owned(),
10461            verdict,
10462            required,
10463            run: Some(run.to_owned()),
10464        };
10465        let got = rerunnable(&[
10466            check("passed", Verdict::Pass, Some(true), "1"),
10467            check("optional", Verdict::Fail, Some(false), "2"),
10468            check("required", Verdict::Fail, Some(true), "3"),
10469            check("unknown", Verdict::Fail, None, "4"),
10470        ]);
10471        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
10472        assert_eq!(labels, ["required", "unknown"]);
10473    }
10474
10475    fn unrelated_ci_fixture() -> CiEvidence {
10476        let failure = CiFailure {
10477            path: "tests/web_render.rs".into(),
10478            start_line: 42,
10479            end_line: 42,
10480            title: "question_identity_disclosure_copy_and_live_updates".into(),
10481            message: "assertion failed".into(),
10482        };
10483        CiEvidence {
10484            head: "a".into(),
10485            base: "base-sha".into(),
10486            changed: vec![
10487                "AGENTS.md".into(),
10488                "src/talk.rs".into(),
10489                "tests/consult_investigation.rs".into(),
10490            ],
10491            checks: vec![CiCheckEvidence {
10492                label: "coverage".into(),
10493                head_check: 70,
10494                base_check: 60,
10495                app: 1,
10496                run: "7".into(),
10497                base_run: "6".into(),
10498                head_failures: vec![failure.clone()],
10499                base_failures: vec![failure],
10500                rerun_exhausted: true,
10501            }],
10502        }
10503    }
10504
10505    #[test]
10506    fn unrelated_ci_requires_complete_structural_evidence_for_every_failure() {
10507        let good = unrelated_ci_fixture();
10508        let red = vec!["coverage".into()];
10509        assert!(good.unrelated("a", &red));
10510        assert!(!good.unrelated("another-head", &red));
10511        assert!(!good.unrelated("a", &["coverage".into(), "test".into()]));
10512        assert!(!good.unrelated("a", &[]));
10513        for mutation in 0..8 {
10514            let mut bad = good.clone();
10515            match mutation {
10516                0 => bad.checks[0].rerun_exhausted = false,
10517                1 => bad.checks[0].base_failures.clear(),
10518                2 => bad.checks[0].head_failures.clear(),
10519                3 => bad.changed.push("tests/web_render.rs".into()),
10520                4 => bad.changed.push(".github/workflows/ci.yml".into()),
10521                5 => bad.checks[0].base_failures[0].message = "different failure".into(),
10522                6 => bad.checks.push(bad.checks[0].clone()),
10523                _ => bad.base.clear(),
10524            }
10525            assert!(!bad.unrelated("a", &red), "mutation {mutation}");
10526        }
10527    }
10528
10529    #[test]
10530    fn red_unknown_merge_state_waits_before_repair_but_never_implies_merge() {
10531        let pr = seen("a", Checks::Red, "UNKNOWN", false).pr;
10532        assert_eq!(decide(&pr, 0, 2, Duration::ZERO), Step::Wait);
10533        assert!(matches!(decide(&pr, 0, 2, CHECKS_GRACE), Step::Fix { .. }));
10534        assert!(matches!(
10535            decide(&pr, 2, 2, CHECKS_GRACE),
10536            Step::GiveUp { .. }
10537        ));
10538        let optional = CheckInfo {
10539            label: "coverage".into(),
10540            verdict: Verdict::Fail,
10541            required: Some(false),
10542            run: Some("7".into()),
10543        };
10544        assert!(rerunnable_with(std::slice::from_ref(&optional), false).is_empty());
10545        assert_eq!(
10546            rerunnable_with(&[optional], true),
10547            vec![("coverage".into(), Some("7".into()))]
10548        );
10549    }
10550
10551    #[tokio::test]
10552    async fn unrelated_ci_after_a_comment_round_does_not_charge_a_no_commit_stop() {
10553        let mut state = landing_state();
10554        let mut red = coverage_unknown();
10555        red.pr = seen("a", Checks::Red, "BLOCKED", true).pr;
10556        red.pr.failing = vec!["coverage".into()];
10557        let forge = Scripted::new(vec![red], vec![]);
10558        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10559        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10560        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10561        assert!(forge.calls().contains(&"fix"));
10562        assert!(state.land_unrelated.is_some());
10563        assert!(state.parked);
10564        assert!(
10565            !state
10566                .events
10567                .iter()
10568                .any(|e| e.message.contains("fixer produced no commit"))
10569        );
10570    }
10571
10572    fn coverage_unknown() -> Seen {
10573        let mut s = red("a", Some("7"));
10574        s.pr.blocking = Blocking::Unsaid;
10575        s.merge_state = "UNKNOWN".into();
10576        s.pr.failing = vec!["coverage".into()];
10577        s.contexts[0].label = "coverage".into();
10578        s.contexts[0].required = Some(false);
10579        s
10580    }
10581
10582    #[tokio::test]
10583    async fn run_196c_unknown_then_unstable_does_not_spend_a_fix_round() {
10584        let mut state = landing_state();
10585        let unknown = coverage_unknown();
10586        let mut unstable = unknown.clone();
10587        unstable.pr.blocking = Blocking::of("UNSTABLE");
10588        unstable.merge_state = "UNSTABLE".into();
10589        let forge = Scripted::new(vec![unknown, unstable], vec![(true, "")]);
10590        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10591        assert!(!forge.calls().contains(&"fix"));
10592        assert!(!forge.calls().contains(&"rerun"));
10593        assert_eq!(state.status, RunStatus::Merged);
10594        assert_eq!(state.pr.unwrap().round, 0);
10595    }
10596
10597    #[tokio::test]
10598    async fn run_196c_persistent_unknown_reruns_optional_once_then_parks_with_evidence() {
10599        let mut state = landing_state();
10600        let forge = Scripted::new(vec![coverage_unknown()], vec![]);
10601        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10602        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10603        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10604        assert!(!forge.calls().contains(&"fix"));
10605        assert!(state.parked);
10606        assert_eq!(state.status, RunStatus::Landing);
10607        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10608        let hold = state.land_unrelated.as_ref().unwrap();
10609        let q = ask::Questions::open().get(&hold.question).unwrap();
10610        assert!(
10611            q.actions
10612                .values()
10613                .any(|a| matches!(a, ask::ChoiceAction::Resume { .. }))
10614        );
10615        assert!(
10616            state
10617                .events
10618                .iter()
10619                .any(|e| e.message.contains("[ci-unrelated]") && e.message.contains("base-sha"))
10620        );
10621        let first = state.land_unsaid.clone();
10622        // A resume on this head neither extends UNKNOWN grace nor reruns 7.
10623        state.parked = false;
10624        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10625        assert_eq!(state.land_unsaid, first);
10626        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10627    }
10628
10629    fn red(head: &str, run: Option<&str>) -> Seen {
10630        let mut s = seen(head, Checks::Red, "BLOCKED", false);
10631        s.pr.failing = vec!["test (macos)".to_owned()];
10632        s.contexts = vec![CheckInfo {
10633            label: "test (macos)".to_owned(),
10634            verdict: Verdict::Fail,
10635            required: Some(true),
10636            run: run.map(str::to_owned),
10637        }];
10638        s
10639    }
10640
10641    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
10642
10643    #[tokio::test]
10644    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
10645        let mut state = landing_state();
10646        let forge = Scripted::new(
10647            vec![
10648                red("a", Some("7")),
10649                seen("a", Checks::Pending, "BLOCKED", false),
10650                seen("a", Checks::Green, "CLEAN", false),
10651            ],
10652            vec![(true, "")],
10653        );
10654        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10655        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10656        assert!(!forge.calls().contains(&"fix"));
10657        assert_eq!(state.status, RunStatus::Merged);
10658        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10659        let argvs = forge.argvs();
10660        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
10661        assert!(
10662            state
10663                .events
10664                .iter()
10665                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
10666        );
10667    }
10668
10669    #[tokio::test]
10670    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
10671        let mut state = landing_state();
10672        let forge = Scripted::new(
10673            vec![
10674                red("a", Some("7")),
10675                seen("a", Checks::Green, "CLEAN", false),
10676            ],
10677            vec![(true, "")],
10678        );
10679        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10680        // The red view repeats for the grace, then the fixer is asked once.
10681        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10682        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
10683        let calls = forge.calls();
10684        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
10685        let fix_at = calls.iter().position(|c| *c == "fix");
10686        assert!(fix_at.is_none_or(|f| f > rerun_at));
10687    }
10688
10689    #[tokio::test]
10690    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
10691        let mut state = landing_state();
10692        let mut v = red("a", Some("7"));
10693        v.pr.checks = Checks::Pending;
10694        v.contexts.push(CheckInfo {
10695            label: "slow".to_owned(),
10696            verdict: Verdict::Pending,
10697            required: Some(true),
10698            run: Some("8".to_owned()),
10699        });
10700        let forge = Scripted::new(
10701            vec![v, seen("a", Checks::Green, "CLEAN", false)],
10702            vec![(true, "")],
10703        );
10704        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10705        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10706        assert_eq!(state.status, RunStatus::Merged);
10707    }
10708
10709    #[test]
10710    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10711        let mut v = red("a", Some("7"));
10712        v.contexts.push(CheckInfo {
10713            label: "same run, still going".to_owned(),
10714            verdict: Verdict::Pending,
10715            required: Some(true),
10716            run: Some("7".to_owned()),
10717        });
10718        assert!(rerunnable(&v.contexts).is_empty());
10719    }
10720
10721    #[tokio::test]
10722    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10723        let mut state = landing_state();
10724        state.config.graph.land_rounds = 0;
10725        let forge = Scripted::new(
10726            vec![
10727                red("a", Some("7")),
10728                seen("a", Checks::Green, "CLEAN", false),
10729            ],
10730            vec![(true, "")],
10731        );
10732        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10733        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10734        assert_eq!(state.status, RunStatus::Merged);
10735        assert!(!forge.calls().contains(&"fix"));
10736        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10737    }
10738
10739    #[tokio::test]
10740    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
10741        let mut state = landing_state();
10742        state.config.graph.land_rounds = 1;
10743        let forge = Scripted::new(
10744            vec![
10745                seen("a", Checks::Green, "CLEAN", true),
10746                red("b", Some("7")),
10747                seen("b", Checks::Green, "CLEAN", false),
10748            ],
10749            vec![(true, "")],
10750        );
10751        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10752            head: "b".to_owned(),
10753        });
10754        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10755        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10756        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
10757        assert_eq!(state.pr.as_ref().unwrap().round, 1);
10758        assert_eq!(state.status, RunStatus::Merged);
10759        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
10760        assert_eq!(
10761            value_of(&forge.argvs()[0], "--match-head-commit"),
10762            Some("b")
10763        );
10764    }
10765
10766    #[tokio::test]
10767    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
10768        let mut state = landing_state();
10769        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10770        *forge.rerun_fails.lock().unwrap() = true;
10771        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10772        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10773        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
10774        assert_eq!(
10775            forge.calls().iter().filter(|c| **c == "fix").count(),
10776            1,
10777            "{:?}",
10778            forge.calls()
10779        );
10780    }
10781
10782    #[tokio::test]
10783    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
10784        let mut state = landing_state();
10785        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10786        // The record was written just before the previous pass died.
10787        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
10788        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10789        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10790        assert!(forge.reruns.lock().unwrap().is_empty());
10791    }
10792
10793    #[tokio::test]
10794    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
10795        let mut state = landing_state();
10796        state.config.graph.rerun_flaky_checks = false;
10797        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10798        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10799        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10800        assert!(forge.reruns.lock().unwrap().is_empty());
10801        assert!(forge.calls().contains(&"fix"));
10802
10803        let mut state = landing_state();
10804        let forge = Scripted::new(vec![red("a", None)], vec![]);
10805        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10806        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10807        assert!(forge.reruns.lock().unwrap().is_empty());
10808        assert!(forge.calls().contains(&"fix"));
10809    }
10810
10811    fn names(xs: &[&str]) -> BTreeSet<String> {
10812        xs.iter().map(|x| (*x).to_owned()).collect()
10813    }
10814
10815    #[test]
10816    fn a_required_check_the_rollup_never_listed_is_named() {
10817        let req = names(&["build"]);
10818        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
10819        assert!(why.contains("never reported: build"), "{why}");
10820        assert!(!why.contains("probably waiting for a review"), "{why}");
10821    }
10822
10823    #[test]
10824    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
10825        let why = waiting_on("BLOCKED", &[passing("review")], None);
10826        assert!(why.contains("could not be read"), "{why}");
10827        assert!(!why.contains("probably waiting for a review"), "{why}");
10828    }
10829
10830    #[test]
10831    fn all_required_reported_keeps_the_review_guess() {
10832        let req = names(&["build"]);
10833        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10834        assert!(why.contains("probably waiting for a review"), "{why}");
10835        assert!(!why.contains("never reported"), "{why}");
10836    }
10837
10838    #[test]
10839    fn required_names_match_the_rollup_ignoring_case_only() {
10840        let req = names(&["Build"]);
10841        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10842        assert!(!why.contains("never reported"), "{why}");
10843    }
10844
10845    #[test]
10846    fn required_contexts_are_read_from_protection_and_rulesets() {
10847        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
10848        assert_eq!(
10849            parse_classic_required(classic),
10850            Some(names(&["build", "lint"]))
10851        );
10852        let rules = r#"[{"type":"pull_request","parameters":{}},
10853            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
10854        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
10855        assert_eq!(parse_ruleset_required("nope"), None);
10856        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
10857    }
10858
10859    #[test]
10860    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
10861        let shown = BTreeSet::new();
10862        let ok = seen("a", Checks::Green, "CLEAN", false);
10863        let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
10864        assert!(guard(Some(&ok)));
10865        assert!(!guard(None));
10866        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
10867        let mut stale = ok.clone();
10868        stale.rollup_head = "old".to_owned();
10869        assert!(!guard(Some(&stale)));
10870        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
10871        assert!(!guard(Some(&merged_view("a"))));
10872    }
10873
10874    #[test]
10875    fn the_rollup_node_carries_whether_each_check_is_required() {
10876        let node = node_json("new", "SUCCESS", false)
10877            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
10878        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
10879        assert_eq!(s.contexts.len(), 1);
10880        assert_eq!(s.contexts[0].required, Some(true));
10881        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
10882        assert_eq!(s.contexts[0].required, None);
10883    }
10884
10885    #[tokio::test]
10886    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
10887        let mut state = landing_state();
10888        state.land_armed_head = Some("a".to_owned());
10889        let forge = Scripted::new(
10890            vec![seen("a", Checks::Green, "CLEAN", true)],
10891            vec![(false, "disable exploded")],
10892        );
10893        land_with(&mut state, URL, &forge).await.unwrap();
10894        assert!(!forge.calls().contains(&"fix"));
10895        assert_eq!(state.status, RunStatus::Blocked);
10896        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
10897        let why = state.merge.as_ref().unwrap().detail.clone();
10898        assert!(why.contains("disable exploded"), "{why}");
10899    }
10900}