Skip to main content

magi/
land.rs

1//! Landing the winner: watch the pull request, fix what it complains about,
2//! and merge it.
3//!
4//! Opening the pull request used to be where magi stopped and the operator
5//! started: watch the checks, read what the review bots found, push a fix,
6//! wait again, merge. That loop is mechanical, it takes an hour of wall-clock
7//! time per pull request, and doing it by hand six times in one session is how
8//! a queue that drains unattended stops being unattended. So it lives here.
9//!
10//! # Shape
11//!
12//! [`PrState`] is one observation of a pull request and [`decide`] is the whole
13//! policy as a *pure* function of it. Nothing in [`decide`] talks to `gh`,
14//! which is what makes "green with an unresolved comment is a fix, not a merge"
15//! an assertion in a test rather than a claim in a comment. [`land`] is the
16//! only part that performs I/O: observe, decide, act, repeat.
17//!
18//! # What it refuses to do
19//!
20//! Merging is the one irreversible thing magi can do to a repository, so the
21//! loop is built to stop rather than to guess:
22//!
23//! * A red pull request is never merged. When the budget runs out the pull
24//!   request is left open with a comment naming what is still failing, because
25//!   a magi that force-merges a red pull request is worse than one that stops.
26//! * A pull request whose checks cannot be read at all (`gh` reported no
27//!   rollup) is not merged either. Landing is for repositories with CI; with no
28//!   signal there is nothing to be green.
29//! * A pull request a human merged or closed underneath us is
30//!   [`Step::Done`] - the person won, and their decision is not an error.
31//!
32//! # Why `--subject` is not optional
33//!
34//! A candidate branch holds one commit whose subject is
35//! `magi: candidate A (uncommitted work)`, and `gh pr merge --squash` prefers a
36//! single commit's message over the pull request title. Merging without
37//! [`merge_argv`]'s explicit `--subject` therefore writes a `main` history that
38//! says nothing about what landed. `AGENTS.md` records the trap; this module is
39//! where it is prevented.
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::MergeMode;
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{
58    ContestedHandoff, LandApproval, LandReruns, MergeOutcome, RunState, RunStatus, tail,
59};
60
61/// How often the pull request is re-read while its checks are still running.
62///
63/// Thirty seconds: a CI matrix takes minutes, so anything shorter is spent
64/// entirely on `gh` invocations, and anything much longer adds latency to every
65/// single round of a loop that already waits for agents.
66pub const POLL: Duration = Duration::from_secs(30);
67
68/// How long one wait may last before landing gives up on the checks finishing.
69///
70/// A workflow that has not settled in forty-five minutes is stuck on a runner
71/// queue, a missing approval, or a hung job - none of which more polling fixes,
72/// and all of which a person needs to see.
73pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
74
75/// How long the checks may stay unreadable before landing gives up on them.
76///
77/// GitHub registers a workflow run some seconds after the branch is pushed, so
78/// immediately after a pull request is opened "no checks" and "no CI in this
79/// repository" look identical. Measured on run 01c2: magi opened pull request
80/// 22, read `unknown` four seconds later, refused to merge on a guess and
81/// marked the run blocked - and every check on that pull request was green
82/// minutes afterwards, with the whole competition then re-run from scratch for
83/// a task that was already finished. Three minutes is well past the observed
84/// registration delay and still bounded, so a repository that genuinely has no
85/// checks costs one three-minute wait and then says so.
86pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
87
88/// Bytes of failing log kept per check. The fixer needs the assertion and the
89/// frame around it, not the forty thousand lines of `cargo` output above it.
90const LOG_TAIL: usize = 4_000;
91
92/// Failing checks whose logs are fetched. Beyond a handful the failures share a
93/// cause, and fetching each one costs a `gh` round trip.
94const MAX_LOGS: usize = 3;
95
96/// Marker carried by every comment magi posts on a pull request.
97///
98/// Without it magi's own "still failing" comment is indistinguishable from a
99/// reviewer's, and the next observation would hand magi's own prose to the
100/// fixer as a finding.
101pub const MARKER: &str = "<!-- magi:land -->";
102
103/// Markers a bot puts in a comment to say that the comment is not a review.
104///
105/// CodeRabbit labels its own machinery in HTML comments - the trigger notice,
106/// the walkthrough summary, the "thanks for using" footer - and its actual
107/// findings arrive as *inline* review comments with a path and a line. Taking
108/// the bot at its word is more honest than guessing from prose, and it is the
109/// difference between a fix round that has something to fix and one that asks
110/// an agent to act on a quota notice.
111const NOT_A_REVIEW: [&str; 3] = [
112    "skip review by coderabbit.ai",
113    "summarize by coderabbit.ai",
114    "<!-- tips_start -->",
115];
116
117/// Where a pull request is in its life.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrLifecycle {
120    /// Still ours to land.
121    Open,
122    /// Already merged, by us or by a person.
123    Merged,
124    /// Closed without merging.
125    Closed,
126}
127
128/// The aggregate verdict of a pull request's checks.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Checks {
131    /// At least one check has not finished.
132    Pending,
133    /// Every check passed (a skipped check counts as passed: the review
134    /// workflow skips release and bot pull requests by design).
135    Green,
136    /// At least one check finished without passing.
137    Red,
138    /// Nothing readable - no rollup at all, or a status magi does not know.
139    Unknown,
140}
141
142impl PrLifecycle {
143    /// Stable lower-case name, as the API and the reports spell it.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::Open => "open",
147            Self::Merged => "merged",
148            Self::Closed => "closed",
149        }
150    }
151}
152
153impl Checks {
154    /// Stable lower-case name, as the API and the reports spell it.
155    pub fn as_str(self) -> &'static str {
156        match self {
157            Self::Pending => "pending",
158            Self::Green => "green",
159            Self::Red => "red",
160            Self::Unknown => "unknown",
161        }
162    }
163}
164
165/// One outstanding review comment, human or bot.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ReviewComment {
168    /// Login of whoever wrote it.
169    pub author: String,
170    /// File it was left on, for inline review comments.
171    pub path: Option<String>,
172    /// Line it was left on, when the comment is inline and still anchored.
173    pub line: Option<u64>,
174    /// The comment itself, as written.
175    pub body: String,
176}
177
178/// One observation of a pull request.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct PrState {
181    /// Pull request url, as `gh` reports it.
182    pub url: String,
183    /// Pull request number.
184    pub number: u64,
185    /// Open, merged, or closed.
186    pub state: PrLifecycle,
187    /// Aggregate check verdict.
188    pub checks: Checks,
189    /// Names of the checks that finished without passing.
190    pub failing: Vec<String>,
191    /// Comments that still want an answer, human and bot.
192    pub review_comments: Vec<ReviewComment>,
193    /// Whether the forge itself considers the failures blocking.
194    pub blocking: Blocking,
195}
196
197/// Whether a failing check actually stands between the pull request and
198/// `main`, according to the forge.
199///
200/// The rollup lists every check equally, so `coverage` going red on a
201/// repository that deliberately does not require it looked exactly like a
202/// broken build - and magi answered by spending a fix round on a change that
203/// was fine. Pull request 37 had to be merged by hand for that reason: the
204/// only red check was `editorconfig`, which was failing because the *action*
205/// could not fetch its own binary, and which the repository does not require.
206///
207/// `mergeStateStatus` is where GitHub applies the required-check set, so it
208/// is the one field that can tell the difference.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Blocking {
211    /// Required checks are satisfied and the branch merges cleanly.
212    No,
213    /// Something required is failing or missing.
214    Yes,
215    /// The branch no longer merges: the base moved under it.
216    Conflict,
217    /// The forge did not say - an older `gh`, or a token without the scope.
218    /// Treated as `Yes`, because refusing to guess is the rule everywhere
219    /// else in this module.
220    Unsaid,
221}
222
223impl Blocking {
224    /// Read `mergeStateStatus`, which is upper-case in `gh`'s output.
225    fn of(raw: &str) -> Self {
226        match raw.to_ascii_uppercase().as_str() {
227            // Mergeable. `UNSTABLE` is the interesting one: mergeable, with a
228            // non-required check failing or still running.
229            "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
230            "DIRTY" => Self::Conflict,
231            "" | "UNKNOWN" => Self::Unsaid,
232            // BLOCKED, BEHIND, DRAFT: something has to change first.
233            _ => Self::Yes,
234        }
235    }
236
237    /// Does this stand between the pull request and the base branch?
238    #[must_use]
239    pub fn stops_a_merge(self) -> bool {
240        !matches!(self, Self::No)
241    }
242}
243
244/// What the loop decided to do next. Pure, so the policy is testable.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Step {
247    /// Checks are still running; re-read the pull request after [`POLL`].
248    Wait,
249    /// The base moved and the branch no longer merges: rebase it.
250    ///
251    /// Not a fix round. Nothing is wrong with the change - a competition
252    /// that runs for two hours against a repository merging pull requests
253    /// all day conflicts on the way in, and that is arithmetic rather than a
254    /// defect. Pull requests 35 and 37 were both rebased by hand for exactly
255    /// this.
256    Rebase,
257    /// Red checks or unresolved comments; run a fix round.
258    Fix {
259        /// What is unhappy, in one line, for the run log and the fix prompt.
260        reason: String,
261    },
262    /// Green and nothing outstanding; merge it.
263    Merge,
264    /// The pull request left our hands.
265    Done {
266        /// Did it land, or was it closed?
267        merged: bool,
268    },
269    /// Stop and leave the pull request to a person.
270    GiveUp {
271        /// Why magi stopped, in one line.
272        reason: String,
273    },
274}
275
276/// The outcome to record when `gh pr merge` exits non-zero, given what the
277/// pull request looked like immediately afterwards.
278///
279/// `gh pr merge` merges server-side first and only then does local work -
280/// deleting the branch, switching back to a base branch - so a non-zero exit
281/// does not mean the merge did not happen. In a jj-colocated repository it
282/// reliably does not mean that: git HEAD is detached, and `--delete-branch`
283/// ends with "could not determine current branch: not on any branch" *after*
284/// the merge has landed. Run ec12 merged pull request 28 into `main` and
285/// recorded `ok: false`, and its task was held waiting for a merge that was
286/// already done.
287///
288/// So the forge is asked, and its answer wins - the same authority [`decide`]
289/// gives the pull request's own state over everything else. The recorded
290/// detail carries both facts, because "the command failed and the merge
291/// happened anyway" is exactly what someone reading the run later needs to
292/// know.
293///
294/// `None` means the merge really did not happen, including when the pull
295/// request could not be read at all: an unreadable answer is not evidence of
296/// success.
297pub(crate) fn merged_after_all(
298    argv: &[String],
299    stderr: &str,
300    after: Option<PrLifecycle>,
301) -> Option<MergeOutcome> {
302    if after? != PrLifecycle::Merged {
303        return None;
304    }
305    Some(MergeOutcome {
306        mode: MergeMode::Pr,
307        ok: true,
308        detail: format!(
309            "gh {} (the command reported `{}`, but the pull request is merged)",
310            argv.join(" "),
311            stderr.trim()
312        ),
313        empty: false,
314    })
315}
316
317/// Decide the next step. No I/O.
318///
319/// `round` counts the fix rounds already spent, so `round == budget` means the
320/// budget is gone. A wait never spends a round: waiting is free, and a slow CI
321/// must not consume the allowance meant for actual fixes.
322///
323/// The order of the tests is the policy:
324///
325/// 1. **The pull request's own state wins.** A merge or a close that happened
326///    underneath us is the end of the story regardless of what the checks say.
327/// 2. **Pending beats red.** A check that is still running may yet fail, and one
328///    fix round that addresses every failure is cheaper than two that each
329///    address half - the fix pushes and restarts the whole suite anyway.
330/// 3. **Comments outrank green.** An unresolved comment holds the merge even
331///    when CI is happy; that is what a review is for.
332/// 4. **Unreadable is not absent.** Checks that cannot be read yet are waited
333///    on for [`CHECKS_GRACE`], because a pull request opened a moment ago has
334///    not been given its workflow runs yet. Past the grace they are treated as
335///    genuinely missing and magi stops rather than merge on a guess.
336pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
337    decide_with(pr, round, budget, waited, CiExpectation::Expected)
338}
339
340/// Whether the repository's CI is expected to report on this pull request.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum CiExpectation {
343    /// Checks will come: wait for them, judge them (the default, and what
344    /// [`decide`] always uses).
345    Expected,
346    /// The repository explicitly disables CI expectations with `[merge] ci = "none"`.
347    /// Ignore all check statuses while retaining conflicts and review comments.
348    Absent,
349}
350
351/// [`decide`] with the CI expectation made explicit. `Absent` ignores the
352/// rollup, but still requires conflicts and review comments to be resolved.
353pub fn decide_with(
354    pr: &PrState,
355    round: usize,
356    budget: usize,
357    waited: Duration,
358    ci: CiExpectation,
359) -> Step {
360    match pr.state {
361        PrLifecycle::Merged => return Step::Done { merged: true },
362        PrLifecycle::Closed => return Step::Done { merged: false },
363        PrLifecycle::Open => {}
364    }
365
366    // Before the checks: every check on a branch that cannot land is an
367    // answer about a state that cannot land.
368    if pr.blocking == Blocking::Conflict {
369        return Step::Rebase;
370    }
371
372    let spent = round >= budget;
373    let checks = match ci {
374        CiExpectation::Expected => pr.checks,
375        CiExpectation::Absent => Checks::Green,
376    };
377    match checks {
378        Checks::Pending => Step::Wait,
379        Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
380        Checks::Unknown => Step::GiveUp {
381            reason: format!(
382                "no check status is readable on the pull request after {} minute(s); \
383                 refusing to merge on a guess",
384                CHECKS_GRACE.as_secs() / 60
385            ),
386        },
387        // Red, but the forge says it does not stand in the way: the failing
388        // checks are ones this repository chose not to require. Spending a fix
389        // round on them asks an agent to repair something nobody is gating on
390        // - and pull request 37's only red check was an *action* that could
391        // not fetch its own binary. Merge, and name them so the record is
392        // honest about what was red when it landed.
393        Checks::Red if pr.blocking == Blocking::Unsaid && waited < CHECKS_GRACE => Step::Wait,
394        Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
395        Checks::Red => {
396            let what = format!(
397                "{} check(s) failing: {}",
398                pr.failing.len(),
399                pr.failing.join(", ")
400            );
401            if spent {
402                Step::GiveUp {
403                    reason: format!("{what} — still red after {budget} fix round(s)"),
404                }
405            } else {
406                Step::Fix { reason: what }
407            }
408        }
409        Checks::Green if pr.review_comments.is_empty() => Step::Merge,
410        Checks::Green => {
411            let what = format!(
412                "{} but {} review comment(s) are unresolved: {}",
413                if ci == CiExpectation::Absent {
414                    "CI is not expected"
415                } else {
416                    "checks are green"
417                },
418                pr.review_comments.len(),
419                authors(&pr.review_comments)
420            );
421            if spent {
422                Step::GiveUp {
423                    reason: format!("{what} — still unresolved after {budget} fix round(s)"),
424                }
425            } else {
426                Step::Fix { reason: what }
427            }
428        }
429    }
430}
431
432/// Distinct comment authors, in the order they first appear.
433fn authors(comments: &[ReviewComment]) -> String {
434    let mut seen: Vec<&str> = Vec::new();
435    for c in comments {
436        if !seen.contains(&c.author.as_str()) {
437            seen.push(&c.author);
438        }
439    }
440    seen.join(", ")
441}
442
443/// The argv magi merges with, minus the program name.
444///
445/// `--subject` is the point of this function existing: see the module docs.
446pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
447    vec![
448        "pr".to_owned(),
449        "merge".to_owned(),
450        number.to_string(),
451        "--squash".to_owned(),
452        "--delete-branch".to_owned(),
453        "--subject".to_owned(),
454        subject.to_owned(),
455    ]
456}
457
458/// [`merge_argv`] pinned to the commit the decision was made about.
459///
460/// `--match-head-commit` makes the forge refuse the merge if the branch moved
461/// after it was observed, so a push landing between the look and the merge is
462/// never merged unseen.
463pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
464    let mut argv = merge_argv(number, subject);
465    argv.push("--match-head-commit".to_owned());
466    argv.push(head.to_owned());
467    argv
468}
469
470/// Take auto-merge back. magi no longer arms auto-merge, but a run recorded by
471/// an older build may still have one standing on the forge, so the disarm
472/// paths (and `RunState::land_armed_head`) stay. Run before anything that changes the head, so an
473/// armed merge never outlives the commit that was approved for it.
474pub fn disable_automerge_argv(number: u64) -> Vec<String> {
475    ["pr", "merge", &number.to_string(), "--disable-auto"]
476        .map(str::to_owned)
477        .to_vec()
478}
479
480/// May the direct merge go ahead, judged from a fresh read?
481///
482/// The pull request must still be open on the approved head, the checks must
483/// have been read for that very head, and the policy must still say merge.
484/// Pure, so the head guard is asserted without a forge.
485fn direct_merge_is_safe(
486    fresh: Option<&Seen>,
487    approved_head: &str,
488    shown: &BTreeSet<String>,
489    round: usize,
490    budget: usize,
491    waited: Duration,
492    ci: CiExpectation,
493) -> bool {
494    let Some(fresh) = fresh else {
495        return false;
496    };
497    if fresh.pr.state != PrLifecycle::Open {
498        return false;
499    }
500    let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
501        return false;
502    };
503    if !bound.eq_ignore_ascii_case(approved_head) {
504        return false;
505    }
506    let mut pr = fresh.pr.clone();
507    pr.review_comments.retain(|c| !shown.contains(&c.body));
508    decide_with(&pr, round, budget, waited, ci) == Step::Merge
509}
510
511/// What GitHub is still waiting for, for the stop reason when an armed merge
512/// does not happen in time. No I/O.
513///
514/// Required checks that are pending or failing are named. A check whose
515/// required-ness could not be read is never assumed optional: every unsettled
516/// check is listed and the reason says so.
517fn waiting_on(
518    merge_state: &str,
519    contexts: &[CheckInfo],
520    required_set: Option<&BTreeSet<String>>,
521) -> String {
522    let state = if merge_state.is_empty() {
523        "unknown"
524    } else {
525        merge_state
526    };
527    let tag = |c: &CheckInfo| match c.verdict {
528        Verdict::Fail => "failed",
529        _ => "pending",
530    };
531    let unsettled: Vec<&CheckInfo> = contexts
532        .iter()
533        .filter(|c| c.verdict != Verdict::Pass)
534        .collect();
535    let required: Vec<String> = unsettled
536        .iter()
537        .filter(|c| c.required == Some(true))
538        .map(|c| format!("{} ({})", c.label, tag(c)))
539        .collect();
540    let unknown: Vec<String> = unsettled
541        .iter()
542        .filter(|c| c.required.is_none())
543        .map(|c| format!("{} ({})", c.label, tag(c)))
544        .collect();
545    // Required contexts the rollup never listed: nothing reported them, so no
546    // pending/failing entry exists to name. Matched case-insensitively against
547    // the labels as the rollup spells them, and no further.
548    let never: Vec<&str> = required_set
549        .map(|set| {
550            set.iter()
551                .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
552                .map(String::as_str)
553                .collect()
554        })
555        .unwrap_or_default();
556    let mut out = format!("merge state: {state}");
557    if !never.is_empty() {
558        let _ = write!(
559            out,
560            "; required checks never reported: {}",
561            never.join(", ")
562        );
563    }
564    if !required.is_empty() {
565        let _ = write!(
566            out,
567            "; required checks not passing: {}",
568            required.join(", ")
569        );
570    }
571    if !unknown.is_empty() {
572        let _ = write!(
573            out,
574            "; whether these are required could not be read, so they may be: {}",
575            unknown.join(", ")
576        );
577    }
578    if required.is_empty() && unknown.is_empty() && never.is_empty() {
579        if required_set.is_some() {
580            out.push_str(
581                "; no required check is pending, failing or unreported, so GitHub is probably \
582                 waiting for a review or another branch rule",
583            );
584        } else {
585            out.push_str(
586                "; the required check list could not be read, so a required check that was \
587                 never reported cannot be ruled out",
588            );
589        }
590    }
591    out
592}
593
594/// The squash subject to merge under.
595///
596/// The pull request title, unless it is empty or is a candidate branch's commit
597/// subject that leaked into the title - in which case the task's own first line
598/// is used, because `magi: candidate A (uncommitted work)` in `main` tells a
599/// reader nothing about what landed.
600pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
601    let title = pr_title.trim();
602    if !title.is_empty() && !title.starts_with("magi: candidate") {
603        return title.to_owned();
604    }
605    let first = instruction
606        .lines()
607        .map(str::trim)
608        .find(|l| !l.is_empty())
609        .unwrap_or("magi: land the winning candidate");
610    first.trim_start_matches(['#', ' ']).to_owned()
611}
612
613/// The choice that lets the merge happen, verbatim as the owner taps it.
614pub const APPROVE: &str = "merge";
615
616/// The choice that leaves the pull request open.
617pub const HOLD: &str = "hold";
618
619/// A bounded repair followed by a new review and a fresh owner decision.
620pub const REPAIR: &str = "fix findings and re-review";
621
622/// Whether review evidence offers a meaningful repair choice. This does not
623/// decide whether owner approval is required.
624pub fn repair_available(state: &RunState) -> bool {
625    state.reviews.last().is_some_and(|r| {
626        state.open_findings().iter().any(|f| f.severity.blocks())
627            || r.final_votes()
628                .iter()
629                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
630    })
631}
632
633/// All applicable reasons for consulting the owner, independent of repair choices.
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub enum ApprovalReason {
636    /// Universal owner approval is enabled for every merge.
637    EveryMerge,
638    /// Contested-merge approval is enabled and blocking findings remain open.
639    OpenBlockingFindings,
640    /// Contested-merge approval is enabled and a final reviewer vote rejects.
641    FinalReject,
642    /// An existing owner decision must be honored even if controls change.
643    ExistingDecision,
644    /// Merge repair requires a fresh owner decision, even on the same head.
645    AfterRepair,
646}
647
648/// Disabling fresh approval controls never bypasses an existing owner decision
649/// or the fresh decision owed after repair, even if repair kept the same head.
650pub fn approval_reasons(state: &RunState) -> Vec<ApprovalReason> {
651    let mut reasons = Vec::new();
652    if state.owner_hold(crate::config::OwnerHold::LandApproval) {
653        reasons.push(ApprovalReason::EveryMerge);
654    }
655    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
656        if state.open_findings().iter().any(|f| f.severity.blocks()) {
657            reasons.push(ApprovalReason::OpenBlockingFindings);
658        }
659        if state.reviews.last().is_some_and(|r| {
660            r.final_votes()
661                .iter()
662                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
663        }) {
664            reasons.push(ApprovalReason::FinalReject);
665        }
666    }
667    if state.land_approval.is_some() {
668        reasons.push(ApprovalReason::ExistingDecision);
669    }
670    if !state.merge_repairs.is_empty() {
671        reasons.push(ApprovalReason::AfterRepair);
672    }
673    reasons
674}
675
676/// Bound free-form approval evidence by characters, including an explicit
677/// ellipsis when abbreviated. Keep UTF-8 text intact in questions and panels.
678fn approval_excerpt(text: &str, max_chars: usize) -> String {
679    if text.chars().count() <= max_chars {
680        text.to_owned()
681    } else {
682        let mut excerpt: String = text.chars().take(max_chars.saturating_sub(1)).collect();
683        excerpt.push('…');
684        excerpt
685    }
686}
687
688/// Persisted verbatim in the question and panel at creation. Deputies and
689/// resumed questions use that snapshot, never reconstruct past policy from
690/// today's settings. Fixer claims and successful gates are not resolutions.
691fn approval_evidence(state: &RunState) -> String {
692    const TITLE_CHARS: usize = 100;
693    const REASON_CHARS: usize = 300;
694    let reason = |text: &str| approval_excerpt(text, REASON_CHARS);
695    let ja = words(&state.config.graph.language).html_lang == "ja";
696    let mut s = if ja {
697        "承認が必要な理由:\n"
698    } else {
699        "Approval required because:\n"
700    }
701    .to_owned();
702    for reason in approval_reasons(state) {
703        let label = match (reason, ja) {
704            (ApprovalReason::EveryMerge, false) => {
705                "land_approval=true: owner approval for every merge"
706            }
707            (ApprovalReason::EveryMerge, true) => {
708                "land_approval=true: すべてのマージに所有者の承認が必要"
709            }
710            (ApprovalReason::OpenBlockingFindings, false) => {
711                "hold_contested_merge=true: open Major-or-above internal findings"
712            }
713            (ApprovalReason::OpenBlockingFindings, true) => {
714                "hold_contested_merge=true: Major 以上の内部指摘が未解決"
715            }
716            (ApprovalReason::FinalReject, false) => {
717                "hold_contested_merge=true: final reviewer reject after reconsideration (even without Major findings)"
718            }
719            (ApprovalReason::FinalReject, true) => {
720                "hold_contested_merge=true: 再考後の最終票が reject(Major 指摘がなくても承認が必要)"
721            }
722            (ApprovalReason::ExistingDecision, false) => {
723                "existing owner decision: changing controls cannot bypass it"
724            }
725            (ApprovalReason::ExistingDecision, true) => {
726                "既存の所有者判断: 設定変更では迂回できません"
727            }
728            (ApprovalReason::AfterRepair, false) => {
729                "fresh owner approval after merge repair, even with an unchanged head"
730            }
731            (ApprovalReason::AfterRepair, true) => {
732                "マージ修復後の新しい所有者承認(head に変更がなくても必要)"
733            }
734        };
735        let control = match reason {
736            ApprovalReason::EveryMerge => Some(crate::config::OwnerHold::LandApproval),
737            ApprovalReason::OpenBlockingFindings | ApprovalReason::FinalReject => {
738                Some(crate::config::OwnerHold::HoldContestedMerge)
739            }
740            _ => None,
741        };
742        let source = control
743            .filter(|c| !c.base_value(&state.config.graph))
744            .and_then(|c| state.branch_owner_holds.iter().find(|h| h.control == c));
745        if let Some(source) = source {
746            if ja {
747                let _ = writeln!(
748                    s,
749                    "- {}=true(レビュー対象ブランチの {} 由来, {})",
750                    source.control.key(),
751                    source.file,
752                    source.head
753                );
754            } else {
755                let _ = writeln!(
756                    s,
757                    "- {}=true from the reviewed branch's {} ({})",
758                    source.control.key(),
759                    source.file,
760                    source.head
761                );
762            }
763        }
764        let _ = writeln!(s, "- {label}");
765    }
766    s.push_str(if ja {
767        "\n再考後の各レビュアーの最終票:\n"
768    } else {
769        "\nFinal reviewer votes after reconsideration:\n"
770    });
771    if let Some(round) = state.reviews.last() {
772        let votes = round.final_votes();
773        for r in &round.reviews {
774            let vote = votes
775                .iter()
776                .find(|(seat, _, _)| *seat == r.reviewer)
777                .map(|(_, _, v)| v.label())
778                .unwrap_or(if ja { "記録なし" } else { "not recorded" });
779            let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
780            let rv = round
781                .reconsideration
782                .iter()
783                .find(|rv| rv.reviewer == r.reviewer);
784            if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
785                let initial = r.vote.map(|v| v.label()).unwrap_or("?");
786                let _ = writeln!(
787                    s,
788                    "  {}: {initial} → {vote}",
789                    if ja { "再考" } else { "reconsideration" }
790                );
791                let _ = writeln!(
792                    s,
793                    "  {}: {}",
794                    if ja { "理由" } else { "reason" },
795                    reason(if rv.reason.trim().is_empty() {
796                        if ja { "記録なし" } else { "not recorded" }
797                    } else {
798                        &rv.reason
799                    })
800                );
801            } else {
802                let _ = writeln!(
803                    s,
804                    "  {}: {}",
805                    if ja {
806                        "初回票を保持・理由"
807                    } else {
808                        "initial vote retained; reason"
809                    },
810                    reason(if r.summary.trim().is_empty() {
811                        if ja { "記録なし" } else { "not recorded" }
812                    } else {
813                        &r.summary
814                    })
815                );
816                if let Some(rv) = rv {
817                    let _ = writeln!(
818                        s,
819                        "  {}: {}",
820                        if ja {
821                            "再投票の回答なし"
822                        } else {
823                            "no reconsideration vote"
824                        },
825                        reason(rv.failed.as_deref().unwrap_or(if ja {
826                            "理由の記録なし"
827                        } else {
828                            "reason not recorded"
829                        }))
830                    );
831                }
832            }
833            if let Some(failed) = &r.failed {
834                let _ = writeln!(
835                    s,
836                    "  {}: {}",
837                    if ja { "回答なし" } else { "no answer" },
838                    reason(failed)
839                );
840            }
841        }
842    } else {
843        s.push_str(if ja {
844            "レビュー記録なし\n"
845        } else {
846            "No review recorded\n"
847        });
848    }
849    s.push_str(if ja { "\n内部レビュー指摘(最終ラウンド): 採用申告や gate 成功だけでは個別指摘の解決確認にはなりません。\n" } else { "\nInternal review findings (last round): adoption claims or a passing gate alone do not confirm individual resolution.\n" });
850    let findings = state.last_round_findings();
851    for f in &findings {
852        let _ = writeln!(
853            s,
854            "[{}] {} {}:{}: {}\n{}",
855            f.id,
856            f.severity,
857            f.file.as_deref().unwrap_or("?"),
858            f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
859            approval_excerpt(&f.title, TITLE_CHARS),
860            reason(&f.detail)
861        );
862        let mut evidence = false;
863        for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
864            state
865                .operator_fixes
866                .iter()
867                .filter_map(|request| request.fix.as_ref()),
868        ) {
869            if fix.addressed.contains(&f.id) {
870                evidence = true;
871                let _ = writeln!(
872                    s,
873                    "  {} ({}): {}",
874                    if ja {
875                        "採用申告(解決未確認)"
876                    } else {
877                        "claimed addressed (resolution unconfirmed)"
878                    },
879                    fix.agent,
880                    reason(&fix.notes)
881                );
882            }
883            for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
884                evidence = true;
885                let _ = writeln!(
886                    s,
887                    "  {}: {}",
888                    if ja { "不採用理由" } else { "declined" },
889                    reason(&rejection.why)
890                );
891            }
892        }
893        if !evidence {
894            s.push_str(if ja {
895                "  採用・解決確認の記録なし\n"
896            } else {
897                "  No adoption or resolution evidence recorded\n"
898            });
899        }
900    }
901    if findings.is_empty() {
902        s.push_str(if ja {
903            "最終ラウンドに指摘の記録なし\n"
904        } else {
905            "No findings recorded in the last round\n"
906        });
907    }
908    s
909}
910
911/// Shared choices for web, CLI and consultation questions.
912pub fn approval_choices(state: &RunState) -> Vec<String> {
913    let mut choices = vec![
914        APPROVE.to_owned(),
915        HOLD.to_owned(),
916        REPAIR.to_owned(),
917        crate::merge_decision::WITHDRAW.to_owned(),
918        crate::merge_decision::DISMISS.to_owned(),
919    ];
920    choices.extend(
921        state
922            .config
923            .agents
924            .iter()
925            .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
926    );
927    choices
928}
929
930/// Extend unanswered legacy cards in place. Answered cards keep their choices;
931/// a legacy repair answer gains only its missing dispatch action.
932pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
933    if q.node != APPROVAL_NODE
934        || !state
935            .land_approval
936            .as_ref()
937            .is_some_and(|a| a.question == q.id)
938    {
939        return false;
940    }
941    let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
942    if q.status.open() {
943        // Older cards attached Resume to hold; a hold must remain answerable
944        // without dispatching a question action.
945        q.actions.remove(HOLD);
946        for choice in approval_choices(state) {
947            if !q.choices.contains(&choice) {
948                q.choices.push(choice);
949            }
950        }
951        if q.merge_target.is_none() {
952            q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
953                pr: pr.url.clone(),
954                head: state.land_approval.as_ref().unwrap().head.clone(),
955                language: state.config.graph.language.clone(),
956            });
957        }
958    }
959    for choice in q.choices.clone() {
960        if (q.status.open()
961            && crate::merge_decision::stops(&choice)
962            && choice != HOLD
963            && choice != crate::merge_decision::DISMISS)
964            || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
965        {
966            q.actions.insert(
967                choice,
968                ask::ChoiceAction::Resume {
969                    run: state.id.clone(),
970                },
971            );
972        }
973    }
974    before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
975}
976
977/// Whether `quote` is a verbatim part of the owner's `message` and nothing in
978/// the whole message carries a hedge, a condition, a negation, a question or a
979/// retraction. Used by `deputy::destructive`.
980///
981/// The whole message is read, not just the quote's sentence: a condition or a
982/// second thought in another sentence ("Merge it. Only if CI passes.") makes
983/// the approval conditional all the same. Doubt anywhere is `false`.
984pub fn unhedged(message: &str, quote: &str) -> bool {
985    let quote = quote.trim();
986    !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
987}
988
989/// Hedging, conditional, negated or interrogative wording. ASCII words are
990/// matched as whole words so `note` is not `not`. A bare negation or stop word
991/// (`no`, `stop`, `nope`, ...) anywhere in the message voids the approval.
992fn hedged(text: &str) -> bool {
993    const WORDS: &[&str] = &[
994        "maybe",
995        "probably",
996        "perhaps",
997        "might",
998        "if",
999        "unless",
1000        "not",
1001        "no",
1002        "nope",
1003        "stop",
1004        "dont",
1005        "abort",
1006        "revert",
1007        "undo",
1008        "never",
1009        "wait",
1010        "hold",
1011        "cancel",
1012        "but",
1013        "think",
1014        "guess",
1015        "suppose",
1016        "unsure",
1017        "yet",
1018        "except",
1019        "only",
1020        "cannot",
1021        "should",
1022        "once",
1023        "provided",
1024        "providing",
1025        "when",
1026        "whenever",
1027        "after",
1028        "until",
1029        "before",
1030        "assuming",
1031        "given",
1032        "while",
1033        "whether",
1034        "depending",
1035        "pending",
1036    ];
1037    const JA: &[&str] = &[
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        "後で",
1070    ];
1071    if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
1072        return true;
1073    }
1074    text.to_lowercase()
1075        .replace('\u{2019}', "'")
1076        .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
1077        .filter(|w| !w.is_empty())
1078        .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
1079}
1080
1081/// Wording that takes back what the rest of the message said. Bare negation
1082/// and stop words are `hedged`'s whole-word list, not substrings here.
1083fn retracts(message: &str) -> bool {
1084    let lower = message.to_lowercase();
1085    [
1086        "やっぱ",
1087        "待って",
1088        "撤回",
1089        "never mind",
1090        "actually",
1091        "on second thought",
1092    ]
1093    .iter()
1094    .any(|w| lower.contains(w))
1095        || lower
1096            .split(|c: char| !c.is_ascii_alphabetic())
1097            .any(|w| w == "wait")
1098}
1099
1100/// Graph node recorded on the approval question.
1101///
1102/// The phone keys its high-stakes card off this rather than off the choice
1103/// strings, so renaming a button cannot silently downgrade the card that
1104/// guards the one irreversible action magi takes.
1105pub const APPROVAL_NODE: &str = "land-approval";
1106
1107/// Unified diff lines carried in the panel before it is truncated.
1108///
1109/// Four hundred: the panel is read on a 390px phone, where a diff line often
1110/// wraps to two rows, so this is already a few thousand rows of scrolling -
1111/// past that nobody is reading, and the bytes still count against the panel's
1112/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
1113/// cut and which worktree holds the whole patch.
1114pub const DIFF_MAX_LINES: usize = 400;
1115
1116/// What the owner's answer to the approval question means.
1117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1118pub enum Approval {
1119    /// The owner said [`APPROVE`]. Merge.
1120    Merge,
1121    /// Repair the existing PR, then ask again; never approval to merge.
1122    Repair,
1123    /// Anything else, including silence. Leave the pull request open.
1124    Hold,
1125}
1126
1127/// Read the owner's answer, where `None` is an unanswered question.
1128///
1129/// Silence is a hold. A timed-out question means the owner never saw it or
1130/// never decided, and defaulting an irreversible merge to "yes" would make this
1131/// gate worse than no gate at all: it would merge unattended while claiming to
1132/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
1133/// function does not recognise holds too.
1134pub fn approval(answer: Option<&str>) -> Approval {
1135    match answer {
1136        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1137        Some(a) if a.trim() == REPAIR => Approval::Repair,
1138        _ => Approval::Hold,
1139    }
1140}
1141
1142/// What [`approval_gate`] found on one check of the owner's merge decision.
1143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1144enum ApprovalGate {
1145    /// The owner said [`APPROVE`]. Merge.
1146    Approved,
1147    /// The owner explicitly requested repair, without authorizing a merge.
1148    Repair,
1149    /// The owner said anything else, the question timed out, or it was
1150    /// closed with no decision recorded.
1151    Held,
1152    /// Filed and still waiting - the caller parks rather than blocking on it.
1153    Pending,
1154}
1155
1156/// Escape text for HTML, including both quote characters.
1157///
1158/// Every string in the panel is agent-influenced: a branch name, a file path, a
1159/// commit subject, a review comment. The sandboxed frame stops such text from
1160/// *running*, but it does not stop a `<` from ending the document early or a
1161/// `"` from ending an attribute and inventing a new one - the panel would then
1162/// render a lie, or not render at all. Both quotes are escaped because the same
1163/// function is used inside attributes, where remembering which quote style the
1164/// caller used is one mistake away from an injected attribute.
1165fn esc(s: &str) -> String {
1166    let mut out = String::with_capacity(s.len());
1167    for c in s.chars() {
1168        match c {
1169            '&' => out.push_str("&amp;"),
1170            '<' => out.push_str("&lt;"),
1171            '>' => out.push_str("&gt;"),
1172            '"' => out.push_str("&quot;"),
1173            '\'' => out.push_str("&#39;"),
1174            _ => out.push(c),
1175        }
1176    }
1177    out
1178}
1179
1180/// One row of the diffstat table.
1181#[derive(Debug, Clone, PartialEq, Eq)]
1182struct StatRow {
1183    path: String,
1184    /// `None` for a binary file, which `git` reports as `-`.
1185    added: Option<u64>,
1186    removed: Option<u64>,
1187}
1188
1189impl StatRow {
1190    /// Lines touched, for sorting. A binary file counts as zero rather than as
1191    /// unknown, which puts it at the bottom where it needs no attention.
1192    fn churn(&self) -> u64 {
1193        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1194    }
1195}
1196
1197/// Parse `git diff --numstat` into rows, biggest churn first.
1198///
1199/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
1200/// terminal width, so counting its characters would print fabricated numbers in
1201/// the one table an operator approves an irreversible action from.
1202fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1203    let mut rows: Vec<StatRow> = numstat
1204        .lines()
1205        .filter_map(|line| {
1206            let mut parts = line.splitn(3, '\t');
1207            let added = parts.next()?.trim();
1208            let removed = parts.next()?.trim();
1209            let path = parts.next()?.trim();
1210            if path.is_empty() {
1211                return None;
1212            }
1213            Some(StatRow {
1214                path: path.to_owned(),
1215                added: added.parse().ok(),
1216                removed: removed.parse().ok(),
1217            })
1218        })
1219        .collect();
1220    // Path breaks the tie so the same change always renders the same table; an
1221    // operator comparing two panels should not see rows shuffle.
1222    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1223    rows
1224}
1225
1226/// How one diff line is shown: a gutter character, a style, and the body to
1227/// print - which is the line minus its marker, so the marker appears exactly
1228/// once, in the gutter.
1229///
1230/// The gutter is why this exists at all. The operator may be colour blind, or
1231/// reading in sunlight with the screen dimmed, so an added line is never
1232/// distinguished by its background alone: `+` and `-` sit in a fixed column,
1233/// the same mark they already read in a terminal.
1234fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1235    if line.starts_with("+++") || line.starts_with("---") {
1236        (" ", "color:#57606a;font-weight:600", line)
1237    } else if let Some(body) = line.strip_prefix('+') {
1238        ("+", "background:#e6ffec;color:#0a3622", body)
1239    } else if let Some(body) = line.strip_prefix('-') {
1240        ("-", "background:#ffebe9;color:#5c1a17", body)
1241    } else if line.starts_with("@@") {
1242        ("~", "background:#eef2ff;color:#3730a3", line)
1243    } else if let Some(body) = line.strip_prefix(' ') {
1244        (" ", "", body)
1245    } else {
1246        (" ", "color:#57606a;font-weight:600", line)
1247    }
1248}
1249
1250/// The handful of words the approval panel says in its own voice.
1251///
1252/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
1253/// the operator asked why the merge question spoke English on a repository
1254/// configured for Japanese, and "because that string is a literal in Rust" is
1255/// not an answer. Only the languages magi can actually check are translated;
1256/// anything else falls back to English rather than shipping a guess, and that
1257/// fallback is deliberate.
1258struct Words {
1259    html_lang: &'static str,
1260    task: &'static str,
1261    what_changed: &'static str,
1262    review_verdict: &'static str,
1263    reviewer: &'static str,
1264    reviewer_no_answer: &'static str,
1265    checks: &'static str,
1266    nothing_failing: &'static str,
1267    files_changed: &'static str,
1268    commits: &'static str,
1269    no_commits: &'static str,
1270    comments: &'static str,
1271    no_comments: &'static str,
1272    diff: &'static str,
1273    truncated: &'static str,
1274    lands_as: &'static str,
1275}
1276
1277const EN: Words = Words {
1278    html_lang: "en",
1279    task: "Task",
1280    what_changed: "What changed",
1281    review_verdict: "Review verdict",
1282    reviewer: "Reviewer",
1283    reviewer_no_answer: "produced no answer",
1284    checks: "Checks",
1285    nothing_failing: "Nothing failing.",
1286    files_changed: "file(s) changed",
1287    commits: "Commits being squashed",
1288    no_commits: "No commit subjects could be read from the branch.",
1289    comments: "External PR review comments",
1290    no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1291    diff: "Diff",
1292    truncated: "Truncated",
1293    lands_as: "They land as one commit titled",
1294};
1295
1296const JA: Words = Words {
1297    html_lang: "ja",
1298    task: "タスク",
1299    what_changed: "変更内容",
1300    review_verdict: "レビューの結論",
1301    reviewer: "レビュアー",
1302    reviewer_no_answer: "回答なし",
1303    checks: "チェック",
1304    nothing_failing: "失敗しているものはありません。",
1305    files_changed: "ファイル変更",
1306    commits: "squash されるコミット",
1307    no_commits: "ブランチからコミット件名を読めませんでした。",
1308    comments: "外部 PR レビューコメント",
1309    no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1310    diff: "差分",
1311    truncated: "省略",
1312    lands_as: "これらは次の件名の1コミットとして入ります:",
1313};
1314
1315impl Words {
1316    /// The clause after the merge subject. Split out because word order moves:
1317    /// Japanese puts the subject before the verb, so a shared template with a
1318    /// hole in the middle would read as machine translation.
1319    fn lands_as_tail(&self) -> &'static str {
1320        if self.html_lang == "ja" {
1321            "。この件名も承認の対象です。"
1322        } else {
1323            ", which you are approving too."
1324        }
1325    }
1326
1327    /// The question's own one-line summary, which is what a phone shows first.
1328    fn approval_summary(&self, number: u64, subject: &str) -> String {
1329        if self.html_lang == "ja" {
1330            format!("プルリクエスト #{number} をマージ: {subject}")
1331        } else {
1332            format!("merge pull request #{number}: {subject}")
1333        }
1334    }
1335
1336    /// The body under the summary, above the panel.
1337    fn approval_detail(
1338        &self,
1339        url: &str,
1340        base: &str,
1341        subject: &str,
1342        contested: Option<&ContestedHandoff>,
1343    ) -> String {
1344        let body = if self.html_lang == "ja" {
1345            format!(
1346                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1347                 できる状態です。差分の要約・パッチ・squash されるコミットは\
1348                 下のパネルにあります。"
1349            )
1350        } else {
1351            format!(
1352                "{url} is green and ready to squash into `{base}` as `{subject}`. \
1353                 The panel holds the diffstat, the patch and the commits being squashed."
1354            )
1355        };
1356        match contested {
1357            Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1358            None => body,
1359        }
1360    }
1361
1362    /// Why this question exists although merge approvals are off: the open
1363    /// blocking findings and who rejected. Short enough for a phone.
1364    fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1365        const SHOWN: usize = 5;
1366        const TITLE_CHARS: usize = 100;
1367        let ja = self.html_lang == "ja";
1368        let mut out = if ja {
1369            format!(
1370                "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1371                 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1372            )
1373        } else {
1374            format!(
1375                "{url} is held for approval although merge approvals are off: the \
1376                 review ended with blocking findings still open and a reviewer \
1377                 voting reject.\n"
1378            )
1379        };
1380        for f in c.findings.iter().take(SHOWN) {
1381            let at = match (&f.file, f.line) {
1382                (Some(file), Some(line)) => format!("{file}:{line}"),
1383                (Some(file), None) => file.clone(),
1384                _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1385            };
1386            let title: String = f.title.chars().take(TITLE_CHARS).collect();
1387            let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1388        }
1389        if c.findings.len() > SHOWN {
1390            let more = c.findings.len() - SHOWN;
1391            let _ = writeln!(
1392                out,
1393                "{}",
1394                if ja {
1395                    format!("- ほか {more} 件")
1396                } else {
1397                    format!("- and {more} more")
1398                }
1399            );
1400        }
1401        let seats: Vec<String> = c
1402            .rejecters
1403            .iter()
1404            .map(|(seat, agent)| format!("#{seat} ({agent})"))
1405            .collect();
1406        let _ = write!(
1407            out,
1408            "{} {}",
1409            if ja {
1410                "却下したレビュアー:"
1411            } else {
1412                "Rejected by reviewer:"
1413            },
1414            seats.join(", ")
1415        );
1416        out
1417    }
1418
1419    /// The truncation note, written whole in each language for the same reason.
1420    fn truncated_note(
1421        &self,
1422        omitted: usize,
1423        total: usize,
1424        shown: usize,
1425        where_: &str,
1426        base: &str,
1427        head: &str,
1428    ) -> String {
1429        if self.html_lang == "ja" {
1430            format!(
1431                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1432                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1433                 プルリクエストにあります。"
1434            )
1435        } else {
1436            format!(
1437                "{omitted} of {total} diff lines omitted after the first {shown}. \
1438                 The whole patch is in <code>{where_}</code> \
1439                 (<code>git diff {base}...{head}</code>) and on the pull request."
1440            )
1441        }
1442    }
1443}
1444
1445/// Pick the panel's language. Codes and names both, because `[graph] language`
1446/// has always accepted either.
1447fn words(language: &str) -> &'static Words {
1448    if crate::lang::is_japanese(language) {
1449        &JA
1450    } else {
1451        &EN
1452    }
1453}
1454
1455/// The approval panel's html: what is about to land, and the evidence for it.
1456///
1457/// Pure, so the whole document is asserted in tests without `gh`, without a
1458/// network and without a repository. The caller gathers `diffstat`
1459/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
1460/// being squashed) and `subject` (what the squash will be called) from the
1461/// winner's worktree.
1462///
1463/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
1464/// content security policy blocks all three: anything of the sort here would be
1465/// dead markup that misleads the next reader into thinking it works.
1466pub fn approval_panel(
1467    state: &RunState,
1468    pr: &PrState,
1469    diffstat: &str,
1470    diff: &str,
1471    commits: &[String],
1472    subject: &str,
1473) -> String {
1474    let rows = parse_numstat(diffstat);
1475    let w = words(&state.config.graph.language);
1476    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1477
1478    let _ = writeln!(
1479        h,
1480        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1481         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1482        w.html_lang
1483    );
1484    let _ = writeln!(
1485        h,
1486        "<title>merge #{} — {}</title>\n</head>",
1487        pr.number,
1488        esc(subject)
1489    );
1490    h.push_str(
1491        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1492         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1493         word-break:break-word\">\n",
1494    );
1495
1496    // The decision, in the words the operator is approving.
1497    let _ = writeln!(
1498        h,
1499        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1500         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1501         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1502         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1503         <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1504        pr.number,
1505        esc(&state.base_branch),
1506        esc(subject),
1507        esc(&state.id),
1508        esc(&pr.url),
1509        esc(&pr.url),
1510    );
1511
1512    let _ = writeln!(
1513        h,
1514        "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1515        esc(&approval_evidence(state))
1516    );
1517
1518    // The task, verbatim: the operator's own words for what was asked, so the
1519    // panel does not make them reconstruct the request from a diffstat.
1520    let _ = writeln!(
1521        h,
1522        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1523         <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1524        w.task,
1525        esc(&state.instruction)
1526    );
1527
1528    // The winner's own account of what it did and why, when there is one.
1529    if let Some(summary) = state
1530        .winner()
1531        .map(|c| c.summary.as_str())
1532        .filter(|s| !s.is_empty())
1533    {
1534        let _ = writeln!(
1535            h,
1536            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1537             <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1538            w.what_changed,
1539            esc(summary)
1540        );
1541    }
1542
1543    // Only the last round's reviewer prose is current. Its votes and
1544    // remaining findings are shown explicitly in the evidence above.
1545    if let Some(round) = state.reviews.last() {
1546        let _ = writeln!(
1547            h,
1548            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1549            w.review_verdict
1550        );
1551        for r in &round.reviews {
1552            // A seat the review loop counted as answered has real prose in
1553            // `summary`; one it counted against `incomplete` (see
1554            // `graph::Runner::review_loop`) never produced any and left it
1555            // empty - which must not be read back as a blank verdict, since
1556            // an empty box here looks like "nothing to say" rather than
1557            // "never answered".
1558            let body = match &r.failed {
1559                Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1560                None => esc(&r.summary),
1561            };
1562            let _ = writeln!(
1563                h,
1564                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1565                 border-radius:6px\">\
1566                 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1567                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1568                w.reviewer,
1569                r.reviewer,
1570                esc(&r.agent),
1571                body,
1572            );
1573        }
1574    }
1575
1576    let _ = writeln!(
1577        h,
1578        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1579        w.checks,
1580        esc(pr.checks.as_str())
1581    );
1582    if pr.failing.is_empty() {
1583        let _ = writeln!(
1584            h,
1585            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1586            w.nothing_failing
1587        );
1588    } else {
1589        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1590        for f in &pr.failing {
1591            let _ = writeln!(h, "<li>{}</li>", esc(f));
1592        }
1593        h.push_str("</ul>\n");
1594    }
1595
1596    // Diffstat as a real table, so a phone reads what moved without scrolling
1597    // sideways through a terminal bar chart.
1598    let _ = writeln!(
1599        h,
1600        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1601        rows.len(),
1602        w.files_changed
1603    );
1604    h.push_str(
1605        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1606         <thead><tr>\
1607         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1608         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1609         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1610         </th></tr></thead>\n<tbody>\n",
1611    );
1612    let mut total_added = 0u64;
1613    let mut total_removed = 0u64;
1614    for r in &rows {
1615        total_added += r.added.unwrap_or(0);
1616        total_removed += r.removed.unwrap_or(0);
1617        let cell = |n: Option<u64>| match n {
1618            Some(n) => n.to_string(),
1619            None => "bin".to_owned(),
1620        };
1621        let _ = writeln!(
1622            h,
1623            "<tr>\
1624             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1625             font-family:ui-monospace,monospace\">{}</td>\
1626             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1627             color:#0a3622\">{}</td>\
1628             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1629             color:#5c1a17\">{}</td></tr>",
1630            esc(&r.path),
1631            cell(r.added),
1632            cell(r.removed),
1633        );
1634    }
1635    let _ = writeln!(
1636        h,
1637        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1638         <td style=\"padding:4px 2px\">total</td>\
1639         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1640         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1641         </tr></tfoot>\n</table>"
1642    );
1643
1644    // The commits being squashed, and the subject that replaces them.
1645    let _ = writeln!(
1646        h,
1647        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1648        w.commits
1649    );
1650    if commits.is_empty() {
1651        h.push_str(&format!(
1652            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1653            w.no_commits
1654        ));
1655    } else {
1656        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1657        for c in commits {
1658            let _ = writeln!(h, "<li>{}</li>", esc(c));
1659        }
1660        h.push_str("</ol>\n");
1661    }
1662    let _ = writeln!(
1663        h,
1664        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1665        w.lands_as,
1666        esc(subject),
1667        w.lands_as_tail()
1668    );
1669
1670    // The review comments that shaped this branch, and who asked for them.
1671    let _ = writeln!(
1672        h,
1673        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1674        w.comments
1675    );
1676    if pr.review_comments.is_empty() {
1677        h.push_str(&format!(
1678            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1679            w.no_comments
1680        ));
1681    } else {
1682        for c in &pr.review_comments {
1683            let anchor = match (&c.path, c.line) {
1684                (Some(p), Some(l)) => format!("{p}:{l}"),
1685                (Some(p), None) => p.clone(),
1686                _ => "pull request thread".to_owned(),
1687            };
1688            let _ = writeln!(
1689                h,
1690                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1691                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1692                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1693                esc(&c.author),
1694                esc(&anchor),
1695                esc(&tail(&c.body, 800)),
1696            );
1697        }
1698    }
1699
1700    // The patch itself.
1701    let total = diff.lines().count();
1702    let shown = total.min(DIFF_MAX_LINES);
1703    let _ = writeln!(
1704        h,
1705        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1706        w.diff
1707    );
1708    h.push_str(
1709        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1710         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1711    );
1712    for line in diff.lines().take(shown) {
1713        let (gutter, style, body) = diff_row(line);
1714        let _ = writeln!(
1715            h,
1716            "<div style=\"display:flex;{style}\">\
1717             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1718             border-right:1px solid #d0d7de\">{gutter}</span>\
1719             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1720            esc(body),
1721        );
1722    }
1723    h.push_str("</div>\n");
1724    if total > shown {
1725        let omitted = total - shown;
1726        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1727        let where_ = state.winner().map_or_else(
1728            || state.repo.display().to_string(),
1729            |w| w.worktree.display().to_string(),
1730        );
1731        let _ = writeln!(
1732            h,
1733            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1734             font-size:13px\">{}: {}</p>",
1735            w.truncated,
1736            w.truncated_note(
1737                omitted,
1738                total,
1739                shown,
1740                &esc(&where_),
1741                &esc(&state.base_branch),
1742                &esc(head),
1743            ),
1744        );
1745    }
1746
1747    h.push_str("</body>\n</html>\n");
1748    h
1749}
1750
1751/// Historical contested hand-off evidence for the question detail. Approval
1752/// itself is decided by `approval_reasons`, including reject-only cases.
1753fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1754    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
1755        state.contested_handoff.clone()
1756    } else {
1757        None
1758    }
1759}
1760
1761/// What a merge-approval question's deputy is told: the pull request, the run,
1762/// what each answer does, and the evidence saved when the question was filed.
1763///
1764/// A snapshot taken when the deputy is attached, so it says so and points at
1765/// `magi show` / `gh pr view` for anything current. `state` is `None` for a run
1766/// that cannot be read; what is missing is named as missing, and no past
1767/// approval basis is rebuilt from today's state.
1768pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1769    let mut s = format!(
1770        "This is the merge approval for run {run} (`magi show {run}`). The question's \
1771         own text above names the pull request. Answering `{APPROVE}` squash-merges \
1772         it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1773         request open. Silence is a hold: the owner not answering never merges. Only \
1774         the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1775         own words, merges. Whether their wording is a clear, unconditional instruction \
1776         is your judgement alone: if it is doubtful, conditional, retracted or a \
1777         question, do not settle - ask back with `magi ask --thread`. Doubt and \
1778         silence are a hold.\n\n\
1779         This brief is a snapshot from when you were attached: check `magi show {run}` \
1780         and `gh pr view` (read-only) before telling the owner anything current. \
1781         You run with permission to write the question record, and what keeps you \
1782         from touching anything else is this brief and your instructions - so do \
1783         not change files, branches or the pull request.",
1784        run = q.run
1785    );
1786    if let Some(target) = &q.merge_target {
1787        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));
1788    }
1789    if q.choices.iter().any(|c| c == REPAIR) {
1790        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.");
1791    }
1792    if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1793    {
1794        s.push_str("\n\nSaved approval evidence (at question creation):\n");
1795        s.push_str(&q.detail);
1796    } else {
1797        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.");
1798    }
1799    let Some(state) = state else {
1800        s.push_str(
1801            "\n\nThe run's record could not be read, so the pull request, the panel \
1802             summary and any contested findings are not known to you beyond the \
1803             question's own text. Say so to the owner rather than guessing.",
1804        );
1805        return s;
1806    };
1807    if let Some(pr) = &state.pr {
1808        s.push_str(&format!(
1809            "\n\nPull request #{} {} (recorded state: {}, last seen).",
1810            pr.number, pr.url, pr.state
1811        ));
1812    }
1813    s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1814    if let Some(w) = state.winner() {
1815        s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1816    }
1817
1818    s
1819}
1820
1821// Releasing a task permits work to resume; it does not renew merge consent.
1822// Check the persisted operator decision both when reusing a card and at the
1823// final merge boundary, since a hold/release can arrive between those reads.
1824fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1825    if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1826        return Ok(false);
1827    }
1828    let Some(task_id) = state
1829        .origin
1830        .as_ref()
1831        .and_then(|origin| origin.task.as_ref())
1832    else {
1833        return Ok(false);
1834    };
1835    let task = crate::queue::Queue::open()
1836        .get(task_id)
1837        .context("cannot verify operator decisions for the merge approval")?;
1838    Ok(task
1839        .operator_decided_at
1840        .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1841}
1842
1843/// Ask the owner before merging, with the whole case attached as a panel.
1844///
1845/// The evidence is gathered from the winner's own worktree with the `git` CLI,
1846/// never from the network, so a phone on a slow link gets the diff magi is
1847/// looking at rather than a link it has to go and open.
1848///
1849/// Never blocks. `land` used to sit inside [`ask::ask_and_wait`]'s poll loop
1850/// for up to a day right here, which held the whole run's task claim - and
1851/// the daemon's one slot with it - for exactly as long as the owner took to
1852/// notice their phone. [`ApprovalGate::Pending`] is the answer that lets the
1853/// caller park the run and hand the slot back instead: the question is on
1854/// disk either way, so nothing about the wait itself changes, only who is
1855/// blocked on it.
1856///
1857/// Idempotent across resumes: called again for a run already waiting on its
1858/// own question, this finds that question by [`crate::ask::Questions::list`]
1859/// rather than filing a second one - asking twice would double the
1860/// notification for one decision, and leave the first question's panel an
1861/// orphan nobody's answer ever reaches.
1862async fn approval_gate(
1863    state: &mut RunState,
1864    pr: &PrState,
1865    subject: &str,
1866    contested: Option<&ContestedHandoff>,
1867    head: &str,
1868) -> Result<ApprovalGate> {
1869    let store = ask::Questions::open();
1870    // An answer belongs to the commit its panel showed. A question recorded
1871    // for another head - or none recorded at all, as for a question filed
1872    // before heads were tracked - is never reused: a fix or rebase push made
1873    // a commit the owner has not seen, and their word does not carry over.
1874    let reusable = state.land_approval.as_ref().and_then(|a| {
1875        store
1876            .list()
1877            .into_iter()
1878            .find(|q| q.id == a.question)
1879            .filter(|q| {
1880                q.status != ask::QuestionStatus::Abandoned
1881                    && (a.head.eq_ignore_ascii_case(head)
1882                        || q.resolution()
1883                            .as_deref()
1884                            .is_some_and(crate::merge_decision::stops))
1885            })
1886    });
1887    let reusable = match reusable {
1888        Some(q) if merge_consent_superseded(state, &q)? => None,
1889        other => other,
1890    };
1891    if reusable.is_none() {
1892        for stale in store
1893            .list()
1894            .into_iter()
1895            .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1896        {
1897            let why =
1898                "the pull request head or operator decision changed; asked again for approval";
1899            if let Err(e) = store.update(&stale.id, |q| {
1900                q.abandon(why);
1901                Ok(())
1902            }) {
1903                tracing::warn!("could not retire the superseded approval question: {e:#}");
1904            }
1905        }
1906    }
1907
1908    let q = match reusable {
1909        Some(mut q) => {
1910            if upgrade_repair_choice(&mut q, state) {
1911                q = store
1912                    .update(&q.id, |current| {
1913                        upgrade_repair_choice(current, state);
1914                        Ok(())
1915                    })?
1916                    .0;
1917            }
1918            q
1919        }
1920        None => {
1921            let worktree = match state.winner() {
1922                Some(w) => w.worktree.clone(),
1923                None => state.repo.clone(),
1924            };
1925            // The diff is built from the commit being approved, not from the
1926            // branch name, which may already point somewhere else.
1927            let head = if head.is_empty() {
1928                state
1929                    .winner()
1930                    .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1931            } else {
1932                head.to_owned()
1933            };
1934            // The diff is against what the remote's base holds, never the
1935            // local branch of that name; unreadable means less evidence.
1936            let remote = &state.config.merge.remote;
1937            let tracking = format!("{remote}/{}", state.base_branch);
1938            let base = if git::rev_exists(&worktree, &tracking).await {
1939                tracking
1940            } else {
1941                String::new()
1942            };
1943            let range = format!("{base}...{head}");
1944            // A failed `git` must not decide the merge: the panel degrades to
1945            // less evidence and the owner still chooses. Merging because the
1946            // diff could not be read would be the worst of both.
1947            let numstat = if base.is_empty() {
1948                String::new()
1949            } else {
1950                git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1951                    .await
1952                    .map(|o| o.stdout)
1953                    .unwrap_or_default()
1954            };
1955            let diff = if base.is_empty() {
1956                String::new()
1957            } else {
1958                git::diff(&worktree, &base, &head).await.unwrap_or_default()
1959            };
1960            let commits: Vec<String> = if base.is_empty() {
1961                Vec::new()
1962            } else {
1963                git::git_raw(
1964                    &worktree,
1965                    &[
1966                        "log",
1967                        "--reverse",
1968                        "--format=%s",
1969                        &format!("{base}..{head}"),
1970                    ],
1971                )
1972                .await
1973                .map(|o| o.stdout)
1974                .unwrap_or_default()
1975                .lines()
1976                .filter(|l| !l.trim().is_empty())
1977                .map(str::to_owned)
1978                .collect()
1979            };
1980
1981            let w = words(&state.config.graph.language);
1982            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1983            let mut fresh = ask::Question::new(
1984                state.id.clone(),
1985                APPROVAL_NODE.to_owned(),
1986                "land".to_owned(),
1987                w.approval_summary(pr.number, subject),
1988                format!(
1989                    "{}\n\n{}",
1990                    approval_evidence(state),
1991                    w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1992                ),
1993                approval_choices(state),
1994            );
1995            fresh.merge_target = Some(crate::merge_decision::Target {
1996                pr: pr.url.clone(),
1997                head: head.clone(),
1998                language: state.config.graph.language.clone(),
1999            });
2000            if fresh.choices.iter().any(|c| c == REPAIR) {
2001                fresh.actions.insert(
2002                    REPAIR.to_owned(),
2003                    ask::ChoiceAction::Resume {
2004                        run: state.id.clone(),
2005                    },
2006                );
2007                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.");
2008            }
2009            for choice in fresh.choices.clone() {
2010                if crate::merge_decision::stops(&choice)
2011                    && choice != HOLD
2012                    && choice != crate::merge_decision::DISMISS
2013                {
2014                    fresh.actions.insert(
2015                        choice,
2016                        ask::ChoiceAction::Resume {
2017                            run: state.id.clone(),
2018                        },
2019                    );
2020                }
2021            }
2022            store
2023                .put_panel(&mut fresh, &html, &[])
2024                .context("write the merge approval panel")?;
2025            store
2026                .put_executable(&mut fresh)
2027                .context("file the merge approval question")?;
2028            state.land_approval = Some(LandApproval {
2029                question: fresh.id.clone(),
2030                head: head.clone(),
2031            });
2032            state.event(
2033                "land",
2034                format!("asking for merge approval ({})", fresh.short()),
2035            );
2036            state.save()?;
2037            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
2038                // A broken webhook is not a reason to lose the merge: the
2039                // question is already on disk and the web UI already shows
2040                // it, so the operator still has a way in.
2041                tracing::warn!(
2042                    "could not notify about merge approval question {}: {e:#} - \
2043                     the web UI is the only surface for it now",
2044                    fresh.short()
2045                );
2046            }
2047            fresh
2048        }
2049    };
2050
2051    Ok(match q.status {
2052        ask::QuestionStatus::Open => ApprovalGate::Pending,
2053        // Nobody answered before `state.config.graph.answer_timeout` passed,
2054        // or the question was closed with no decision recorded underneath
2055        // this run - either way there is nothing left to wait on.
2056        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
2057        // The merge gate does not speak `--thread`: an owner who talked back
2058        // instead of choosing never reaches `Answered`, so this arm only
2059        // ever sees an actual decision.
2060        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
2061            Approval::Merge => ApprovalGate::Approved,
2062            Approval::Repair if !state.reviews.is_empty() => {
2063                if !state.merge_repairs.iter().any(|r| r.question == q.id) {
2064                    state.merge_repairs.push(crate::run::MergeRepair {
2065                        question: q.id.clone(),
2066                        head: head.to_owned(),
2067                        pr: pr.url.clone(),
2068                        reviews: state.reviews.clone(),
2069                        gate: state.gate.clone(),
2070                        gate_head: state.gate_head.clone(),
2071                        gate_fixes: state.gate_fixes.clone(),
2072                        pre_gate: state.pre_gate.clone(),
2073                        phase: crate::run::MergeRepairPhase::Requested,
2074                        failure: None,
2075                    });
2076                    state.land_approval = None;
2077                    state.event(
2078                        "repair",
2079                        "owner requested fix findings and re-review; merge is not approved",
2080                    );
2081                    state.save()?;
2082                }
2083                ApprovalGate::Repair
2084            }
2085            Approval::Repair | Approval::Hold => ApprovalGate::Held,
2086        },
2087    })
2088}
2089
2090/// Fold a rollup's entries into one verdict plus the failing checks' labels.
2091/// No I/O. An empty rollup is `Unknown`, never green.
2092fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
2093    let mut failing = Vec::new();
2094    let mut pending = false;
2095    let mut unknown = false;
2096    for check in rollup {
2097        match check.verdict() {
2098            Verdict::Pass => {}
2099            Verdict::Pending => pending = true,
2100            Verdict::Fail => failing.push(check.label()),
2101            Verdict::Unknown => unknown = true,
2102        }
2103    }
2104    let checks = if rollup.is_empty() {
2105        Checks::Unknown
2106    } else if pending {
2107        Checks::Pending
2108    } else if !failing.is_empty() {
2109        Checks::Red
2110    } else if unknown {
2111        Checks::Unknown
2112    } else {
2113        Checks::Green
2114    };
2115    (checks, failing)
2116}
2117
2118/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
2119/// output into a [`PrState`]. No I/O.
2120pub fn parse_pr(json: &str) -> Result<PrState> {
2121    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2122    let state = match raw.state.to_ascii_uppercase().as_str() {
2123        "OPEN" => PrLifecycle::Open,
2124        "MERGED" => PrLifecycle::Merged,
2125        "CLOSED" => PrLifecycle::Closed,
2126        other => bail!("unknown pull request state `{other}`"),
2127    };
2128
2129    let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2130
2131    let mut review_comments = Vec::new();
2132    for r in raw.reviews {
2133        push_if_outstanding(
2134            &mut review_comments,
2135            ReviewComment {
2136                author: r.author.login,
2137                path: None,
2138                line: None,
2139                body: r.body,
2140            },
2141        );
2142    }
2143    for c in raw.comments {
2144        push_if_outstanding(
2145            &mut review_comments,
2146            ReviewComment {
2147                author: c.author.login,
2148                path: None,
2149                line: None,
2150                body: c.body,
2151            },
2152        );
2153    }
2154
2155    Ok(PrState {
2156        url: raw.url,
2157        number: raw.number,
2158        state,
2159        checks,
2160        failing,
2161        review_comments,
2162        blocking: Blocking::of(&raw.merge_state_status),
2163    })
2164}
2165
2166/// One rollup entry as the release watcher reads it.
2167#[derive(Debug, Clone, PartialEq, Eq)]
2168pub(crate) struct CheckView {
2169    pub name: String,
2170    pub verdict: Verdict,
2171    /// Workflow run behind the check, when its url names one.
2172    pub run: Option<String>,
2173    pub url: Option<String>,
2174}
2175
2176/// A pull request's lifecycle, head commit and per-check verdicts, without
2177/// [`rollup_verdict`]'s aggregation (a pending check anywhere hides a failure
2178/// from it, which is exactly what the release watcher must not inherit).
2179#[derive(Debug, Clone, PartialEq, Eq)]
2180pub(crate) struct RollupView {
2181    pub url: String,
2182    pub number: u64,
2183    pub state: PrLifecycle,
2184    pub head: String,
2185    pub checks: Vec<CheckView>,
2186}
2187
2188/// Parse `gh pr view --json url,number,state,headRefOid,statusCheckRollup`
2189/// output. No I/O.
2190pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2191    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2192    let state = match raw.state.to_ascii_uppercase().as_str() {
2193        "OPEN" => PrLifecycle::Open,
2194        "MERGED" => PrLifecycle::Merged,
2195        "CLOSED" => PrLifecycle::Closed,
2196        other => bail!("unknown pull request state `{other}`"),
2197    };
2198    let checks = raw
2199        .status_check_rollup
2200        .iter()
2201        .map(|c| CheckView {
2202            name: c.label(),
2203            verdict: c.verdict(),
2204            run: c.url().and_then(run_of),
2205            url: c.url().map(str::to_owned),
2206        })
2207        .collect();
2208    Ok(RollupView {
2209        url: raw.url,
2210        number: raw.number,
2211        state,
2212        head: raw.head_ref_oid,
2213        checks,
2214    })
2215}
2216
2217/// Read just a pull request's lifecycle state - open, merged, or closed -
2218/// with none of the checks/reviews/comments [`land`] itself needs to decide
2219/// what to do next.
2220///
2221/// For a caller that only ever wants one fact and must not risk anything
2222/// else: `magi fold --merged` uses this to confirm a URL the operator hands
2223/// it is actually a merged pull request *before* touching a run's state, so a
2224/// typo or a still-open PR fails loudly instead of quietly recording a merge
2225/// that never happened.
2226pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2227    let view = gh(
2228        repo,
2229        &[
2230            "pr".to_owned(),
2231            "view".to_owned(),
2232            pr_url.to_owned(),
2233            "--json".to_owned(),
2234            "state".to_owned(),
2235        ],
2236    )
2237    .await?;
2238    if !view.0 {
2239        bail!("gh pr view {pr_url}: {}", view.1);
2240    }
2241    // `parse_pr` reads every other field of `GhPr` as its serde default
2242    // (empty string, empty vec, zero) when this narrower `--json` selection
2243    // does not carry them - harmless, since only `.state` is read back.
2244    Ok(parse_pr(&view.1)?.state)
2245}
2246
2247/// A pull request the operator merged outside of `land::land`'s own loop,
2248/// found by asking GitHub about the run's own winning branch rather than
2249/// requiring the operator to go and find the URL themselves.
2250#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2251pub struct ExternalMerge {
2252    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
2253    pub url: String,
2254    /// The pull request's number.
2255    pub number: u64,
2256}
2257
2258#[derive(Debug, Deserialize)]
2259#[serde(rename_all = "camelCase")]
2260struct GhMergedPr {
2261    url: String,
2262    number: u64,
2263    merged_at: String,
2264    base_ref_name: String,
2265}
2266
2267/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
2268/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
2269/// decide whether exactly one of the pull requests it lists could actually
2270/// be *this* run's.
2271///
2272/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
2273/// from the run's own short id, so a collision with some other, unrelated
2274/// task's merged pull request from a same-named branch is rare but not
2275/// impossible once branches are deleted and ids run out. Filtering on
2276/// `base_ref_name` (the branch this run actually targets) and `merged_at`
2277/// (which cannot predate the run itself) rules that case out. More than one
2278/// survivor is exactly as uninformative as zero — something this run cannot
2279/// tell apart from another — so only a unique survivor is returned.
2280fn pick_merged_pr(
2281    json: &str,
2282    base_branch: &str,
2283    created_at: Timestamp,
2284) -> Result<Option<ExternalMerge>> {
2285    let raw: Vec<GhMergedPr> =
2286        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2287    let mut matches: Vec<ExternalMerge> = Vec::new();
2288    for pr in raw {
2289        if pr.base_ref_name != base_branch {
2290            continue;
2291        }
2292        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2293            continue;
2294        };
2295        if merged_at < created_at {
2296            continue;
2297        }
2298        matches.push(ExternalMerge {
2299            url: pr.url,
2300            number: pr.number,
2301        });
2302    }
2303    if matches.len() == 1 {
2304        Ok(matches.pop())
2305    } else {
2306        Ok(None)
2307    }
2308}
2309
2310/// What `gh pr list --head <branch> --base <base> --state open` found.
2311#[derive(Debug, Clone, PartialEq, Eq)]
2312pub enum OpenPr {
2313    /// Nothing open: the caller creates one.
2314    None,
2315    /// Exactly one: the caller adopts it instead of creating a second.
2316    One {
2317        /// The pull request's URL.
2318        url: String,
2319        /// Its current title.
2320        title: String,
2321    },
2322    /// More than one: magi does not pick between them.
2323    Many(Vec<String>),
2324}
2325
2326#[derive(Debug, Deserialize)]
2327#[serde(rename_all = "camelCase")]
2328struct GhOpenPr {
2329    // `url` and `baseRefName` are required: a record missing either must be a
2330    // parse error, not a pull request that silently fails the base filter and
2331    // reads as "none open" (which would go on to create a duplicate).
2332    url: String,
2333    #[serde(default)]
2334    title: String,
2335    base_ref_name: String,
2336    // A fork's branch of the same name is not the task head; `gh pr list --head`
2337    // matches by branch name alone, so the filter has to be ours.
2338    #[serde(default)]
2339    is_cross_repository: bool,
2340}
2341
2342/// Pure half of [`find_open_pr`]: classify the raw `--json
2343/// number,url,title,baseRefName` output. Entries whose base is not `base` are
2344/// dropped even though the query already filtered on it, so a stub or an old
2345/// `gh` that ignores `--base` cannot get a pull request into the wrong branch
2346/// adopted.
2347pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2348    let raw: Vec<GhOpenPr> =
2349        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2350    let mut hits: Vec<GhOpenPr> = raw
2351        .into_iter()
2352        .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2353        .collect();
2354    Ok(match hits.len() {
2355        0 => OpenPr::None,
2356        1 => {
2357            let p = hits.remove(0);
2358            OpenPr::One {
2359                url: p.url,
2360                title: p.title,
2361            }
2362        }
2363        _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2364    })
2365}
2366
2367/// Open pull requests whose head is `branch` and whose base is `base`. A
2368/// failing `gh` is an error carrying its own output, never "none": guessing
2369/// there is how a duplicate gets created.
2370pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2371    let (ok, out) = gh(
2372        repo,
2373        &[
2374            "pr".to_owned(),
2375            "list".to_owned(),
2376            "--head".to_owned(),
2377            branch.to_owned(),
2378            "--base".to_owned(),
2379            base.to_owned(),
2380            "--state".to_owned(),
2381            "open".to_owned(),
2382            "--json".to_owned(),
2383            "number,url,title,baseRefName,isCrossRepository".to_owned(),
2384        ],
2385    )
2386    .await?;
2387    if !ok {
2388        bail!("gh pr list failed: {out}");
2389    }
2390    pick_open_pr(&out, base)
2391}
2392
2393#[derive(Debug, Deserialize)]
2394#[serde(rename_all = "camelCase")]
2395struct GhPrHead {
2396    head_ref_name: String,
2397    base_ref_name: String,
2398    state: String,
2399    // Required, like `GhOpenPr`'s fields: a record that cannot say whether the
2400    // head lives in a fork must be a parse error, never "same repository".
2401    is_cross_repository: bool,
2402    // Required too: it is what ties the pull request to the commits that were
2403    // actually proven to be on the base.
2404    head_ref_oid: String,
2405}
2406
2407/// Why [`closable`] said no, and whether asking again later could say yes.
2408#[derive(Debug, Clone, PartialEq, Eq)]
2409pub struct Refusal {
2410    /// A later attempt may succeed (the head moved, the view was unreadable);
2411    /// `false` means this pull request is simply not the run's to close.
2412    pub retry: bool,
2413    /// What was wrong.
2414    pub why: String,
2415}
2416
2417impl Refusal {
2418    fn final_(why: String) -> Self {
2419        Self { retry: false, why }
2420    }
2421}
2422
2423/// Pure half of [`close_superseded_pr`]: read `gh pr view --json
2424/// headRefName,baseRefName,state,isCrossRepository` and say whether closing
2425/// is safe, `Err` carrying the reason when it is not.
2426///
2427/// Closing is outward-facing, so every property is checked on what the forge
2428/// says now, not on what magi recorded: the head must be exactly `branch` in
2429/// this repository (a fork's branch of the same name is somebody else's), the
2430/// base must be `base`, and the pull request must still be open.
2431pub fn closable(
2432    json: &str,
2433    branch: &str,
2434    base: &str,
2435    verified: &[String],
2436) -> std::result::Result<(), Refusal> {
2437    let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2438        retry: true,
2439        why: format!("could not read the pull request ({e})"),
2440    })?;
2441    if pr.head_ref_name != branch {
2442        return Err(Refusal::final_(format!(
2443            "its head is `{}`, not this run's `{branch}`",
2444            pr.head_ref_name
2445        )));
2446    }
2447    if pr.is_cross_repository {
2448        return Err(Refusal::final_("its head lives in a fork".to_owned()));
2449    }
2450    if pr.base_ref_name != base {
2451        return Err(Refusal::final_(format!(
2452            "it targets `{}`, not `{base}`",
2453            pr.base_ref_name
2454        )));
2455    }
2456    if !pr.state.eq_ignore_ascii_case("open") {
2457        return Err(Refusal::final_(format!(
2458            "it is already {}",
2459            pr.state.to_ascii_lowercase()
2460        )));
2461    }
2462    // Last, so a pull request that is not this run's at all is reported as
2463    // such. A head that is this branch but not a commit checked against the
2464    // base (somebody pushed since) may well get checked next time: retry.
2465    if !verified.contains(&pr.head_ref_oid) {
2466        return Err(Refusal {
2467            retry: true,
2468            why: format!(
2469                "its head {} is not a commit this run checked against the base",
2470                crate::already::short_sha(&pr.head_ref_oid)
2471            ),
2472        });
2473    }
2474    Ok(())
2475}
2476
2477/// Does `remote` point at something a forge could host - a URL or an scp-style
2478/// `user@host:path` - rather than a filesystem path or nothing at all? Judged
2479/// from the URL alone, so it answers the same on every machine, whatever `gh`
2480/// happens to be installed or logged in to.
2481async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2482    let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2483        return false;
2484    };
2485    is_forge_url(url.trim())
2486}
2487
2488fn is_forge_url(url: &str) -> bool {
2489    url.contains("://") && !url.starts_with("file://")
2490        || url
2491            .split_once(':')
2492            .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2493}
2494
2495/// Does this `gh` failure mean there is no GitHub to ask, as opposed to a
2496/// request that failed?
2497fn forge_unavailable(message: &str) -> bool {
2498    message.contains("known GitHub host") || message.contains("spawn gh")
2499}
2500
2501/// The comment left on a pull request closed because its change is already on
2502/// the base.
2503pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2504    let how = match evidence.proof {
2505        crate::already::Proof::PatchId => format!(
2506            "carried by commit {} on `{base}` with the same patch",
2507            evidence.names()
2508        ),
2509        crate::already::Proof::Ancestry => {
2510            format!("already in the history of `{base}` as {}", evidence.names())
2511        }
2512        crate::already::Proof::Tree => format!(
2513            "already part of `{base}` (merging this branch changes nothing at {})",
2514            crate::already::short_sha(&evidence.tip)
2515        ),
2516    };
2517    format!(
2518        "Closing: everything this branch adds is {how}, so there is nothing left to \
2519         land. This pull request was closed automatically after that was verified; \
2520         reopen it if you disagree."
2521    )
2522}
2523
2524/// Close the open pull request for `branch`, if there is one and it is
2525/// provably this run's, with a comment naming what supersedes it. `Ok(Ok(url))` is
2526/// the URL closed; `Ok(Err(why))` is a final "nothing to close" (no pull request,
2527/// not this run's, no forge); `Err` is anything a later attempt could resolve (a
2528/// failed `gh` call, a head that moved since it was checked), which the caller
2529/// must not treat as settled.
2530///
2531/// The recorded `state.pr` is preferred, else the forge is asked for an open
2532/// pull request on `branch`; either way the candidate is re-read and passed
2533/// through [`closable`] before anything is written; `verified` lists the
2534/// commits proven to be on the base, and the pull request's head must be one.
2535pub async fn close_superseded_pr(
2536    state: &mut RunState,
2537    branch: &str,
2538    evidence: &crate::already::Evidence,
2539    verified: &[String],
2540) -> Result<std::result::Result<String, String>> {
2541    let repo = state.repo.clone();
2542    let base = state.base_branch.clone();
2543    let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2544        Some(p) => p.url.clone(),
2545        // No recorded pull request. A forge that cannot be asked at all (no
2546        // GitHub remote, no `gh`) says nothing about this run, so that is
2547        // "none found"; any other lookup failure is an error, because "could
2548        // not look" is not "nothing there" and the caller must retry.
2549        None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2550            return Ok(Err(
2551                "the remote is not a forge, so there is no pull request".to_owned(),
2552            ));
2553        }
2554        None => match find_open_pr(&repo, branch, &base).await {
2555            Err(e) if forge_unavailable(&format!("{e:#}")) => {
2556                return Ok(Err(format!("no forge to ask: {e:#}")));
2557            }
2558            Err(e) => return Err(e),
2559            Ok(OpenPr::One { url, .. }) => url,
2560            Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2561            Ok(OpenPr::Many(urls)) => {
2562                return Ok(Err(format!(
2563                    "{} open pull requests name it; not choosing between them",
2564                    urls.len()
2565                )));
2566            }
2567        },
2568    };
2569    let (ok, view) = gh(
2570        &repo,
2571        &[
2572            "pr".to_owned(),
2573            "view".to_owned(),
2574            url.clone(),
2575            "--json".to_owned(),
2576            "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2577        ],
2578    )
2579    .await?;
2580    if !ok {
2581        bail!("gh pr view {url} failed: {view}");
2582    }
2583    if let Err(refusal) = closable(&view, branch, &base, verified) {
2584        // A pull request whose head cannot be tied to what was proven is not
2585        // one to walk away from: the caller keeps the run resumable.
2586        if refusal.retry {
2587            bail!("left {url} open: {}", refusal.why);
2588        }
2589        return Ok(Err(format!("left {url} open: {}", refusal.why)));
2590    }
2591    let (ok, out) = gh(
2592        &repo,
2593        &[
2594            "pr".to_owned(),
2595            "close".to_owned(),
2596            url.clone(),
2597            "--comment".to_owned(),
2598            superseded_comment(&base, evidence),
2599        ],
2600    )
2601    .await?;
2602    if !ok {
2603        bail!("gh pr close {url} failed: {out}");
2604    }
2605    if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2606        p.state = "closed".to_owned();
2607    }
2608    Ok(Ok(url))
2609}
2610
2611/// `gh pr edit <url> --title <title>`, for an adopted pull request whose title
2612/// differs from the one this run computed. Only the title: the body may have
2613/// been edited by the owner and cannot be compared.
2614pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2615    let (title, _) = crate::github_text::prepare(state, title, "");
2616    edit_pr_title(repo, url, &title).await
2617}
2618
2619/// [`set_pr_title`] for a title the owner explicitly chose to post unmodified
2620/// (`github_text::POST_ORIGINAL` or `github_text::USE_REWRITE`): the posting
2621/// gate is skipped on purpose.
2622pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2623    edit_pr_title(repo, url, title).await
2624}
2625
2626/// Post both fields of the owner's chosen rewrite to an adopted pull request,
2627/// unmodified and without the posting gate.
2628pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2629    let (ok, _) = gh(
2630        repo,
2631        &[
2632            "pr".to_owned(),
2633            "edit".to_owned(),
2634            url.to_owned(),
2635            "--title".to_owned(),
2636            title.to_owned(),
2637            "--body".to_owned(),
2638            body.to_owned(),
2639        ],
2640    )
2641    .await
2642    .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2643    if !ok {
2644        // Command output may echo the chosen text; keep it out of run events.
2645        bail!("gh pr edit failed while posting the chosen rewrite");
2646    }
2647    Ok(())
2648}
2649
2650async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2651    let (ok, out) = gh(
2652        repo,
2653        &[
2654            "pr".to_owned(),
2655            "edit".to_owned(),
2656            url.to_owned(),
2657            "--title".to_owned(),
2658            title.to_owned(),
2659        ],
2660    )
2661    .await?;
2662    if !ok {
2663        bail!("gh pr edit failed: {out}");
2664    }
2665    Ok(())
2666}
2667
2668/// Ask GitHub whether this run's winning candidate branch was actually merged
2669/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2670/// --merged` exists to close, minus the operator having to find the URL by
2671/// hand.
2672///
2673/// `Ok(None)` covers every case where nothing can be said with confidence: no
2674/// winner decided yet (nothing to check a branch for), no merged pull request
2675/// found, or [`pick_merged_pr`] found more than one candidate and would not
2676/// guess between them. Never wired to a weaker, URL-less signal like
2677/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2678/// separately, precisely because it cannot drive an automatic correction on
2679/// its own (see that function's own doc).
2680pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2681    let Some(winner) = state.winner() else {
2682        return Ok(None);
2683    };
2684    let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2685    let out = gh(
2686        &state.repo,
2687        &[
2688            "pr".to_owned(),
2689            "list".to_owned(),
2690            "--head".to_owned(),
2691            branch.clone(),
2692            "--state".to_owned(),
2693            "merged".to_owned(),
2694            "--json".to_owned(),
2695            "url,number,mergedAt,baseRefName".to_owned(),
2696        ],
2697    )
2698    .await?;
2699    if !out.0 {
2700        bail!("gh pr list --head {branch}: {}", out.1);
2701    }
2702    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2703}
2704
2705/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2706/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2707///
2708/// Deliberately never consulted by [`find_external_merge`]: a base branch
2709/// that has moved since the run started can make an old, abandoned branch
2710/// look like an ancestor of the *current* base for reasons that have nothing
2711/// to do with a merge (a later commit that happens to supersede it, an
2712/// unrelated squash), and there is no pull request URL here to confirm
2713/// against or to land through anyway. Its only honest use is a weaker
2714/// notice — "this looks merged, go check" — never an automatic rewrite of
2715/// `status`/`merge`.
2716pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2717    let out = tokio::process::Command::new("git")
2718        .args(["merge-base", "--is-ancestor", branch, base_branch])
2719        .current_dir(repo)
2720        .quiet()
2721        .stdin(std::process::Stdio::null())
2722        .output()
2723        .await
2724        .context("spawn git merge-base --is-ancestor")?;
2725    Ok(out.status.success())
2726}
2727
2728/// Parse `host/owner/repo` out of a forge URL, with no network access.
2729///
2730/// The host is part of the slug, not discarded: `owner/repo` alone would
2731/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2732/// repository, which is exactly the mix-up the same-repo guard exists to
2733/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2734/// shape at all.
2735fn forge_slug(url: &str) -> Option<(String, &str)> {
2736    let rest = url.rsplit("://").next()?;
2737    let (host, path) = rest.split_once('/')?;
2738    if host.is_empty() {
2739        return None;
2740    }
2741    Some((host.to_ascii_lowercase(), path))
2742}
2743
2744/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2745/// access - the first half of the same-repo guard [`correct_manual_merge`]
2746/// applies before it writes anything.
2747///
2748/// Returns `None` for anything that does not look like
2749/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2750/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2751pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2752    let (host, path) = forge_slug(url)?;
2753    let mut segments = path.split('/');
2754    let owner = segments.next()?;
2755    let repo = segments.next()?;
2756    let kind = segments.next()?;
2757    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2758        return None;
2759    }
2760    Some(format!("{host}/{owner}/{repo}"))
2761}
2762
2763/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2764/// suffix), the shape `gh repo view --json url` returns - the other half of
2765/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2766fn slug_of_repo_url(url: &str) -> Option<String> {
2767    let (host, path) = forge_slug(url)?;
2768    let mut segments = path.split('/');
2769    let owner = segments.next()?;
2770    let repo = segments.next()?;
2771    if owner.is_empty() || repo.is_empty() {
2772        return None;
2773    }
2774    Some(format!("{host}/{owner}/{repo}"))
2775}
2776
2777/// Refuse to correct a run against a pull request from a different
2778/// repository than the one it is recorded against.
2779///
2780/// This is the guard the shun/8c75 incident argued for: an operator ran
2781/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2782/// in a different repository, omitted the run id, and the id defaulted to
2783/// this machine's most recently created run - an unrelated, still-in-progress
2784/// run in a completely different repository - which then had its `status`
2785/// rewritten to `merged` from a pull request it had nothing to do with.
2786/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2787/// CLI help), but a mistyped or stale id could still name a run in a
2788/// different repository than the one the URL belongs to, so this checks that
2789/// independently rather than trusting the id alone.
2790///
2791/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2792/// mismatch names both slugs rather than just refusing, so an operator whose
2793/// local checkout's `origin` is a fork of the repository the pull request was
2794/// opened against (a legitimate setup this cannot tell apart from a genuine
2795/// mix-up) can judge for themselves rather than being blocked with no way to
2796/// see why.
2797pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2798    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2799        return Ok(());
2800    }
2801    bail!(
2802        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2803         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2804         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2805         verify by hand before treating this as a false positive)"
2806    );
2807}
2808
2809/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2810/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2811///
2812/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2813/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2814/// repository the same way GitHub itself would recognize it, so the
2815/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2816/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2817/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2818/// a same-named one on a GitHub Enterprise host.
2819async fn repo_slug(repo: &Path) -> Result<String> {
2820    let out = gh(
2821        repo,
2822        &[
2823            "repo".to_owned(),
2824            "view".to_owned(),
2825            "--json".to_owned(),
2826            "url".to_owned(),
2827        ],
2828    )
2829    .await?;
2830    if !out.0 {
2831        bail!("gh repo view --json url: {}", out.1);
2832    }
2833    #[derive(Debug, Deserialize)]
2834    struct GhRepo {
2835        url: String,
2836    }
2837    let parsed: GhRepo = serde_json::from_str(&out.1)
2838        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2839    slug_of_repo_url(&parsed.url)
2840        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2841}
2842
2843/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2844/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2845/// would have written them had magi opened and merged this pull request
2846/// itself.
2847///
2848/// This is `magi fold --merged`'s whole implementation, and also what the
2849/// web `fold-merged` route calls once it has a URL in hand — an operator
2850/// recovery path for a merge magi could not finish on its own: a PR title too
2851/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2852/// closed by hand with a pull request magi never opened and so never
2853/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2854/// directly keeps this one authoritative: a merged pull request decides
2855/// `Step::Done { merged: true }` on the very first read, before any of
2856/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2857/// safe to call here even though this pull request was never magi's own.
2858///
2859/// [`ensure_same_repo`] is checked before anything else: a pull request from
2860/// a different repository than the one `state` is recorded against is
2861/// refused outright, regardless of its lifecycle. This is the guard for a
2862/// URL an *operator* hands in - the CLI or the web route - where a stale or
2863/// mistyped run id could otherwise get corrected from an unrelated
2864/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2865/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2866/// goes through [`correct_confirmed_external_merge`] instead, which skips
2867/// this check: its `url` was never operator-supplied, it comes from
2868/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2869/// it is already guaranteed to name a pull request in that same repository -
2870/// re-deriving and re-checking the repository here would only be a second
2871/// `gh repo view` call that can fail for reasons that have nothing to do with
2872/// correctness (a rate limit, a network blip), turning a self-heal that would
2873/// otherwise have succeeded into a run left `Blocked` for another pass.
2874///
2875/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2876/// URL fails loudly without writing anything, rather than handing an open
2877/// pull request to the full autonomous loop by accident.
2878///
2879/// Correcting `status` this way does not run `bump::after_merge`
2880/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2881/// which this path never goes through. A release version bump the change
2882/// might have earned is therefore not filed automatically and has to be
2883/// requested by hand - recorded as an event on the run so the gap is visible
2884/// to whoever reads it later, not just wherever this was called from. Follow-up
2885/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2886///
2887/// Returns the status before and after, so every caller (CLI, janitor, web
2888/// route) can build its own log line or response from the same pair rather
2889/// than each re-deriving it.
2890pub async fn correct_manual_merge(
2891    state: &mut RunState,
2892    url: &str,
2893) -> Result<(RunStatus, RunStatus)> {
2894    let Some(pr_slug) = slug_of_pr_url(url) else {
2895        bail!(
2896            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2897             this pull request belongs to"
2898        );
2899    };
2900    let run_slug = repo_slug(&state.repo).await?;
2901    ensure_same_repo(&run_slug, &pr_slug)?;
2902    correct_merge(state, url).await
2903}
2904
2905/// The janitor's own entry point into the same correction
2906/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2907/// same-repo guard - see that function's own doc for why skipping it here is
2908/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2909/// calls this with a `url` [`find_external_merge`] already found by querying
2910/// `state.repo`'s own remote, so the guard could never do anything here but
2911/// fail on its own transient errors.
2912///
2913/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2914/// because it lives in a different module.
2915pub(crate) async fn correct_confirmed_external_merge(
2916    state: &mut RunState,
2917    url: &str,
2918) -> Result<(RunStatus, RunStatus)> {
2919    correct_merge(state, url).await
2920}
2921
2922/// Same pull request, same repository: the url, or the number within one repo.
2923fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2924    let Some(pr) = a.pr.as_ref() else {
2925        return false;
2926    };
2927    if !url.is_empty()
2928        && pr
2929            .url
2930            .trim_end_matches('/')
2931            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2932    {
2933        return true;
2934    }
2935    number > 0
2936        && pr.number == number
2937        && match (a.repo.canonicalize(), repo.canonicalize()) {
2938            (Ok(x), Ok(y)) => x == y,
2939            _ => a.repo == repo,
2940        }
2941}
2942
2943/// Rewrite `pr.state` to a final state on every run record under `home` that
2944/// `decide` picks, and report how many were rewritten.
2945///
2946/// Lifecycle writes through to other runs are deliberately narrow (task-head
2947/// ownership is recorded separately): only a **terminal** run (never one a
2948/// driver may still be writing), only a proven-stopped driver with no daemon
2949/// claim (the shared liveness judge), only a record
2950/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2951/// The record is read as folding reads it (no schema check: every bump so far
2952/// only added fields, and the whole struct round-trips) and written through
2953/// [`RunState::save_under`], the path every record uses, so nothing but
2954/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2955/// A run that cannot be read or written is skipped with a warning.
2956fn rewrite_open_prs(
2957    home: &Path,
2958    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2959) -> usize {
2960    let now = Timestamp::now();
2961    let mut changed = 0;
2962    for id in crate::run::list_ids_in(&home.join("runs")) {
2963        let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2964            continue;
2965        };
2966        let path = home.join("runs").join(&id).join("run.json");
2967        let Ok(body) = std::fs::read_to_string(&path) else {
2968            continue;
2969        };
2970        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2971            continue;
2972        };
2973        if !state.status.done()
2974            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2975            || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2976                != crate::run::Liveness::Dead
2977        {
2978            continue;
2979        }
2980        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2981            continue;
2982        };
2983        if let Some(pr) = state.pr.as_mut() {
2984            pr.state = to.as_str().to_owned();
2985        }
2986        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2987        state.event(
2988            "land",
2989            format!("recorded {url} as {}: another run settled it", to.as_str()),
2990        );
2991        match state.save_under(home) {
2992            Ok(()) => changed += 1,
2993            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2994        }
2995    }
2996    changed
2997}
2998
2999/// A run reached a final state for its pull request: tell every other terminal
3000/// run in the same repository that names the same pull request (handed-over
3001/// predecessors, blocked attempts, anything), so their records stop saying
3002/// `open`. Best effort; `run`'s own record is the caller's.
3003pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
3004    if to == PrLifecycle::Open {
3005        return;
3006    }
3007    let Some(home) = crate::run::try_home() else {
3008        return;
3009    };
3010    write_pr_state_through_in(&home, run, to);
3011}
3012
3013pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
3014    let Some(pr) = run.pr.as_ref() else {
3015        return 0;
3016    };
3017    let (url, number) = (pr.url.clone(), pr.number);
3018    rewrite_open_prs(home, &mut |other| {
3019        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
3020    })
3021}
3022
3023/// Terminal runs whose record still says their pull request is open, as
3024/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
3025pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
3026    let now = Timestamp::now();
3027    let mut out = Vec::new();
3028    for id in crate::run::list_ids_in(&home.join("runs")) {
3029        let path = home.join("runs").join(&id).join("run.json");
3030        let Ok(body) = std::fs::read_to_string(&path) else {
3031            continue;
3032        };
3033        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
3034            continue;
3035        };
3036        if let Some(pr) = state.pr.as_ref()
3037            && state.status.done()
3038            && pr.state == "open"
3039            && !pr.url.is_empty()
3040            && !crate::daemon::is_working_on(home, &id, now)
3041        {
3042            out.push((id, state.repo.clone(), pr.url.clone()));
3043        }
3044    }
3045    out
3046}
3047
3048/// Apply forge answers (`pr url -> state`) to every stale terminal record.
3049/// A url with no answer (the forge was unreadable) changes nothing.
3050pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
3051    rewrite_open_prs(home, &mut |s| {
3052        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
3053    })
3054}
3055
3056/// One-time (and idempotent) repair of records that froze an `open` pull
3057/// request: ask the forge about each distinct pull request that a terminal run
3058/// still calls open - at most `max_lookups` of them - and rewrite the merged
3059/// and closed ones. A genuinely open pull request is left alone, and a lookup
3060/// that fails is "unknown, change nothing". Returns `(records rewritten,
3061/// lookups that failed)`.
3062pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
3063    let mut known = BTreeMap::new();
3064    let mut failed = 0;
3065    let mut seen = BTreeSet::new();
3066    for (_, repo, url) in stale_open_prs(home) {
3067        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
3068            continue;
3069        }
3070        match lifecycle(&repo, &url).await {
3071            Ok(state) => {
3072                known.insert(url, state);
3073            }
3074            Err(e) => {
3075                tracing::warn!("repair pr state of {url}: {e:#}");
3076                failed += 1;
3077            }
3078        }
3079    }
3080    (apply_pr_states(home, &known), failed)
3081}
3082
3083async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
3084    match lifecycle(&state.repo, url).await? {
3085        PrLifecycle::Merged => {}
3086        other => bail!(
3087            "{url} is {}, not merged; refusing to record {} as merged on a guess",
3088            other.as_str(),
3089            state.id
3090        ),
3091    }
3092    let before = state.status;
3093    if let Err(e) = land(state, url).await {
3094        // `land` sets `status` to `Landing` and saves before its first read
3095        // of the pull request — see its own doc — so a failure here (a
3096        // transient `gh` hiccup between the two forge reads this function
3097        // makes) can leave the run stuck on that in-between value with
3098        // nothing left driving it. Land it on the same terminal shape an
3099        // automated `land` failure lands on instead of leaving it stuck.
3100        state.status = RunStatus::Blocked;
3101        state.event("fold", format!("manual-merge correction failed: {e:#}"));
3102        state.save()?;
3103        return Err(e).context(format!("confirming the merge of {url}"));
3104    }
3105    state.event(
3106        "fold",
3107        "operator recorded this pull request as a manual merge; this run never \
3108         re-entered `land`, so `bump::after_merge` did not run for it - a release \
3109         bump this change might warrant has to be filed by hand",
3110    );
3111    // Unlike the bump, the findings the merge left open are filed on this
3112    // path too: nothing else would ever carry them forward.
3113    if state.status == RunStatus::Merged {
3114        crate::followup::after_merge(state, url).await;
3115    }
3116    state.save()?;
3117    Ok((before, state.status))
3118}
3119
3120/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
3121/// comments. No I/O.
3122///
3123/// `gh pr view` does not surface inline comments, and inline is exactly where
3124/// both review bots put their findings - a landing loop that read only the
3125/// top-level thread would never see the thing it is supposed to fix.
3126pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3127    let raw: Vec<GhInline> =
3128        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3129    let mut out = Vec::new();
3130    for c in raw {
3131        push_if_outstanding(
3132            &mut out,
3133            ReviewComment {
3134                author: c.user.login,
3135                path: c.path,
3136                line: c.line,
3137                body: c.body,
3138            },
3139        );
3140    }
3141    Ok(out)
3142}
3143
3144/// Keep a comment only when it asks for something.
3145///
3146/// An inline comment always does: it names a file and a line. A top-level
3147/// comment is dropped when it is empty, when it is magi's own, or when it is
3148/// [noise](is_noise).
3149fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3150    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3151        return;
3152    }
3153    if comment.path.is_none() && is_noise(&comment.body) {
3154        return;
3155    }
3156    out.push(comment);
3157}
3158
3159/// Is this comment body machinery rather than a finding?
3160///
3161/// Two tests, both structural, because guessing from prose is how a "looks
3162/// good to me" turns into a fix round:
3163///
3164/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
3165///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
3166///    footer.
3167/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
3168///    horizontal rules, and the bot's own status banner are removed, every
3169///    remaining line is a task-list item. That is exactly the shape of the
3170///    comment the Claude review job posts while it is still working.
3171///
3172/// Anything else is input, including bot prose. A bot that writes a paragraph
3173/// has said something, and the fix prompt tells the fixer it may decline a
3174/// comment with an argument - a wasted sentence in a prompt is cheaper than a
3175/// missed finding.
3176pub fn is_noise(body: &str) -> bool {
3177    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3178        return true;
3179    }
3180    let mut content = false;
3181    for line in strip_blocks(body).lines() {
3182        let line = unquote(line);
3183        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3184            continue;
3185        }
3186        content = true;
3187        break;
3188    }
3189    !content
3190}
3191
3192/// Remove HTML comments and collapsed `<details>` blocks.
3193fn strip_blocks(body: &str) -> String {
3194    let mut out = String::with_capacity(body.len());
3195    let mut rest = body;
3196    loop {
3197        let open = ["<!--", "<details>"]
3198            .iter()
3199            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3200            .min_by_key(|(i, _)| *i);
3201        let Some((at, tag)) = open else {
3202            out.push_str(rest);
3203            return out;
3204        };
3205        out.push_str(&rest[..at]);
3206        let after = &rest[at + tag.len()..];
3207        let close = if tag == "<!--" { "-->" } else { "</details>" };
3208        match after.find(close) {
3209            Some(end) => rest = &after[end + close.len()..],
3210            // Unterminated: the rest of the body is inside the block.
3211            None => return out,
3212        }
3213    }
3214}
3215
3216/// Strip blockquote markers, which both bots wrap their callouts in.
3217fn unquote(line: &str) -> &str {
3218    let mut s = line.trim();
3219    while let Some(rest) = s.strip_prefix('>') {
3220        s = rest.trim_start();
3221    }
3222    s.trim()
3223}
3224
3225/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
3226fn is_checklist(line: &str) -> bool {
3227    let rest = line
3228        .strip_prefix("- ")
3229        .or_else(|| line.strip_prefix("* "))
3230        .unwrap_or("");
3231    let rest = rest.trim_start();
3232    matches!(
3233        rest.get(..3),
3234        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3235    )
3236}
3237
3238/// A heading, a horizontal rule, or a callout tag - shape, never content.
3239fn is_decoration(line: &str) -> bool {
3240    line.starts_with('#')
3241        || line.starts_with("[!")
3242        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3243}
3244
3245/// A line that is nothing but emphasis and links.
3246///
3247/// Both review jobs open with a status banner
3248/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
3249/// to a line-based test and asks for nothing, so it is measured the same way a
3250/// heading is: strip the markup, and if no word survives, it was decoration.
3251fn is_banner(line: &str) -> bool {
3252    let plain = drop_spans(line, "**", "**");
3253    let plain = if plain.contains("](") {
3254        drop_spans(&plain, "[", ")")
3255    } else {
3256        plain
3257    };
3258    !plain.chars().any(char::is_alphanumeric)
3259}
3260
3261/// Remove every `open` .. `close` span, including the delimiters. An
3262/// unterminated span swallows the rest of the input, which is what a reader
3263/// sees too.
3264fn drop_spans(s: &str, open: &str, close: &str) -> String {
3265    let mut out = String::with_capacity(s.len());
3266    let mut rest = s;
3267    while let Some(at) = rest.find(open) {
3268        out.push_str(&rest[..at]);
3269        let after = &rest[at + open.len()..];
3270        match after.find(close) {
3271            Some(end) => rest = &after[end + close.len()..],
3272            None => return out,
3273        }
3274    }
3275    out.push_str(rest);
3276    out
3277}
3278
3279/// The lock that keeps at most one run per repository actually moving the
3280/// base branch at a time: a rebase push, or `gh pr merge`.
3281///
3282/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
3283/// running a fix round in the winner's own worktree, waiting on the owner's
3284/// approval - touches nothing a *different* run in the same repository could
3285/// collide with, and holding a lock across any of that would serialise one
3286/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
3287/// resume, which is precisely the "must not wait on another task" property
3288/// the daemon's slot-freeing exists to give a resume. Only the two moments
3289/// that actually write to the shared base branch need mutual exclusion, and
3290/// both are brief.
3291///
3292/// One entry per repository, each its own `tokio::sync::Mutex`, so two
3293/// different repositories' runs never wait on each other. The outer
3294/// `std::sync::Mutex` guards only the map itself, held long enough to find or
3295/// insert an entry and clone its `Arc`, never across an `.await`.
3296fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3297    static LOCKS: std::sync::LazyLock<
3298        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3299    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3300    LOCKS
3301        .lock()
3302        .unwrap_or_else(std::sync::PoisonError::into_inner)
3303        .entry(repo.to_path_buf())
3304        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3305        .clone()
3306}
3307
3308/// `owner/repo` out of a pull request url, falling back to the checkout's
3309/// directory name when the url is not the usual `host/owner/repo/pull/N`.
3310fn repo_label(repo: &Path, pr_url: &str) -> String {
3311    let parts: Vec<&str> = pr_url.split('/').collect();
3312    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3313        && at >= 2
3314        && !parts[at - 1].is_empty()
3315        && !parts[at - 2].is_empty()
3316    {
3317        return format!("{}/{}", parts[at - 2], parts[at - 1]);
3318    }
3319    repo.file_name()
3320        .map(|n| n.to_string_lossy().into_owned())
3321        .unwrap_or_default()
3322}
3323
3324/// The operator-facing sentence for a merge that went ahead with red checks,
3325/// or `None` when the checks were not red. Judged on `checks`, not on
3326/// `failing`, which can be non-empty on a green observation.
3327fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3328    (pr.checks == Checks::Red).then(|| {
3329        let details = crate::notices::format_list(&pr.failing)
3330            .map(|names| format!(": {names}"))
3331            .unwrap_or_default();
3332        format!(
3333            "Merged {repo_name} PR #{} with red checks{details} ({})",
3334            pr.number, pr.url
3335        )
3336    })
3337}
3338
3339/// After a merge that succeeded: record which checks were red and tell the
3340/// operator. The decision to merge is already made; this only makes it audible.
3341/// Best-effort - a broken notifier never fails the run.
3342async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3343    let repo_name = repo_label(&state.repo, &pr.url);
3344    let Some(summary) = red_merge_summary(&repo_name, pr) else {
3345        return;
3346    };
3347    if let Some(rec) = state.pr.as_mut() {
3348        rec.red_at_merge = pr.failing.clone();
3349    }
3350    state.event("land", summary.clone());
3351    // The notice pages through `[notify]` itself, once, unless a question
3352    // already carries the cause.
3353    crate::notices::raise_with(
3354        crate::notices::merged_red(&state.id, &summary),
3355        &state.config.notify,
3356    );
3357}
3358
3359/// Run the loop against a real pull request until it merges or the budget runs
3360/// out.
3361///
3362/// The caller decides whether landing happens at all: this is only reached when
3363/// `graph.land` is on. Returns the last observation, so the caller can report
3364/// what magi was looking at when it stopped.
3365pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3366    land_with(state, pr_url, &GhForge).await
3367}
3368
3369/// The forge calls whose timing the loop's decisions depend on, behind a seam
3370/// so a test can script what the pull request looks like from one observation
3371/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
3372trait Forge {
3373    async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3374        let (ok, out) = gh(
3375            repo,
3376            &[
3377                "pr".into(),
3378                "close".into(),
3379                pr_url.into(),
3380                "--comment".into(),
3381                comment.into(),
3382            ],
3383        )
3384        .await?;
3385        if !ok {
3386            bail!("PR closure failed: {out}");
3387        }
3388        Ok(())
3389    }
3390    async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
3391        None
3392    }
3393    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3394    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3395    async fn poll(&self);
3396    /// Rerun the failed jobs of one workflow run.
3397    async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3398    /// Unix seconds, behind the seam so a test owns the clock.
3399    fn now(&self) -> i64;
3400    /// The check contexts the base branch requires, for a stop reason only.
3401    /// `None` when they could not be read, which is not the same as none.
3402    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3403    #[allow(clippy::too_many_arguments)]
3404    async fn fix(
3405        &self,
3406        state: &mut RunState,
3407        pr: &PrState,
3408        round: usize,
3409        budget: usize,
3410        reason: &str,
3411        logs: &str,
3412    ) -> Result<Fixed>;
3413}
3414
3415struct GhForge;
3416
3417impl Forge for GhForge {
3418    async fn attribution(&self, state: &RunState, seen: &Seen) -> Option<CiEvidence> {
3419        collect_ci_evidence(state, seen).await
3420    }
3421    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3422        observe(repo, pr_url).await
3423    }
3424    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3425        gh(repo, argv).await
3426    }
3427    async fn poll(&self) {
3428        tokio::time::sleep(POLL).await;
3429    }
3430    async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3431        let args = [
3432            "run".to_owned(),
3433            "rerun".to_owned(),
3434            run.to_owned(),
3435            "--failed".to_owned(),
3436        ];
3437        let (ok, out) = gh(repo, &args).await?;
3438        if !ok {
3439            bail!("gh run rerun {run}: {out}");
3440        }
3441        Ok(())
3442    }
3443    fn now(&self) -> i64 {
3444        jiff::Timestamp::now().as_second()
3445    }
3446    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3447        required_contexts_of(repo, base).await
3448    }
3449    async fn fix(
3450        &self,
3451        state: &mut RunState,
3452        pr: &PrState,
3453        round: usize,
3454        budget: usize,
3455        reason: &str,
3456        logs: &str,
3457    ) -> Result<Fixed> {
3458        fix_round(state, pr, round, budget, reason, logs).await
3459    }
3460}
3461
3462/// Percent-encode a branch name for a URL path segment (`/` included).
3463fn encode_path_segment(s: &str) -> String {
3464    let mut out = String::new();
3465    for b in s.bytes() {
3466        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3467            out.push(b as char);
3468        } else {
3469            let _ = write!(out, "%{b:02X}");
3470        }
3471    }
3472    out
3473}
3474
3475/// Read the required contexts of `base` from classic branch protection and
3476/// from rulesets, once, for a stop reason. Organisation-level rulesets that
3477/// these two endpoints do not list are missed. Any unreadable source makes the
3478/// whole answer `None`: a partial set would read as a complete one.
3479async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3480    let enc = encode_path_segment(base);
3481    let mut all = BTreeSet::new();
3482    // Classic protection answers 404 for an unprotected branch, which is
3483    // "nothing required here", not a failure to read.
3484    let classic = gh(
3485        repo,
3486        &[
3487            "api".to_owned(),
3488            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3489        ],
3490    )
3491    .await
3492    .ok()?;
3493    if classic.0 {
3494        all.extend(parse_classic_required(&classic.1)?);
3495    } else if !classic.1.contains("404") {
3496        return None;
3497    }
3498    let rules = gh(
3499        repo,
3500        &[
3501            "api".to_owned(),
3502            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3503        ],
3504    )
3505    .await
3506    .ok()?;
3507    if !rules.0 {
3508        return None;
3509    }
3510    all.extend(parse_ruleset_required(&rules.1)?);
3511    Some(all)
3512}
3513
3514/// `required_status_checks` of classic protection: `contexts` plus the
3515/// `checks[].context` form.
3516fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3517    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3518    let mut out = BTreeSet::new();
3519    for c in v.get("contexts")?.as_array()? {
3520        out.insert(c.as_str()?.to_owned());
3521    }
3522    for c in v
3523        .get("checks")
3524        .and_then(|c| c.as_array())
3525        .into_iter()
3526        .flatten()
3527    {
3528        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3529            out.insert(name.to_owned());
3530        }
3531    }
3532    Some(out)
3533}
3534
3535/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
3536fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3537    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3538    let mut out = BTreeSet::new();
3539    for rule in v.as_array()? {
3540        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3541            continue;
3542        }
3543        let checks = rule
3544            .pointer("/parameters/required_status_checks")?
3545            .as_array()?;
3546        for c in checks {
3547            out.insert(c.get("context")?.as_str()?.to_owned());
3548        }
3549    }
3550    Some(out)
3551}
3552
3553/// Is the observation older than the commit a fix round pushed?
3554///
3555/// The forge takes a few seconds to move the pull request to a new head and
3556/// attach that head's check runs, and until it has, the rollup is the previous
3557/// head's - all green, which is exactly what a merge decision must not read.
3558/// An absent or different head counts as not yet: guessing "close enough"
3559/// would reopen the hole.
3560fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3561    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3562}
3563
3564/// The commit an observation may be decided on, or `None` while it cannot be
3565/// trusted to describe one.
3566///
3567/// `None` when a pushed commit is awaited and the pull request is not on it,
3568/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3569/// last commit's) is not the head's: that is the previous commit's checks. The
3570/// caller re-polls on `None`. A rollup that cannot be read (including one
3571/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3572/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3573fn bound_head<'a>(
3574    seen_head: &'a str,
3575    rollup_head: &str,
3576    awaiting: Option<&str>,
3577) -> Option<&'a str> {
3578    if seen_head.is_empty()
3579        || awaiting_new_head(awaiting, seen_head)
3580        || !rollup_head.eq_ignore_ascii_case(seen_head)
3581    {
3582        return None;
3583    }
3584    Some(seen_head)
3585}
3586
3587/// What a refused `gh pr merge` means.
3588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3589enum Refused {
3590    /// The branch policy is not satisfied *yet*: go back to waiting.
3591    Pending,
3592    /// Checks have settled and the merge state still says no, seen for the
3593    /// first time. The forge updates `mergeStateStatus` a moment after the last
3594    /// check finishes, so one more look is allowed before believing it.
3595    Recheck,
3596    /// Nothing is in flight and the policy still refuses: a person has to
3597    /// supply what it asks for (a review, say).
3598    Final,
3599}
3600
3601/// Judged from the pull request's state after the refusal, never from the
3602/// refusal's wording, which belongs to the forge and changes.
3603fn classify_refusal(
3604    after: Option<&Seen>,
3605    rechecked: bool,
3606    observed_head: &str,
3607    ci: CiExpectation,
3608) -> Refused {
3609    let Some(after) = after else {
3610        // Unreadable is not evidence of anything; the next loop reads again.
3611        return Refused::Pending;
3612    };
3613    if after.pr.state != PrLifecycle::Open {
3614        return Refused::Final;
3615    }
3616    // The branch moved after it was observed (the forge refuses a merge pinned
3617    // to the old commit): not a verdict on anything, look again.
3618    if !after.head.eq_ignore_ascii_case(observed_head) {
3619        return Refused::Pending;
3620    }
3621    // The same binding the loop applies: checks of another commit say nothing.
3622    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3623        return Refused::Pending;
3624    }
3625    let state = after.merge_state.to_ascii_uppercase();
3626    if (ci == CiExpectation::Expected
3627        && matches!(after.pr.checks, Checks::Pending | Checks::Unknown))
3628        || state.is_empty()
3629        || state == "UNKNOWN"
3630    {
3631        return Refused::Pending;
3632    }
3633    if rechecked {
3634        Refused::Final
3635    } else {
3636        Refused::Recheck
3637    }
3638}
3639
3640/// Take auto-merge back from the forge and forget that it was armed.
3641///
3642/// `Err` carries the forge's message: the caller must not push or move on, as
3643/// an armed merge left behind could still fire for a commit nobody approved.
3644async fn disarm<F: Forge>(
3645    forge: &F,
3646    state: &mut RunState,
3647    repo: &Path,
3648    number: u64,
3649) -> std::result::Result<(), String> {
3650    let argv = disable_automerge_argv(number);
3651    let out = {
3652        let merge_lock = repo_merge_lock(repo);
3653        let _merge_slot = merge_lock.lock().await;
3654        forge.merge(repo, &argv).await
3655    };
3656    match out {
3657        Ok((true, _)) => {
3658            state.land_armed_head = None;
3659            state.event("land", "auto-merge disabled");
3660            state.save().map_err(|e| format!("{e:#}"))?;
3661            Ok(())
3662        }
3663        Ok((false, msg)) => Err(msg),
3664        Err(e) => Err(format!("{e:#}")),
3665    }
3666}
3667
3668/// [`stop`], first taking back an armed auto-merge so a pull request magi
3669/// gave up on does not merge on its own later. A failure to disarm is added
3670/// to the reason rather than hiding it.
3671async fn stop_disarmed<F: Forge>(
3672    forge: &F,
3673    state: &mut RunState,
3674    repo: &Path,
3675    pr: &PrState,
3676    why: &str,
3677) -> Result<()> {
3678    if state.land_armed_head.is_none() {
3679        return stop(state, repo, pr, why).await;
3680    }
3681    match disarm(forge, state, repo, pr.number).await {
3682        Ok(()) => stop(state, repo, pr, why).await,
3683        Err(e) => {
3684            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3685            stop(state, repo, pr, &why).await
3686        }
3687    }
3688}
3689
3690/// Check the persisted card again around awaits that can outlive an owner tap.
3691fn owner_requested_stop(state: &RunState) -> Result<bool> {
3692    let Some(binding) = &state.land_approval else {
3693        return Ok(false);
3694    };
3695    let q = ask::Questions::open().get(&binding.question)?;
3696    Ok(q.run == state.id
3697        && q.node == APPROVAL_NODE
3698        && q.resolution()
3699            .as_deref()
3700            .is_some_and(crate::merge_decision::stops))
3701}
3702
3703/// Read stop choices before checks, repair or rebase. These are about stopping
3704/// ownership, so a changed head never cancels them as it cancels merge consent.
3705async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3706    use crate::merge_decision as md;
3707    let Some(binding) = &state.land_approval else {
3708        return Ok(false);
3709    };
3710    let q = ask::Questions::open().get(&binding.question)?;
3711    let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3712        return Ok(false);
3713    };
3714    if q.run != state.id || q.node != APPROVAL_NODE {
3715        bail!("stop decision names another run");
3716    }
3717    let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3718        pr: pr.url.clone(),
3719        head: binding.head.clone(),
3720        language: state.config.graph.language.clone(),
3721    });
3722    if target.pr != pr.url {
3723        bail!("stop decision names another PR");
3724    }
3725    if state.merge_decision.is_none() {
3726        state.merge_decision = Some(md::Decision {
3727            question: q.id.clone(),
3728            target,
3729            choice: choice.clone(),
3730            closed: false,
3731            replacement_run: None,
3732            replacement_pr: None,
3733        });
3734        state.save()?;
3735    }
3736    let repo = state.repo.clone();
3737    // Always disable, including arms left by an older driver.
3738    if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3739        stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3740        return Ok(true);
3741    }
3742    if choice.eq_ignore_ascii_case(HOLD) {
3743        stop(
3744            state,
3745            &repo,
3746            pr,
3747            "owner held the decision: PR left open; no further repair or merge work",
3748        )
3749        .await?;
3750        return Ok(true);
3751    }
3752    if md::selected_agent(&choice).is_some()
3753        || state
3754            .origin
3755            .as_ref()
3756            .and_then(|o| o.task.as_ref())
3757            .is_some()
3758    {
3759        let owners: Vec<_> = crate::queue::Queue::open()
3760            .list()
3761            .into_iter()
3762            .filter(|task| task.runs.last() == Some(&state.id))
3763            .collect();
3764        let owner = (owners.len() == 1).then(|| &owners[0]);
3765        if !owner.is_some_and(|task| {
3766            task.runs.last() == Some(&state.id)
3767                && (!task
3768                    .operator_decided_at
3769                    .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3770                    || task.resumption.as_ref().is_some_and(|release| {
3771                        release.question_id.as_deref() == Some(q.id.as_str())
3772                            && task.operator_decided_at == Some(release.at)
3773                    }))
3774        }) {
3775            stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3776            return Ok(true);
3777        }
3778    }
3779    if pr.state == PrLifecycle::Merged {
3780        stop(
3781            state,
3782            &repo,
3783            pr,
3784            "PR already merged; withdrawal/replacement cannot be applied",
3785        )
3786        .await?;
3787        return Ok(true);
3788    }
3789    let comment = format!(
3790        "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3791        q.id, choice, state.id
3792    );
3793    if pr.state == PrLifecycle::Open {
3794        if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3795            stop(
3796                state,
3797                &repo,
3798                pr,
3799                &format!(
3800                    "owner requested closure but it failed: {e}; task held, no replacement started"
3801                ),
3802            )
3803            .await?;
3804            return Ok(true);
3805        }
3806    }
3807    let confirmed = forge.view(&repo, &pr.url).await?;
3808    if confirmed.pr.state != PrLifecycle::Closed {
3809        stop(
3810            state,
3811            &repo,
3812            pr,
3813            "PR closure was not confirmed; task held, no replacement started",
3814        )
3815        .await?;
3816        return Ok(true);
3817    }
3818    *pr = confirmed.pr;
3819    state.merge_decision.as_mut().unwrap().closed = true;
3820    if let Some(record) = &mut state.pr {
3821        record.state = "closed".into();
3822    }
3823    state.status = RunStatus::Superseded;
3824    state.merge = Some(MergeOutcome {
3825        mode: MergeMode::Pr,
3826        ok: false,
3827        detail: format!("owner decision: {choice}; PR closure confirmed"),
3828        empty: false,
3829    });
3830    state.event(
3831        "land",
3832        format!(
3833            "{} applied: PR closure confirmed; old execution retired",
3834            choice
3835        ),
3836    );
3837    state.save()?;
3838    Ok(true)
3839}
3840
3841/// Keep an unstarted verification resumable without charging a task attempt.
3842fn check_landing_resources(
3843    state: &mut RunState,
3844    node: &str,
3845    outcomes: &[crate::run::CommandOutcome],
3846) -> Result<()> {
3847    if let Some(blocked) = outcomes.iter().find(|outcome| outcome.resource_blocked) {
3848        let detail = format!("{}: {}", blocked.command, blocked.output_tail);
3849        state
3850            .resource_block
3851            .get_or_insert_with(|| crate::run::ResourceBlock {
3852                cause: crate::run::ResourceBlockCause::Other,
3853                node: node.into(),
3854                kind: "shared build cache".into(),
3855                detail: detail.clone(),
3856                at: jiff::Timestamp::now(),
3857            });
3858        state.event("resource", &detail);
3859        state.save()?;
3860        bail!("landing verification could not start: {detail}");
3861    }
3862    Ok(())
3863}
3864
3865/// Gate the local winner and publish that exact SHA, retaining the saved lease.
3866/// A push completed before a crash is recoverable only when its SHA was gated.
3867async fn publish_landing_head<F: Forge>(
3868    state: &mut RunState,
3869    observed: &str,
3870    pr_url: &str,
3871    forge: &F,
3872) -> Result<String> {
3873    crate::pr_head::ensure_owner(state)?;
3874    let winner = state.winner().cloned().context("landing has no winner")?;
3875    let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3876    let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3877    anyhow::ensure!(
3878        observed == expected
3879            || (observed == head
3880                && state.gate_head.as_deref() == Some(&head)
3881                && state.gate_allows_progress()),
3882        "public head moved outside this run; refusing to overwrite it"
3883    );
3884    anyhow::ensure!(
3885        git::is_clean(&winner.worktree).await?,
3886        "landing worktree is dirty"
3887    );
3888    if state.gate_head.as_deref() != Some(&head) || !state.gate_ran || !state.gate_allows_progress()
3889    {
3890        state.gate.clear();
3891        state.gate_head = None;
3892        state.gate_ran = false;
3893        state.pre_gate.clear();
3894        state.pre_gate_commit = None;
3895        state.save()?;
3896        let inherited = crate::repo_lock::WaitPolicy::current();
3897        let wait = crate::repo_lock::WaitPolicy::new(
3898            Duration::from_secs(state.config.graph.timeout_resource_wait),
3899            move || inherited.cancelled(),
3900        );
3901        let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3902        loop {
3903            if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3904                state.gate = outcomes.clone();
3905                check_landing_resources(state, "gate", &outcomes)?;
3906            }
3907            if outcomes.iter().all(|o| o.ok()) {
3908                break;
3909            }
3910            match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3911                crate::graph::GateFix::Retry => {
3912                    outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3913                }
3914                crate::graph::GateFix::Stop => break,
3915                crate::graph::GateFix::Defer => {
3916                    // The repair checks e2e immediately, before the next gate.
3917                    // Preserve a cache wait here as well as below; otherwise
3918                    // the daemon charges an attempt for unstarted verification.
3919                    if let Some(e2e) = &state.gate_repair_e2e {
3920                        let outcomes = e2e.outcomes.clone();
3921                        check_landing_resources(state, "e2e", &outcomes)?;
3922                    }
3923                    bail!("landing gate repair deferred");
3924                }
3925            }
3926        }
3927        state.gate = outcomes;
3928        state.gate_ran = true;
3929        state.gate_head = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3930        state.save()?;
3931    }
3932    anyhow::ensure!(
3933        state.gate_allows_progress(),
3934        "landing gate failed; nothing published"
3935    );
3936    let checked = state
3937        .gate_head
3938        .clone()
3939        .context("landing gate has no checked SHA")?;
3940    anyhow::ensure!(
3941        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3942            && git::is_clean(&winner.worktree).await?,
3943        "landing tree changed after gate"
3944    );
3945    let inherited = crate::repo_lock::WaitPolicy::current();
3946    let wait = crate::repo_lock::WaitPolicy::new(
3947        Duration::from_secs(state.config.graph.timeout_resource_wait),
3948        move || inherited.cancelled(),
3949    );
3950    let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3951    if matches!(e2e, crate::graph::GateFix::Defer) {
3952        let outcomes = state
3953            .gate_repair_e2e
3954            .as_ref()
3955            .context("deferred landing e2e has no outcomes")?
3956            .outcomes
3957            .clone();
3958        check_landing_resources(state, "e2e", &outcomes)?;
3959    }
3960    anyhow::ensure!(
3961        matches!(e2e, crate::graph::GateFix::Retry),
3962        "post-repair e2e has not passed on the landing head; nothing published"
3963    );
3964    anyhow::ensure!(
3965        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3966            && git::is_clean(&winner.worktree).await?,
3967        "landing tree changed during post-repair e2e"
3968    );
3969    crate::pr_head::ensure_owner(state)?;
3970    if owner_requested_stop(state)? {
3971        bail!("owner requested stop before publication");
3972    }
3973    let current = forge.view(&state.repo, pr_url).await?;
3974    anyhow::ensure!(
3975        current.pr.state == PrLifecycle::Open,
3976        "PR closed or merged while gating; nothing published"
3977    );
3978    anyhow::ensure!(
3979        current.head == observed || current.head == checked,
3980        "PR head changed while gating; nothing published"
3981    );
3982    let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3983    let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3984    let refspec = format!("{checked}:refs/heads/{public}");
3985    let lock = repo_merge_lock(&state.repo);
3986    let _slot = lock.lock().await;
3987    let pushed = git::git_raw(
3988        &winner.worktree,
3989        &["push", &lease, &state.config.merge.remote, &refspec],
3990    )
3991    .await?;
3992    anyhow::ensure!(
3993        pushed.ok(),
3994        "publishing gated landing head failed: {}",
3995        pushed.stderr
3996    );
3997    state.pr_pushed_head = Some(checked.clone());
3998    state.land_sync_pending = false;
3999    state.save()?;
4000    Ok(checked)
4001}
4002
4003/// A scratch rebase moves the winner ref before its index and files. Recover
4004/// only a tree proven unchanged since the saved start; never discard edits.
4005async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
4006    let winner = state.winner().cloned().context("landing has no winner")?;
4007    let inherited = crate::repo_lock::WaitPolicy::current();
4008    let wait = crate::repo_lock::WaitPolicy::new(
4009        Duration::from_secs(state.config.graph.timeout_resource_wait),
4010        move || inherited.cancelled(),
4011    );
4012    let _coordination = crate::repo_lock::SeatLease::operation_with(
4013        &winner.worktree,
4014        state.config.coordination.as_ref(),
4015        &wait,
4016    )
4017    .await?;
4018    if !git::is_clean(&winner.worktree).await? {
4019        let from = state
4020            .land_rebase_from
4021            .as_deref()
4022            .context("landing worktree is dirty")?;
4023        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4024        anyhow::ensure!(head != from, "landing worktree is dirty");
4025        for args in [
4026            vec!["diff", "--cached", "--quiet", from],
4027            vec!["diff", "--quiet"],
4028        ] {
4029            anyhow::ensure!(
4030                git::git_raw(&winner.worktree, &args).await?.ok(),
4031                "landing worktree has edits beyond the interrupted rebase"
4032            );
4033        }
4034        // Preserve build artifacts and local files, including ignored ones.
4035        // read-tree can overwrite ignored paths, so reject collisions with
4036        // the new tree (including file/directory conflicts) before updating.
4037        let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
4038        let tracked = git::git(
4039            &winner.worktree,
4040            &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
4041        )
4042        .await?;
4043        for local in untracked.split('\0').filter(|p| !p.is_empty()) {
4044            anyhow::ensure!(
4045                !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
4046                    path == local
4047                        || path
4048                            .strip_prefix(local)
4049                            .is_some_and(|rest| rest.starts_with('/'))
4050                        || local
4051                            .strip_prefix(path)
4052                            .is_some_and(|rest| rest.starts_with('/'))
4053                }),
4054                "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
4055            );
4056        }
4057        git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
4058    }
4059    crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
4060    state.land_rebase_from = None;
4061    state.save()?;
4062    Ok(())
4063}
4064
4065async fn rebase_landing_worktree(
4066    state: &mut RunState,
4067    onto: &str,
4068) -> Result<crate::rebase::Rebased> {
4069    restore_landing_worktree(state).await?;
4070    let winner = state.winner().cloned().context("landing has no winner")?;
4071    state.land_sync_pending = true;
4072    state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
4073    state.save()?;
4074    let scratch = state.dir().join("rebase");
4075    let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
4076    if result == crate::rebase::Rebased::Applied {
4077        restore_landing_worktree(state).await?;
4078    }
4079    Ok(result)
4080}
4081
4082/// Reconcile a stopped landing before changing its branch. This also handles
4083/// old records whose graph status or merge detail no longer identifies land.
4084async fn resume_landing_head<F: Forge>(
4085    state: &mut RunState,
4086    observed: &str,
4087    pr_url: &str,
4088    forge: &F,
4089) -> Result<String> {
4090    crate::pr_head::ensure_owner(state)?;
4091    let winner = state.winner().cloned().context("landing has no winner")?;
4092    let local = git::rev_parse(&winner.worktree, "HEAD").await?;
4093    let expected = state
4094        .pr_pushed_head
4095        .as_deref()
4096        .or(state.gate_head.as_deref())
4097        .context("missing public head lease")?;
4098    anyhow::ensure!(
4099        observed == expected
4100            || (observed == local
4101                && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
4102                    || git::is_ancestor(&winner.worktree, expected, &local).await)),
4103        "public head moved outside this run; refusing to sync"
4104    );
4105    // An operator may have extended and published the winner while approval
4106    // was pending. Matching local/public history can be adopted as the lease,
4107    // but publish_landing_head must gate its new SHA before proceeding. A
4108    // remote-only or divergent update still cannot be overwritten.
4109    // Save reconciliation before rebasing again; a prior push may have reached
4110    // the remote immediately before the previous driver's last save.
4111    state.pr_pushed_head = Some(observed.to_owned());
4112    state.save()?;
4113    restore_landing_worktree(state).await?;
4114    let remote = state.config.merge.remote.clone();
4115    let base = state.base_branch.clone();
4116    let fetched = git::fetch(&state.repo, &remote, &base).await?;
4117    anyhow::ensure!(
4118        fetched.ok(),
4119        "cannot fetch landing base: {}",
4120        fetched.stderr
4121    );
4122    let onto = format!("{remote}/{base}");
4123    let tip = git::rev_parse(&state.repo, &onto).await?;
4124    if !git::is_ancestor(&state.repo, &tip, &local).await {
4125        match rebase_landing_worktree(state, &onto).await? {
4126            crate::rebase::Rebased::Applied => {}
4127            crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4128        }
4129    }
4130    publish_landing_head(state, observed, pr_url, forge).await
4131}
4132
4133async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4134    crate::pr_head::ensure_owner(state)?;
4135    state.land_unrelated = None;
4136    let reopening_hold = crate::merge_decision::resumable_hold(state);
4137    let repo = state.repo.clone();
4138    let budget = state.config.graph.land_rounds;
4139    let mut round = 0usize;
4140    // Counted apart from `round`: a rebase is not a fix, and a base that
4141    // moved is not the change's fault.
4142    let mut rebases = 0usize;
4143    let ci = state.config.merge.ci.expectation();
4144    if ci == CiExpectation::Absent {
4145        state.event(
4146            "land",
4147            "CI not expected ([merge] ci = \"none\"); check status ignored",
4148        );
4149    }
4150    let mut waited = Duration::ZERO;
4151    // Comment bodies the fixer has already been shown. A comment is
4152    // outstanding until it has been handed over once; after that it is a
4153    // recorded decision, not an open question, and re-feeding it would loop the
4154    // budget away on a comment the fixer already declined with an argument.
4155    let mut shown: BTreeSet<String> = BTreeSet::new();
4156    // Await the last successfully published head, including across resumes.
4157    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4158    // Whether a settled-checks refusal has already been given its one re-look.
4159    let mut rechecked = false;
4160
4161    // Marks the run resumable through exactly this function, not through a
4162    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
4163    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
4164    // status specifically to know a resumed run belongs back in `land`
4165    // rather than at a second `gh pr create`. Set on every entry - fresh or
4166    // resumed - because a resume that parked here again must keep reading
4167    // `Landing`, not whatever a first pass through `merge` left behind.
4168    state.status = RunStatus::Landing;
4169    state.event("land", format!("watching {pr_url}"));
4170    state.save()?;
4171
4172    // An armed merge recorded by an earlier pass may or may not have reached
4173    // the forge (the record is written before the call). It is taken back on
4174    // the first observation, where a pull request is known to stop with.
4175    let mut resumed_armed = state.land_armed_head.is_some();
4176
4177    loop {
4178        let seen = forge.view(&repo, pr_url).await?;
4179        let mut pr = seen.pr.clone();
4180        pr.review_comments.retain(|c| !shown.contains(&c.body));
4181        state.pr = Some(crate::run::PrRecord {
4182            url: pr.url.clone(),
4183            number: pr.number,
4184            state: pr.state.as_str().to_owned(),
4185            checks: pr.checks.as_str().to_owned(),
4186            round,
4187            rounds: budget,
4188            red_at_merge: Vec::new(),
4189        });
4190        state.save()?;
4191
4192        if pr.state == PrLifecycle::Open {
4193            state.observe_branch_owner_holds(&seen.head).await;
4194            state.save()?;
4195        }
4196        crate::pr_head::ensure_owner(state)?;
4197        if reopening_hold {
4198            anyhow::ensure!(
4199                pr.state == PrLifecycle::Open,
4200                "held PR is no longer open; cannot request a fresh decision"
4201            );
4202            // An explicit resume authorizes a fresh question only. Preserve the
4203            // answered card, and never enter repair/rebase/merge on this pass.
4204            disarm(forge, state, &repo, pr.number)
4205                .await
4206                .map_err(anyhow::Error::msg)?;
4207            let previous = state.merge_decision.take();
4208            let binding = state.land_approval.take();
4209            state.config.graph.land_approval = true;
4210            let subject = merge_subject(
4211                crate::graph::landing_title(state, &seen.title),
4212                &crate::graph::landing_subject_source(state),
4213            );
4214            let contested = contested_to_ask(state);
4215            if let Err(error) =
4216                approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4217            {
4218                state.merge_decision = previous;
4219                state.land_approval = binding;
4220                return Err(error);
4221            }
4222            state.end_pass_parked(crate::run::ParkCause::Question);
4223            state.parked = true;
4224            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()));
4225            state.save()?;
4226            return Ok(pr);
4227        }
4228        if owner_stop(state, &mut pr, forge).await? {
4229            return Ok(pr);
4230        }
4231
4232        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4233            // An approval already given for the same head is reused, so
4234            // nothing is asked twice. A failed disable
4235            // stops the run with the record kept: pushing on could change the
4236            // head under an arm that is still live.
4237            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4238                let why = format!(
4239                    "a previous pass may have armed auto-merge and it could not be disabled \
4240                     on resume: {e}"
4241                );
4242                stop(state, &repo, &pr, &why).await?;
4243                return Ok(pr);
4244            }
4245        }
4246
4247        // The head moved away from the one auto-merge was armed on, by
4248        // someone other than this loop. The arm is pinned and would refuse to
4249        // merge the new commit, but the approval was for the old one: take it
4250        // back and go through the normal path again.
4251        if pr.state == PrLifecycle::Open
4252            && !seen.head.is_empty()
4253            && state
4254                .land_armed_head
4255                .as_deref()
4256                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4257        {
4258            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4259                let why = format!(
4260                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
4261                );
4262                stop(state, &repo, &pr, &why).await?;
4263                return Ok(pr);
4264            }
4265        }
4266
4267        if state.land_sync_pending && pr.state == PrLifecycle::Open {
4268            // Disarming can outlive an owner answer. Consume it before our
4269            // own rebase invalidates the head shown on the decision card.
4270            if owner_stop(state, &mut pr, forge).await? {
4271                return Ok(pr);
4272            }
4273            let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4274                binding.head.eq_ignore_ascii_case(&seen.head)
4275                    && ask::Questions::open()
4276                        .get(&binding.question)
4277                        .is_ok_and(|q| {
4278                            q.run == state.id
4279                                && q.node == APPROVAL_NODE
4280                                && q.status == ask::QuestionStatus::Answered
4281                                && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4282                        })
4283            });
4284            if repair_answer {
4285                let subject = merge_subject(
4286                    crate::graph::landing_title(state, &seen.title),
4287                    &crate::graph::landing_subject_source(state),
4288                );
4289                let contested = contested_to_ask(state);
4290                if matches!(
4291                    approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4292                    ApprovalGate::Repair
4293                ) {
4294                    if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4295                        state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4296                        state.save()?;
4297                    }
4298                    return Ok(pr);
4299                }
4300            }
4301            let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4302            // A stop detected before publication must apply its durable
4303            // decision, even when verification returned an error meanwhile.
4304            if owner_stop(state, &mut pr, forge).await? {
4305                return Ok(pr);
4306            }
4307            let head = synced?;
4308            awaiting_head = Some(head);
4309            waited = Duration::ZERO;
4310            rechecked = false;
4311            // Read CI and approvals afresh, never from the pre-push observation.
4312            continue;
4313        }
4314
4315        if pr.state == PrLifecycle::Open {
4316            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4317                if waited >= WAIT_CEILING {
4318                    let want = awaiting_head.as_deref().unwrap_or_default();
4319                    let why = format!(
4320                        "the pull request's checks were still not about one readable head after \
4321                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
4322                         someone may have pushed over it",
4323                        WAIT_CEILING.as_secs() / 60,
4324                        if want.is_empty() { "any" } else { want },
4325                        if seen.head.is_empty() {
4326                            "nothing readable"
4327                        } else {
4328                            &seen.head
4329                        },
4330                        if seen.rollup_head.is_empty() {
4331                            "nothing readable"
4332                        } else {
4333                            &seen.rollup_head
4334                        },
4335                    );
4336                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
4337                    return Ok(pr);
4338                }
4339                waited += POLL;
4340                forge.poll().await;
4341                continue;
4342            }
4343            // Reset once, when the awaited head first shows up; resetting on
4344            // every re-observation would let a standing refusal wait forever.
4345            if awaiting_head.take().is_some() {
4346                // The checks now being read belong to the new head; give them
4347                // the same grace a fresh pull request gets.
4348                waited = Duration::ZERO;
4349            }
4350        }
4351
4352        // The merge-state grace is head-bound and durable. Reruns reset the
4353        // checks clock, but must not extend this deadline across resumes.
4354        let decision_wait = if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid {
4355            let first = state
4356                .land_unsaid
4357                .get_or_insert_with(|| (seen.head.clone(), forge.now()));
4358            if !first.0.eq_ignore_ascii_case(&seen.head) {
4359                *first = (seen.head.clone(), forge.now());
4360            }
4361            let elapsed = Duration::from_secs(forge.now().saturating_sub(first.1).max(0) as u64);
4362            state.save()?;
4363            elapsed
4364        } else {
4365            waited
4366        };
4367        let proposed = decide_with(&pr, round, budget, decision_wait, ci);
4368        if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid && proposed == Step::Wait {
4369            if waited >= WAIT_CEILING {
4370                stop_disarmed(
4371                    forge,
4372                    state,
4373                    &repo,
4374                    &pr,
4375                    "merge state remained unreadable; refusing to merge on a guess",
4376                )
4377                .await?;
4378                return Ok(pr);
4379            }
4380            waited += POLL;
4381            forge.poll().await;
4382            continue;
4383        }
4384
4385        // A failed workflow run is rerun once, judged by its own run rather
4386        // than by the rollup's aggregate: another run still pending, or a fix
4387        // budget already spent, must not keep a flaky failure from its one
4388        // rerun. It spends no round and no attempt.
4389        if ci == CiExpectation::Expected
4390            && pr.state == PrLifecycle::Open
4391            && state.config.graph.rerun_flaky_checks
4392        {
4393            let include_optional = pr.checks == Checks::Red
4394                && matches!(proposed, Step::Fix { .. } | Step::GiveUp { .. });
4395            let failing = rerunnable_with(&seen.contexts, include_optional);
4396            let now = forge.now();
4397            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4398                RerunStep::Rerun(runs) => {
4399                    let rec = state.land_reruns.get_or_insert_with(Default::default);
4400                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
4401                        *rec = LandReruns {
4402                            head: seen.head.clone(),
4403                            runs: BTreeMap::new(),
4404                        };
4405                    }
4406                    for run in &runs {
4407                        rec.runs.insert(run.clone(), now);
4408                    }
4409                    // Durable before the call: a restart must not
4410                    // rerun the same run twice. A save that fails
4411                    // means no rerun.
4412                    state.save()?;
4413                    let mut any = false;
4414                    for run in &runs {
4415                        let names = failing
4416                            .iter()
4417                            .filter(|(_, r)| r.as_deref() == Some(run))
4418                            .map(|(l, _)| l.as_str())
4419                            .collect::<Vec<_>>()
4420                            .join(", ");
4421                        match forge.rerun(&repo, run).await {
4422                            Ok(()) => {
4423                                any = true;
4424                                state.event(
4425                                    "land",
4426                                    format!("reran failed check(s) {names} (run {run})"),
4427                                );
4428                            }
4429                            // Stays recorded: never a retry loop.
4430                            Err(e) => state.event(
4431                                "land",
4432                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4433                            ),
4434                        }
4435                    }
4436                    state.save()?;
4437                    if any {
4438                        waited = Duration::ZERO;
4439                        forge.poll().await;
4440                        continue;
4441                    }
4442                }
4443                RerunStep::Grace => {
4444                    if waited >= WAIT_CEILING {
4445                        let why = format!(
4446                            "checks were still failing after a rerun and {} minutes",
4447                            WAIT_CEILING.as_secs() / 60
4448                        );
4449                        stop(state, &repo, &pr, &why).await?;
4450                        return Ok(pr);
4451                    }
4452                    waited += POLL;
4453                    forge.poll().await;
4454                    continue;
4455                }
4456                RerunStep::Fix => {}
4457            }
4458        }
4459
4460        let step = proposed;
4461        // Armed on exactly this head: the forge is doing the waiting. A
4462        // decision to merge (or keep waiting) is only watched, never re-armed
4463        // and never re-approved; anything else - a red check, a comment, a
4464        // conflict - falls through to its own arm, which disarms first.
4465        let armed_here = state
4466            .land_armed_head
4467            .as_deref()
4468            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4469        if armed_here && matches!(step, Step::Merge | Step::Wait) {
4470            if waited >= WAIT_CEILING {
4471                let required = if seen.base.is_empty() {
4472                    None
4473                } else {
4474                    forge.required_contexts(&repo, &seen.base).await
4475                };
4476                let why = format!(
4477                    "auto-merge was armed on {} but the pull request did not merge within {} \
4478                     minutes ({})",
4479                    seen.head,
4480                    WAIT_CEILING.as_secs() / 60,
4481                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4482                );
4483                stop_disarmed(forge, state, &repo, &pr, &why).await?;
4484                return Ok(pr);
4485            }
4486            waited += POLL;
4487            forge.poll().await;
4488            continue;
4489        }
4490        if armed_here && !matches!(step, Step::Done { .. }) {
4491            // Not merging or waiting any more: whatever is next may change the
4492            // head or give up, and neither may leave the arm standing.
4493            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4494                let why = format!("auto-merge could not be disabled: {e}");
4495                stop(state, &repo, &pr, &why).await?;
4496                return Ok(pr);
4497            }
4498        }
4499        if pr.checks == Checks::Red
4500            && matches!(step, Step::Fix { .. } | Step::GiveUp { .. })
4501            && pr.review_comments.is_empty()
4502            && let Some(evidence) = forge.attribution(state, &seen).await
4503            && evidence.unrelated(&seen.head, &pr.failing)
4504        {
4505            if owner_stop(state, &mut pr, forge).await? {
4506                return Ok(pr);
4507            }
4508            // Attribution does not authorize action against an unseen push.
4509            let fresh = forge.view(&repo, pr_url).await?;
4510            if fresh.head != seen.head
4511                || fresh.rollup_head != seen.rollup_head
4512                || fresh.pr.state != PrLifecycle::Open
4513                || fresh.pr.checks != Checks::Red
4514                || fresh.pr.failing != pr.failing
4515                || fresh
4516                    .pr
4517                    .review_comments
4518                    .iter()
4519                    .any(|c| !shown.contains(&c.body))
4520            {
4521                continue;
4522            }
4523            hold_unrelated(state, &pr, evidence).await?;
4524            return Ok(pr);
4525        }
4526        match step {
4527            Step::Wait => {
4528                if waited >= WAIT_CEILING {
4529                    let why = format!(
4530                        "checks were still running after {} minutes",
4531                        WAIT_CEILING.as_secs() / 60
4532                    );
4533                    stop(state, &repo, &pr, &why).await?;
4534                    return Ok(pr);
4535                }
4536                waited += POLL;
4537                forge.poll().await;
4538            }
4539            Step::Done { merged } => {
4540                if merged {
4541                    state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4542                }
4543                // Auto-merge is pinned to the approved head, but the forge's
4544                // own handling of a later push is not something magi can
4545                // see: if the pull request merged on another commit, say so
4546                // loudly rather than record a clean landing.
4547                if let Some(armed) = state.land_armed_head.take() {
4548                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4549                        let msg = format!(
4550                            "{} merged on {} but the owner approved {armed}; review what landed",
4551                            pr.url, seen.head
4552                        );
4553                        tracing::warn!("{msg}");
4554                        state.event("land", msg);
4555                        // Only an arm left by an older build can get here.
4556                        // The wording is fixed so a repeat does not relight
4557                        // the notice.
4558                        crate::notices::raise_with(
4559                            crate::notices::Notice::warn(
4560                                &format!("merged-unapproved-head:{}", state.id),
4561                                "A pull request merged on a commit the owner did not approve; \
4562                                 review what landed",
4563                            )
4564                            .link(crate::notices::Link::Run {
4565                                id: state.id.clone(),
4566                            }),
4567                            &state.config.notify,
4568                        );
4569                    }
4570                }
4571                state.status = if merged {
4572                    RunStatus::Merged
4573                } else {
4574                    RunStatus::Ready
4575                };
4576                let detail = if merged {
4577                    format!("{} was merged", pr.url)
4578                } else {
4579                    format!("{} was closed without merging", pr.url)
4580                };
4581                state.merge = Some(MergeOutcome {
4582                    mode: MergeMode::Pr,
4583                    ok: merged,
4584                    detail: detail.clone(),
4585                    empty: false,
4586                });
4587                state.event("land", detail);
4588                state.save()?;
4589                write_pr_state_through(state, pr.state);
4590                return Ok(pr);
4591            }
4592            Step::Merge => {
4593                state.check_delivery_evidence(&seen.head)?;
4594                let subject = merge_subject(
4595                    crate::graph::landing_title(state, &seen.title),
4596                    &crate::graph::landing_subject_source(state),
4597                );
4598                // The owner sees the panel before the one irreversible step,
4599                // and an unanswered question is a hold: silence never merges.
4600                //
4601                // The dedicated policy includes existing consent and post-repair
4602                // decisions; repair choice availability never grants merge authority.
4603                let contested = contested_to_ask(state);
4604                if !approval_reasons(state).is_empty() {
4605                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4606                        .await?
4607                    {
4608                        ApprovalGate::Approved => {}
4609                        ApprovalGate::Repair => {
4610                            // The driver consumes the saved request under the existing
4611                            // run/task claim. No merge, no second run or task.
4612                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4613                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4614                                state.save()?;
4615                            }
4616                            return Ok(pr);
4617                        }
4618                        ApprovalGate::Held => {
4619                            // Another entry point may have saved a stop after
4620                            // the first read above. Apply its durable action.
4621                            if owner_stop(state, &mut pr, forge).await? {
4622                                return Ok(pr);
4623                            }
4624                            stop(
4625                                state,
4626                                &repo,
4627                                &pr,
4628                                "the owner did not approve the merge (held or unanswered)",
4629                            )
4630                            .await?;
4631                            return Ok(pr);
4632                        }
4633                        // Filed (or still standing from an earlier visit) and
4634                        // not yet answered. Park here rather than wait: the
4635                        // question survives on disk, the daemon hands this
4636                        // run's slot to something else, and a later resume
4637                        // re-enters `land`, finds the same question, and
4638                        // either merges or stops depending on what it says
4639                        // by then.
4640                        ApprovalGate::Pending => {
4641                            state.end_pass_parked(crate::run::ParkCause::Question);
4642                            state.parked = true;
4643                            state.event(
4644                                "land",
4645                                "parked awaiting merge approval - resumes once answered",
4646                            );
4647                            state.save()?;
4648                            return Ok(pr);
4649                        }
4650                    }
4651                }
4652                // Open and past the gate above, so `seen.head` is the commit
4653                // the checks were bound to and the owner approved.
4654                //
4655                // Merge directly, bound to that commit. Auto-merge is not
4656                // armed any more: the forge checks `--match-head-commit` only
4657                // when the request is made, so an arm outlives the head it
4658                // was made for, and a push of another commit whose
4659                // requirements are met first would merge without approval.
4660                // A direct merge is checked by the forge at the moment it
4661                // happens, so only the approved head can land.
4662                let observed_head = seen.head.clone();
4663                // Read the pull request again just before: the state seen
4664                // above can be a moment old.
4665                {
4666                    let fresh = forge.view(&repo, pr_url).await.ok();
4667                    if !direct_merge_is_safe(
4668                        fresh.as_ref(),
4669                        &observed_head,
4670                        &shown,
4671                        round,
4672                        budget,
4673                        waited,
4674                        ci,
4675                    ) {
4676                        if waited >= WAIT_CEILING {
4677                            let why = "the pull request did not settle on the approved head \
4678                                       before it could be merged";
4679                            stop(state, &repo, &pr, why).await?;
4680                            return Ok(pr);
4681                        }
4682                        state.event(
4683                            "land",
4684                            "the pull request changed before merging; looking again",
4685                        );
4686                        waited += POLL;
4687                        forge.poll().await;
4688                        continue;
4689                    }
4690                }
4691                let argv = merge_argv_at(pr.number, &subject, &observed_head);
4692                let out = {
4693                    let merge_lock = repo_merge_lock(&repo);
4694                    let _merge_slot = merge_lock.lock().await;
4695                    crate::pr_head::ensure_owner(state)?;
4696                    if let Some(task_id) = state
4697                        .origin
4698                        .as_ref()
4699                        .and_then(|origin| origin.task.as_ref())
4700                    {
4701                        let task = crate::queue::Queue::open()
4702                            .get(task_id)
4703                            .context("cannot verify the task owner immediately before merge")?;
4704                        if task.runs.last() != Some(&state.id)
4705                            || task.operator_held()
4706                            || task.status == crate::queue::TaskStatus::Done
4707                        {
4708                            bail!(
4709                                "task ownership or an operator hold superseded this merge approval"
4710                            );
4711                        }
4712                    }
4713                    if let Some(binding) = &state.land_approval {
4714                        let current = ask::Questions::open().get(&binding.question)?;
4715                        if merge_consent_superseded(state, &current)?
4716                            || current.run != state.id
4717                            || !binding.head.eq_ignore_ascii_case(&observed_head)
4718                            || approval(current.resolution().as_deref()) != Approval::Merge
4719                            || current.merge_target.as_ref().is_some_and(|t| {
4720                                t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4721                            })
4722                        {
4723                            bail!(
4724                                "merge decision is stale or no longer authorizes the reviewed head"
4725                            );
4726                        }
4727                    }
4728                    forge.merge(&repo, &argv).await?
4729                };
4730                if out.0 {
4731                    // Exit 0 is not proof of a merge: with a merge queue `gh`
4732                    // enqueues (or reports the pull request already queued)
4733                    // and succeeds while it is still open. Ask the forge; an
4734                    // unreadable answer is not a confirmation either, so it
4735                    // is looked at again rather than recorded as merged.
4736                    let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4737                        if c.pr.state == PrLifecycle::Merged {
4738                            state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4739                            true
4740                        } else {
4741                            false
4742                        }
4743                    });
4744                    if !confirmed {
4745                        if waited >= WAIT_CEILING {
4746                            let why = "the merge request succeeded but the pull request \
4747                                       could not be confirmed merged after waiting";
4748                            stop(state, &repo, &pr, why).await?;
4749                            return Ok(pr);
4750                        }
4751                        state.event(
4752                            "land",
4753                            "merge accepted but the pull request is not confirmed merged yet; waiting",
4754                        );
4755                        state.save()?;
4756                        waited += POLL;
4757                        forge.poll().await;
4758                        continue;
4759                    }
4760                    pr.state = PrLifecycle::Merged;
4761                    state.status = RunStatus::Merged;
4762                    state.merge = Some(MergeOutcome {
4763                        mode: MergeMode::Pr,
4764                        ok: true,
4765                        detail: format!("gh {}", argv.join(" ")),
4766                        empty: false,
4767                    });
4768                    // The last `state.pr` snapshot is whatever the poll before
4769                    // this merge observed - still `open` - and nothing below
4770                    // refreshes it from GitHub again, so the UI's round rail
4771                    // would otherwise keep animating a merged run forever.
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 after_seen = forge.view(&repo, pr_url).await.ok();
4782                let after = after_seen.as_ref().map(|s| s.pr.state);
4783                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4784                    state.pr_merged_head = after_seen
4785                        .as_ref()
4786                        .map(|s| s.head.clone())
4787                        .filter(|h| !h.is_empty());
4788                    pr.state = PrLifecycle::Merged;
4789                    state.status = RunStatus::Merged;
4790                    state.merge = Some(outcome);
4791                    if let Some(pr_record) = state.pr.as_mut() {
4792                        pr_record.state = pr.state.as_str().to_owned();
4793                    }
4794                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4795                    announce_red_merge(state, &pr).await;
4796                    state.save()?;
4797                    write_pr_state_through(state, pr.state);
4798                    return Ok(pr);
4799                }
4800                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head, ci);
4801                match verdict {
4802                    Refused::Final => {
4803                        let merge_state = after_seen
4804                            .as_ref()
4805                            .map(|s| s.merge_state.as_str())
4806                            .filter(|m| !m.is_empty())
4807                            .unwrap_or("unknown");
4808                        // Which refusal this was decides what a person has to
4809                        // do about it, so the two are worded apart; both carry
4810                        // the forge's own message.
4811                        let why = format!(
4812                            "the merge was refused: {} (merge state: {merge_state})",
4813                            out.1
4814                        );
4815                        stop(state, &repo, &pr, &why).await?;
4816                        return Ok(pr);
4817                    }
4818                    verdict => {
4819                        // Back to the top, which re-decides and passes the
4820                        // approval gate again, a poll later. `waited` is not
4821                        // reset here: only a pushed fix restarts it, or a
4822                        // standing refusal would wait forever.
4823                        if waited >= WAIT_CEILING {
4824                            let why = format!(
4825                                "the merge was still refused after {} minutes: {}",
4826                                WAIT_CEILING.as_secs() / 60,
4827                                out.1
4828                            );
4829                            stop(state, &repo, &pr, &why).await?;
4830                            return Ok(pr);
4831                        }
4832                        if verdict == Refused::Recheck {
4833                            rechecked = true;
4834                        }
4835                        state.event(
4836                            "land",
4837                            "merge refused while the branch policy is not satisfied yet; waiting",
4838                        );
4839                        state.save()?;
4840                        waited += POLL;
4841                        forge.poll().await;
4842                    }
4843                }
4844            }
4845            Step::Rebase => {
4846                // Bounded by the same budget as a fix, because a rebase that
4847                // keeps being needed means the base moves faster than this
4848                // run can land and a person should decide what to do. It
4849                // spends none of that budget: the change is not what is
4850                // wrong.
4851                if rebases >= budget {
4852                    let why = format!(
4853                        "the base moved under this branch {budget} time(s) and it still does \
4854                         not merge; rebasing again would only race it"
4855                    );
4856                    stop(state, &repo, &pr, &why).await?;
4857                    return Ok(pr);
4858                }
4859                rebases += 1;
4860                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4861                    stop(
4862                        state,
4863                        &repo,
4864                        &pr,
4865                        "the pull request conflicts and this run has no winning branch to rebase",
4866                    )
4867                    .await?;
4868                    return Ok(pr);
4869                };
4870                let base = state.base_branch.clone();
4871                state.event(
4872                    "land",
4873                    format!("{} no longer merges; rebasing onto {base}", pr.url),
4874                );
4875                state.save()?;
4876
4877                // Onto the base as the *remote* has it: the local ref may be
4878                // behind, and rebasing onto a stale base produces a branch
4879                // that conflicts all over again.
4880                let remote = state.config.merge.remote.clone();
4881                git::fetch(&repo, &remote, &base).await.ok();
4882                if owner_stop(state, &mut pr, forge).await? {
4883                    return Ok(pr);
4884                }
4885                let onto = format!("{remote}/{base}");
4886                let rebased = match rebase_landing_worktree(state, &onto).await {
4887                    Ok(crate::rebase::Rebased::Applied) => Ok(None),
4888                    Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4889                    Err(e) => Err(e),
4890                };
4891                if owner_stop(state, &mut pr, forge).await? {
4892                    return Ok(pr);
4893                }
4894                match rebased {
4895                    Ok(None) => {
4896                        let published =
4897                            publish_landing_head(state, &seen.head, pr_url, forge).await;
4898                        if owner_stop(state, &mut pr, forge).await? {
4899                            return Ok(pr);
4900                        }
4901                        let head = published?;
4902                        crate::graph::refresh_reviewed_commits(state, &branch).await;
4903                        state.pr_pushed_head = Some(head.clone());
4904                        awaiting_head = Some(head);
4905                        rechecked = false;
4906                        state.event("land", format!("rebased {branch} onto {base}"));
4907                        state.save()?;
4908                        // The forge has to re-run its checks against the
4909                        // rebased head before anything else can be decided.
4910                        waited = Duration::ZERO;
4911                        tokio::time::sleep(POLL).await;
4912                    }
4913                    // The fixer's rounds are spent (or it could not finish):
4914                    // that is a decision for a person.
4915                    Ok(Some(conflict)) => {
4916                        let why = format!(
4917                            "{} conflicts with {base} and the rebase did not apply: {}",
4918                            pr.url,
4919                            conflict.chars().take(600).collect::<String>()
4920                        );
4921                        stop(state, &repo, &pr, &why).await?;
4922                        return Ok(pr);
4923                    }
4924                    Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4925                    Err(e) => {
4926                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
4927                        stop(state, &repo, &pr, &why).await?;
4928                        return Ok(pr);
4929                    }
4930                }
4931            }
4932            Step::GiveUp { reason } => {
4933                stop(state, &repo, &pr, &reason).await?;
4934                return Ok(pr);
4935            }
4936            Step::Fix { reason } => {
4937                round += 1;
4938                waited = Duration::ZERO;
4939                for c in &pr.review_comments {
4940                    shown.insert(c.body.clone());
4941                }
4942                state.event("land", format!("round {round}: {reason}"));
4943                state.save()?;
4944
4945                let logs = failing_logs(&repo, &seen.failing_urls).await;
4946                let was_red = pr.checks == Checks::Red;
4947                if owner_stop(state, &mut pr, forge).await? {
4948                    return Ok(pr);
4949                }
4950                let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4951                if owner_stop(state, &mut pr, forge).await? {
4952                    return Ok(pr);
4953                }
4954                match fixed {
4955                    Fixed::Committed { head } => {
4956                        // Whatever the next look shows may still be the
4957                        // previous head; see `awaiting_new_head`.
4958                        state.pr_pushed_head = Some(head.clone());
4959                        awaiting_head = Some(head);
4960                        rechecked = false;
4961                        waited = Duration::ZERO;
4962                        forge.poll().await;
4963                    }
4964                    Fixed::Declined if was_red => {
4965                        // Review comments may have required this round even
4966                        // when CI was unrelated. Once the fixer has read them,
4967                        // use the same structural attribution before charging
4968                        // a no-commit stop to the task.
4969                        if let Some(evidence) = forge.attribution(state, &seen).await
4970                            && evidence.unrelated(&seen.head, &pr.failing)
4971                        {
4972                            let fresh = forge.view(&repo, pr_url).await?;
4973                            if fresh.head != seen.head
4974                                || fresh.rollup_head != seen.rollup_head
4975                                || fresh.pr.state != PrLifecycle::Open
4976                                || fresh.pr.checks != Checks::Red
4977                                || fresh.pr.failing != pr.failing
4978                                || fresh
4979                                    .pr
4980                                    .review_comments
4981                                    .iter()
4982                                    .any(|c| !shown.contains(&c.body))
4983                            {
4984                                continue;
4985                            }
4986                            if owner_stop(state, &mut pr, forge).await? {
4987                                return Ok(pr);
4988                            }
4989                            hold_unrelated(state, &pr, evidence).await?;
4990                            return Ok(pr);
4991                        }
4992                        let why = format!(
4993                            "the fixer produced no commit while {} check(s) were failing \
4994                             ({}); stopping instead of looping on an unchanged tree",
4995                            pr.failing.len(),
4996                            pr.failing.join(", ")
4997                        );
4998                        stop(state, &repo, &pr, &why).await?;
4999                        return Ok(pr);
5000                    }
5001                    // Comment-driven round with no commit: the fixer read the
5002                    // comments and changed nothing, which is a decision it is
5003                    // allowed to make. The comments are recorded as shown, so
5004                    // the next observation sees a clean pull request.
5005                    Fixed::Declined => state.event(
5006                        "land",
5007                        format!("round {round}: fixer declined the comments, nothing committed"),
5008                    ),
5009                    Fixed::Failed(why) => {
5010                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
5011                        return Ok(pr);
5012                    }
5013                }
5014                state.save()?;
5015            }
5016        }
5017    }
5018}
5019
5020/// Owner decision for a CI failure independently reproduced on the base.
5021pub const CI_HOLD_NODE: &str = "land-ci-unrelated";
5022
5023/// Durable machine-hold evidence, bound to one observed PR head.
5024#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
5025pub struct CiHold {
5026    /// Head that exhibited the independently reproduced failures.
5027    pub head: String,
5028    /// Typed owner question controlling resumption.
5029    pub question: String,
5030    /// Complete structured facts used to attribute the failures.
5031    pub evidence: CiEvidence,
5032}
5033
5034/// Only structured forge annotations are compared, never log prose.
5035#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5036struct CiFailure {
5037    path: String,
5038    start_line: u64,
5039    end_line: u64,
5040    title: String,
5041    message: String,
5042}
5043
5044#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
5045struct CiCheckEvidence {
5046    label: String,
5047    head_check: u64,
5048    base_check: u64,
5049    app: u64,
5050    run: String,
5051    base_run: String,
5052    head_failures: Vec<CiFailure>,
5053    base_failures: Vec<CiFailure>,
5054    rerun_exhausted: bool,
5055}
5056
5057/// Pinned Git diff and forge failure annotations used for CI attribution.
5058#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
5059pub struct CiEvidence {
5060    head: String,
5061    base: String,
5062    changed: Vec<String>,
5063    checks: Vec<CiCheckEvidence>,
5064}
5065
5066impl CiEvidence {
5067    /// Every red check must have the same located failure on the base, outside
5068    /// the diff. Changes to CI/build configuration invalidate this inference.
5069    fn unrelated(&self, head: &str, failing: &[String]) -> bool {
5070        if self.head != head
5071            || self.base.is_empty()
5072            || failing.is_empty()
5073            || self.changed.iter().any(|p| {
5074                p.starts_with(".github/")
5075                    || p.starts_with(".config/")
5076                    || p.starts_with(".cargo/")
5077                    || matches!(
5078                        p.rsplit('/').next().unwrap_or(p),
5079                        "Cargo.toml"
5080                            | "Cargo.lock"
5081                            | "Makefile.toml"
5082                            | "build.rs"
5083                            | "rust-toolchain.toml"
5084                    )
5085            })
5086        {
5087            return false;
5088        }
5089        failing.iter().all(|label| {
5090            let matches: Vec<_> = self.checks.iter().filter(|c| &c.label == label).collect();
5091            matches.len() == 1
5092                && matches[0].rerun_exhausted
5093                && !matches[0].head_failures.is_empty()
5094                && matches[0].head_failures.iter().all(|f| {
5095                    // Workflow-level exit-code annotations do not identify a
5096                    // failing test or source file, and prove nothing here.
5097                    !f.path.is_empty()
5098                        && !f.path.starts_with('.')
5099                        && !f.path.contains("..")
5100                        && !f.path.starts_with('/')
5101                        && !self.changed.contains(&f.path)
5102                        && matches[0].base_failures.contains(f)
5103                })
5104        })
5105    }
5106}
5107
5108async fn ci_json(repo: &Path, endpoint: String) -> Option<serde_json::Value> {
5109    let (ok, body) = gh(repo, &["api".into(), endpoint]).await.ok()?;
5110    if !ok {
5111        return None;
5112    }
5113    serde_json::from_str(&body).ok()
5114}
5115
5116async fn ci_annotations(repo: &Path, check: &serde_json::Value) -> Option<Vec<CiFailure>> {
5117    let count = check.pointer("/output/annotations_count")?.as_u64()?;
5118    if count == 0 || count >= 100 {
5119        return None;
5120    }
5121    let id = check.get("id")?.as_u64()?;
5122    let value = ci_json(
5123        repo,
5124        format!("repos/{{owner}}/{{repo}}/check-runs/{id}/annotations?per_page=100"),
5125    )
5126    .await?;
5127    let annotations = value.as_array()?;
5128    if annotations.len() != count as usize {
5129        return None;
5130    }
5131    annotations
5132        .iter()
5133        .filter(|a| a["annotation_level"] == "failure")
5134        .map(|a| {
5135            Some(CiFailure {
5136                path: a["path"].as_str()?.into(),
5137                start_line: a["start_line"].as_u64()?,
5138                end_line: a["end_line"].as_u64()?,
5139                title: a["title"].as_str()?.into(),
5140                message: a["message"].as_str()?.into(),
5141            })
5142        })
5143        .collect()
5144}
5145
5146async fn ci_runs(repo: &Path, sha: &str) -> Option<Vec<serde_json::Value>> {
5147    let value = ci_json(
5148        repo,
5149        format!("repos/{{owner}}/{{repo}}/commits/{sha}/check-runs?per_page=100&filter=latest"),
5150    )
5151    .await?;
5152    let runs = value["check_runs"].as_array()?;
5153    if value["total_count"].as_u64()? != runs.len() as u64 || runs.len() >= 100 {
5154        return None;
5155    }
5156    Some(runs.clone())
5157}
5158
5159async fn collect_ci_evidence(state: &RunState, seen: &Seen) -> Option<CiEvidence> {
5160    let repo = &state.repo;
5161    let base_ref = format!("refs/remotes/{}/{}", state.config.merge.remote, seen.base);
5162    let base = git::git_raw(repo, &["rev-parse", "--verify", &base_ref])
5163        .await
5164        .ok()?;
5165    if !base.ok() {
5166        return None;
5167    }
5168    let range = format!("{}...{}", base.stdout, seen.head);
5169    // No rename detection: both old and new paths must count as changed.
5170    let diff = git::git_raw(
5171        repo,
5172        &["diff", "--no-renames", "--name-only", "-z", &range, "--"],
5173    )
5174    .await
5175    .ok()?;
5176    if !diff.ok() {
5177        return None;
5178    }
5179    let head_runs = ci_runs(repo, &seen.head).await?;
5180    let base_runs = ci_runs(repo, &base.stdout).await?;
5181    let mut checks = Vec::new();
5182    for label in &seen.pr.failing {
5183        let matching = |runs: &[serde_json::Value]| {
5184            runs.iter()
5185                .filter(|r| r["name"].as_str() == Some(label))
5186                .cloned()
5187                .collect::<Vec<_>>()
5188        };
5189        let heads = matching(&head_runs);
5190        let bases = matching(&base_runs);
5191        if heads.len() != 1 || bases.len() != 1 {
5192            return None;
5193        }
5194        let h = &heads[0];
5195        let b = &bases[0];
5196        if h["conclusion"] != "failure"
5197            || b["conclusion"] != "failure"
5198            || h["status"] != "completed"
5199            || b["status"] != "completed"
5200            || h["head_sha"].as_str()? != seen.head
5201            || b["head_sha"].as_str()? != base.stdout
5202            || h.pointer("/app/id")?.as_u64()? != b.pointer("/app/id")?.as_u64()?
5203        {
5204            return None;
5205        }
5206        let run = run_of(h["details_url"].as_str()?)?;
5207        let contexts: Vec<_> = seen.contexts.iter().filter(|c| &c.label == label).collect();
5208        if contexts.len() != 1
5209            || contexts[0].run.as_ref() != Some(&run)
5210            || contexts[0].verdict != Verdict::Fail
5211        {
5212            return None;
5213        }
5214        // A persisted request alone is not a completed rerun: require the
5215        // workflow's structured attempt number as well.
5216        let workflow =
5217            ci_json(repo, format!("repos/{{owner}}/{{repo}}/actions/runs/{run}")).await?;
5218        let base_run = run_of(b["details_url"].as_str()?)?;
5219        let base_workflow = ci_json(
5220            repo,
5221            format!("repos/{{owner}}/{{repo}}/actions/runs/{base_run}"),
5222        )
5223        .await?;
5224        if base_workflow["head_sha"].as_str()? != base.stdout
5225            || base_workflow["head_branch"].as_str()? != seen.base
5226            || base_workflow["status"] != "completed"
5227            || base_workflow["conclusion"] != "failure"
5228            || base_workflow["workflow_id"].as_u64()? != workflow["workflow_id"].as_u64()?
5229        {
5230            return None;
5231        }
5232        let exhausted = state
5233            .land_reruns
5234            .as_ref()
5235            .is_some_and(|r| r.head == seen.head && r.runs.contains_key(&run))
5236            && workflow["head_sha"].as_str()? == seen.head
5237            && workflow["run_attempt"].as_u64()? > 1
5238            && workflow["status"] == "completed";
5239        checks.push(CiCheckEvidence {
5240            label: label.clone(),
5241            head_check: h["id"].as_u64()?,
5242            base_check: b["id"].as_u64()?,
5243            app: h.pointer("/app/id")?.as_u64()?,
5244            run,
5245            base_run,
5246            head_failures: ci_annotations(repo, h).await?,
5247            base_failures: ci_annotations(repo, b).await?,
5248            rerun_exhausted: exhausted,
5249        });
5250    }
5251    Some(CiEvidence {
5252        head: seen.head.clone(),
5253        base: base.stdout,
5254        changed: diff
5255            .stdout
5256            .split('\0')
5257            .filter(|p| !p.is_empty())
5258            .map(str::to_owned)
5259            .collect(),
5260        checks,
5261    })
5262}
5263
5264async fn hold_unrelated(state: &mut RunState, pr: &PrState, evidence: CiEvidence) -> Result<()> {
5265    let detail = serde_json::to_string(&evidence)?;
5266    let resume = "daemon: CI を確認して同じ run を再開する".to_owned();
5267    let hold = "operator: 保留して後で判断する".to_owned();
5268    let mut q = ask::Question::new(
5269        state.id.clone(),
5270        CI_HOLD_NODE.into(),
5271        "land".into(),
5272        "base でも再現する CI の失敗を検出したため、試行を消費せず保留しました".into(),
5273        format!(
5274            "必須チェックや承認は省略しません。再開時にも同じ workflow run は自動再実行しません。\n構造化された判定根拠:\n{detail}"
5275        ),
5276        vec![resume.clone(), hold.clone()],
5277    );
5278    q.actions.insert(
5279        resume,
5280        ask::ChoiceAction::Resume {
5281            run: state.id.clone(),
5282        },
5283    );
5284    q.actions.insert(hold, ask::ChoiceAction::Hold);
5285    ask::Questions::open().put_executable(&mut q)?;
5286    state.land_unrelated = Some(CiHold {
5287        head: evidence.head.clone(),
5288        question: q.id.clone(),
5289        evidence,
5290    });
5291    state.event("land", format!("[ci-unrelated] base reproduces every located failure after rerun; owner question {}: {detail}", q.id));
5292    state.end_pass_parked(crate::run::ParkCause::Question);
5293    state.parked = true;
5294    state.save()?;
5295    if let Err(e) = ask::notify(&state.config.notify, &q).await {
5296        tracing::warn!("could not notify about CI hold: {e:#}");
5297    }
5298    // A durable question and evidence are sufficient even if commenting fails.
5299    let body = stop_comment(
5300        &state.id,
5301        "CI failures independently reproduced on the base; awaiting an owner decision without charging the task attempt",
5302    );
5303    let (_, body) = crate::github_text::prepare(state, "", &body);
5304    gh(
5305        &state.repo,
5306        &[
5307            "pr".into(),
5308            "comment".into(),
5309            pr.number.to_string(),
5310            "--body".into(),
5311            body,
5312        ],
5313    )
5314    .await
5315    .ok();
5316    Ok(())
5317}
5318
5319/// One observation, plus the two things [`PrState`] deliberately does not carry:
5320/// the title (needed for the squash subject) and where the failing checks'
5321/// logs live.
5322#[derive(Clone)]
5323struct Seen {
5324    pr: PrState,
5325    title: String,
5326    failing_urls: Vec<(String, String)>,
5327    /// `headRefOid`: the commit this observation, checks included, is about.
5328    head: String,
5329    /// The commit the checks belong to, read from the same GraphQL node as
5330    /// the checks themselves. Empty when unreadable.
5331    rollup_head: String,
5332    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
5333    /// BEHIND and DRAFT together, and a stop reason has to say which.
5334    merge_state: String,
5335    /// Every check of the rollup, for naming what an armed merge waits on.
5336    contexts: Vec<CheckInfo>,
5337    /// `baseRefName`, to look up which contexts the base requires.
5338    base: String,
5339}
5340
5341/// One check of the rollup as far as a stop reason needs it.
5342#[derive(Clone)]
5343struct CheckInfo {
5344    label: String,
5345    verdict: Verdict,
5346    required: Option<bool>,
5347    /// The workflow run the check belongs to; `None` for an external status.
5348    run: Option<String>,
5349}
5350
5351/// What to do with a red head before a fix round is spent on it.
5352#[derive(Debug, Clone, PartialEq, Eq)]
5353enum RerunStep {
5354    /// Rerun these workflow runs (each not yet rerun for this head).
5355    Rerun(Vec<String>),
5356    /// Reran already and the forge may still report the old failure: wait.
5357    Grace,
5358    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
5359    Fix,
5360}
5361
5362/// Failed checks a rerun may help: a failure of a check that blocks (`required`
5363/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
5364/// pending checks in another run do not delay this run's rerun.
5365#[cfg(test)]
5366fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
5367    rerunnable_with(contexts, false)
5368}
5369
5370fn rerunnable_with(
5371    contexts: &[CheckInfo],
5372    include_optional: bool,
5373) -> Vec<(String, Option<String>)> {
5374    contexts
5375        .iter()
5376        .filter(|c| c.verdict == Verdict::Fail && (include_optional || c.required != Some(false)))
5377        // `gh run rerun --failed` refuses a run with a job still going.
5378        .filter(|c| {
5379            c.run.as_ref().is_none_or(|r| {
5380                !contexts.iter().any(|o| {
5381                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
5382                        && o.run.as_ref() == Some(r)
5383                })
5384            })
5385        })
5386        .map(|c| (c.label.clone(), c.run.clone()))
5387        .collect()
5388}
5389
5390/// Pure: rerun once per run id, wait out the grace, then hand over.
5391fn rerun_step(
5392    failing: &[(String, Option<String>)],
5393    record: Option<&LandReruns>,
5394    head: &str,
5395    now: i64,
5396) -> RerunStep {
5397    // A failure with no workflow run (an external status) cannot be rerun, and
5398    // the fixer's push re-runs everything anyway.
5399    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
5400        return RerunStep::Fix;
5401    }
5402    // Reruns belong to the head they were made on.
5403    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
5404    let mut fresh: Vec<String> = Vec::new();
5405    let mut recent = false;
5406    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
5407        match record.and_then(|r| r.runs.get(run)) {
5408            None => {
5409                if !fresh.iter().any(|f| f == run) {
5410                    fresh.push(run.to_owned());
5411                }
5412            }
5413            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
5414            Some(_) => {}
5415        }
5416    }
5417    if !fresh.is_empty() {
5418        RerunStep::Rerun(fresh)
5419    } else if recent {
5420        RerunStep::Grace
5421    } else {
5422        RerunStep::Fix
5423    }
5424}
5425
5426/// Read the pull request: `gh pr view` for the top-level thread and merge
5427/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
5428/// inline review comments `gh pr view` does not report.
5429async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
5430    let view = gh(
5431        repo,
5432        &[
5433            "pr".to_owned(),
5434            "view".to_owned(),
5435            pr_url.to_owned(),
5436            "--json".to_owned(),
5437            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
5438                .to_owned(),
5439        ],
5440    )
5441    .await?;
5442    if !view.0 {
5443        bail!("gh pr view {pr_url}: {}", view.1);
5444    }
5445    let number = parse_pr(&view.1)?.number;
5446    let node = last_commit_node(repo, number).await;
5447    let mut seen = seen_from(&view.1, node.as_deref())?;
5448
5449    let inline = gh(
5450        repo,
5451        &[
5452            "api".to_owned(),
5453            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
5454        ],
5455    )
5456    .await?;
5457    if inline.0 {
5458        match parse_inline_comments(&inline.1) {
5459            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5460            // An unreadable inline thread must not end a landing: the rollup
5461            // and the top-level thread are still real signal.
5462            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5463        }
5464    } else {
5465        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5466    }
5467    Ok(seen)
5468}
5469
5470/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
5471/// response. No I/O.
5472///
5473/// The commit oid and the checks are read from the *same* node, so the rollup
5474/// is bound to the commit it belongs to by construction; two reads that agree
5475/// before and after another response prove nothing about what that response
5476/// held. The view's own rollup is never used. A node that is missing,
5477/// unreadable, carries GraphQL errors, or whose checks run past the page
5478/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
5479/// as "look again" and never as a reason to merge.
5480fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5481    let mut pr = parse_pr(view_json)?;
5482    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5483
5484    let mut rollup_head = String::new();
5485    let mut failing_urls = Vec::new();
5486    let mut contexts = Vec::new();
5487    let mut checks = Checks::Unknown;
5488    let mut failing = Vec::new();
5489    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5490        (checks, failing) = rollup_verdict(&rollup);
5491        failing_urls = rollup
5492            .iter()
5493            .filter(|c| c.verdict() == Verdict::Fail)
5494            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5495            .collect();
5496        contexts = rollup
5497            .iter()
5498            .map(|c| CheckInfo {
5499                label: c.label(),
5500                verdict: c.verdict(),
5501                required: c.is_required,
5502                run: c.url().and_then(run_of),
5503            })
5504            .collect();
5505        rollup_head = oid;
5506    }
5507    pr.checks = checks;
5508    pr.failing = failing;
5509
5510    Ok(Seen {
5511        pr,
5512        title: raw.title,
5513        failing_urls,
5514        head: raw.head_ref_oid,
5515        rollup_head,
5516        merge_state: raw.merge_state_status,
5517        contexts,
5518        base: raw.base_ref_name,
5519    })
5520}
5521
5522/// The last commit's oid and its checks from one GraphQL response, `None`
5523/// when anything about it cannot be trusted.
5524fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5525    let v: serde_json::Value = serde_json::from_str(json).ok()?;
5526    if v.get("errors").is_some_and(|e| !e.is_null()) {
5527        return None;
5528    }
5529    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5530    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5531    let contexts = commit.pointer("/statusCheckRollup/contexts");
5532    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5533        // No rollup at all: the commit has no checks.
5534        return Some((oid.to_owned(), Vec::new()));
5535    };
5536    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5537        return None;
5538    }
5539    let nodes = contexts.get("nodes")?.as_array()?;
5540    let rollup = nodes
5541        .iter()
5542        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5543        .collect::<Result<Vec<_>, _>>()
5544        .ok()?;
5545    Some((oid.to_owned(), rollup))
5546}
5547
5548/// The pull request's last commit and its checks, in one response. `None`
5549/// when the forge could not be asked.
5550async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5551    let out = gh(
5552        repo,
5553        &[
5554            "api".to_owned(),
5555            "graphql".to_owned(),
5556            "-F".to_owned(),
5557            "owner={owner}".to_owned(),
5558            "-F".to_owned(),
5559            "repo={repo}".to_owned(),
5560            "-F".to_owned(),
5561            format!("number={number}"),
5562            "-f".to_owned(),
5563            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5564             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5565             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5566             ... on CheckRun{name status conclusion detailsUrl \
5567             isRequired(pullRequestNumber:$number)} \
5568             ... on StatusContext{context state targetUrl \
5569             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5570                .to_owned(),
5571        ],
5572    )
5573    .await
5574    .ok()?;
5575    out.0.then_some(out.1)
5576}
5577
5578/// What a fix round did.
5579#[doc(hidden)]
5580#[derive(Debug, PartialEq)]
5581pub enum Fixed {
5582    /// The fixer committed something, and this is the head that was pushed.
5583    Committed {
5584        /// The commit now at the tip of the pushed branch.
5585        head: String,
5586    },
5587    /// The fixer ran and chose to change nothing.
5588    Declined,
5589    /// The fixer could not run, or said nothing usable.
5590    Failed(String),
5591}
5592
5593/// Hand the failures and the comments to the fixer, then commit and push.
5594///
5595/// The fixer works in the winner's own worktree so its commits land on the
5596/// branch the pull request is built from, and it runs with `allow_write` for
5597/// the same reason.
5598#[doc(hidden)]
5599pub async fn fix_round(
5600    state: &mut RunState,
5601    pr: &PrState,
5602    round: usize,
5603    budget: usize,
5604    reason: &str,
5605    logs: &str,
5606) -> Result<Fixed> {
5607    let winner = state
5608        .winner()
5609        .cloned()
5610        .context("landing needs a winning candidate; none is recorded on this run")?;
5611    let roles = state
5612        .config
5613        .resolve_roles()
5614        .context("resolve the roster for the fix round")?;
5615    // Same chain as the review loop (see `crate::fixer`): the configured
5616    // fixers in order, otherwise the winner's own author continuing its own
5617    // conversation - the competition is over, so its context is pure benefit.
5618    let attempts = crate::fixer::attempts(state, &roles, &winner);
5619    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5620
5621    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5622    let mut artifacts = agent::artifacts_dir(&state.dir());
5623    if let Some(request) = state
5624        .merge_repairs
5625        .last()
5626        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5627    {
5628        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5629    }
5630    let prompt = if state.config.cache_dir().is_some() {
5631        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5632    } else {
5633        prompt
5634    };
5635    // Read before the fixer runs: it normally commits for itself, so HEAD has
5636    // already moved by the time it returns and a later read would see no
5637    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
5638    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5639    // Each id at most once, forward only: the next one is asked only when the
5640    // call advances, and only the last attempt's result is judged below, so an
5641    // exhausted chain ends as a single failed fixer does.
5642    let mut last = None;
5643    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5644        if owner_requested_stop(state)? {
5645            return Ok(Fixed::Declined);
5646        }
5647        let mut seat = seat_of(state, &seat_key, &spec.id);
5648        let stem = if i == 0 {
5649            format!("land-{round}")
5650        } else {
5651            format!("land-{round}-{}", spec.id)
5652        };
5653        let out = agent::invoke(
5654            &spec,
5655            &mut seat,
5656            &Invocation {
5657                scope: crate::agent::InvocationScope::Repository,
5658                coordination: state.config.coordination.as_ref(),
5659                fetched_base: state.fetched_base.as_ref(),
5660                cwd: &winner.worktree,
5661                prompt: &prompt,
5662                timeout: Duration::from_secs(state.config.graph.timeout_fix),
5663                allow_write: true,
5664                unsandboxed: false,
5665                sessions: state.config.graph.sessions,
5666                artifacts: &artifacts,
5667                stem: &stem,
5668                run: &state.id,
5669                node: "land",
5670                cache_dir: state.config.cache_dir().as_deref(),
5671                attachments: &[],
5672                writable: &[],
5673            },
5674        )
5675        .await;
5676        state.seats.insert(seat.key.clone(), seat);
5677        if let Some(next) = ids.get(i + 1)
5678            && agent::chain_advances(&out)
5679        {
5680            let (class, why) = crate::fixer::failure_of(&out);
5681            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5682            continue;
5683        }
5684        last = Some(out);
5685        break;
5686    }
5687    let out = last.expect("the fixer chain always has an entry");
5688
5689    match out {
5690        Ok(o) if o.quota_exhausted() => {
5691            return Ok(Fixed::Failed(
5692                "rate limited (quota); the fixer could not run".to_owned(),
5693            ));
5694        }
5695        Ok(o) if !o.usable() => {
5696            return Ok(Fixed::Failed(format!(
5697                "the fixer produced nothing usable ({})",
5698                crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5699            )));
5700        }
5701        Ok(_) => {}
5702        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5703        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5704    }
5705
5706    if owner_requested_stop(state)? {
5707        return Ok(Fixed::Declined);
5708    }
5709
5710    // An agent that edited files but never committed would otherwise push
5711    // nothing and look like a refusal.
5712    let wait = crate::repo_lock::WaitPolicy::new(
5713        Duration::from_secs(state.config.graph.timeout_resource_wait),
5714        || false,
5715    );
5716    let coordination = crate::repo_lock::SeatLease::operation_with(
5717        &winner.worktree,
5718        state.config.coordination.as_ref(),
5719        &wait,
5720    )
5721    .await?;
5722    match git::rescue_commit(
5723        &winner.worktree,
5724        &format!("magi: land round {round} fixes (uncommitted work)"),
5725    )
5726    .await
5727    {
5728        Ok(r) => state.note_withheld("land", &r.withheld),
5729        Err(e)
5730            if state
5731                .merge_repairs
5732                .last()
5733                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5734        {
5735            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5736        }
5737        Err(_) => {}
5738    }
5739    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5740    drop(coordination);
5741    if after == before {
5742        return Ok(Fixed::Declined);
5743    }
5744
5745    if owner_requested_stop(state)? {
5746        return Ok(Fixed::Declined);
5747    }
5748    crate::pr_head::ensure_owner(state)?;
5749    let remote = state.config.merge.remote.clone();
5750    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5751    let push = if state.pr_head.is_some() {
5752        let expected = state
5753            .pr_pushed_head
5754            .as_deref()
5755            .context("missing public head lease")?;
5756        git::push_as(
5757            &winner.worktree,
5758            &remote,
5759            &winner.branch,
5760            &public_head,
5761            expected,
5762        )
5763        .await?
5764    } else {
5765        git::push(&winner.worktree, &remote, &winner.branch).await?
5766    };
5767    if !push.ok() {
5768        return Ok(Fixed::Failed(format!(
5769            "pushing {} to {remote} failed: {}",
5770            public_head, push.stderr
5771        )));
5772    }
5773    state.event(
5774        "land",
5775        format!("round {round}: pushed a fix to {public_head}"),
5776    );
5777    state.pr_pushed_head = Some(after.clone());
5778    state.save()?;
5779    Ok(Fixed::Committed { head: after })
5780}
5781
5782/// Fetch or create a seat, keeping its conversation across nodes.
5783pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5784    crate::fixer::seat_for(state, key, agent)
5785}
5786
5787/// Recheck the live PR and local worktree before spending the requested fix.
5788/// A moved/closed PR is held, never repaired using obsolete evidence.
5789pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5790    let seen = observe(&state.repo, url).await?;
5791    let winner = state.winner().context("no winning branch to repair")?;
5792    if seen.pr.state != PrLifecycle::Open || seen.head != head {
5793        anyhow::bail!(
5794            "PR is closed or its head changed; review the current head before requesting repair again"
5795        );
5796    }
5797    if git::rev_parse(&winner.worktree, "HEAD").await? != head
5798        || !git::is_clean(&winner.worktree).await?
5799    {
5800        anyhow::bail!(
5801            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5802        );
5803    }
5804    // Always disable any forge-side reservation, including one not recorded
5805    // by this run. A repair request is never merge authorization.
5806    let view = gh(
5807        &state.repo,
5808        &[
5809            "pr".into(),
5810            "view".into(),
5811            url.into(),
5812            "--json".into(),
5813            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5814        ],
5815    )
5816    .await?;
5817    if !view.0 {
5818        bail!("cannot check auto-merge: {}", view.1);
5819    }
5820    let live: serde_json::Value = serde_json::from_str(&view.1)?;
5821    let public = state.pr_branch().unwrap_or(&winner.branch);
5822    if live["state"] != "OPEN"
5823        || live["headRefOid"] != head
5824        || live["headRefName"] != public
5825        || live["baseRefName"] != state.base_branch
5826        || live["isCrossRepository"] != false
5827    {
5828        bail!("PR changed during repair preflight; review its current head explicitly");
5829    }
5830    let auto_merge = live
5831        .get("autoMergeRequest")
5832        .context("cannot read the PR auto-merge reservation; repair held")?;
5833    if !auto_merge.is_null() {
5834        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5835        if !out.0 {
5836            bail!("cannot disable auto-merge; repair held: {}", out.1);
5837        }
5838    }
5839    Ok(seen.pr)
5840}
5841
5842/// What the fixer is told.
5843fn fix_prompt(
5844    state: &RunState,
5845    pr: &PrState,
5846    round: usize,
5847    budget: usize,
5848    reason: &str,
5849    logs: &str,
5850) -> String {
5851    let mut s = format!(
5852        "Your patch is open as a pull request and it is not landing. Land round \
5853         {round} of {budget}.\n\n\
5854         Pull request: {}\n\n\
5855         What is holding it: {reason}\n\n\
5856         # The task\n\n{}\n",
5857        pr.url, state.instruction
5858    );
5859
5860    if pr.failing.is_empty() {
5861        s.push_str("\n# Failing checks\n\n(none)\n");
5862    } else {
5863        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5864        if logs.trim().is_empty() {
5865            s.push_str(
5866                "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5867            );
5868        } else {
5869            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5870        }
5871    }
5872
5873    if pr.review_comments.is_empty() {
5874        s.push_str("\n# Review comments\n\n(none)\n");
5875    } else {
5876        s.push_str("\n# Review comments\n");
5877        for c in &pr.review_comments {
5878            let where_ = match (&c.path, c.line) {
5879                (Some(p), Some(l)) => format!(" ({p}:{l})"),
5880                (Some(p), None) => format!(" ({p})"),
5881                _ => String::new(),
5882            };
5883            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5884        }
5885    }
5886
5887    s.push_str(
5888        "\n# Rules\n\n\
5889         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5890            failing test; do not silence a lint with an allow attribute; do not \
5891            stretch a timeout to hide a race. If the check is right, the code is \
5892            wrong.\n\
5893         2. Change nothing the checks and the comments did not raise. A \
5894            drive-by refactor turns a one-line fix into a pull request that \
5895            needs reviewing again.\n\
5896         3. If a comment is wrong, say so with a checkable argument and change \
5897            nothing for it. A declined comment with a reason is a correct \
5898            outcome; a change made to appease a reviewer is not.\n\
5899         4. Commit in this worktree. magi pushes to the pull request's branch \
5900            for you; do not push, merge, or close anything yourself.\n\
5901         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5902         # Output\n\n\
5903         Say what you changed and why, and what you declined and why.",
5904    );
5905
5906    let language = &state.config.graph.language;
5907    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5908        let _ = write!(s, "\n\nWrite all prose in {language}.");
5909    }
5910    // After the language line, so the exception is the last word on it.
5911    s.push_str(&crate::prompt::github_rule(
5912        language,
5913        &state.config.graph.github_language,
5914    ));
5915    if let Some(overlay) = state.config.prompts.overlay("fix") {
5916        let _ = write!(s, "\n\n{overlay}");
5917    }
5918    s.push('\n');
5919    s.push_str(crate::prompt::child_run_ownership());
5920    s
5921}
5922
5923/// Failing log tails, the way the operator collects them by hand:
5924/// `gh run view --log-failed`.
5925async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5926    let mut out = String::new();
5927    for (name, url) in failing.iter().take(MAX_LOGS) {
5928        let args = match (job_of(url), run_of(url)) {
5929            (Some(job), _) => vec![
5930                "run".to_owned(),
5931                "view".to_owned(),
5932                "--log-failed".to_owned(),
5933                "--job".to_owned(),
5934                job,
5935            ],
5936            (None, Some(run)) => vec![
5937                "run".to_owned(),
5938                "view".to_owned(),
5939                run,
5940                "--log-failed".to_owned(),
5941            ],
5942            // Not a GitHub Actions check - an external status has no log here.
5943            (None, None) => continue,
5944        };
5945        let (ok, body) = match gh(repo, &args).await {
5946            Ok(v) => v,
5947            Err(e) => (false, format!("{e:#}")),
5948        };
5949        if !ok && body.trim().is_empty() {
5950            continue;
5951        }
5952        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5953    }
5954    out
5955}
5956
5957/// Job id out of a check's `detailsUrl`
5958/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
5959fn job_of(details_url: &str) -> Option<String> {
5960    let after = details_url.split("/job/").nth(1)?;
5961    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5962    (!id.is_empty()).then_some(id)
5963}
5964
5965/// Workflow run id out of a check's `detailsUrl`.
5966pub(crate) fn run_of(details_url: &str) -> Option<String> {
5967    let after = details_url.split("/actions/runs/").nth(1)?;
5968    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5969    (!id.is_empty()).then_some(id)
5970}
5971
5972/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
5973/// it lands on GitHub, not in front of the operator. Pure so a test can hold
5974/// it to that.
5975fn stop_comment(run_id: &str, why: &str) -> String {
5976    format!(
5977        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5978         The branch is untouched and the run is `{run_id}`. Nothing was merged."
5979    )
5980}
5981
5982/// Leave the pull request open, say why on it, and mark the run blocked.
5983///
5984/// The comment is what makes an unattended stop actionable: the operator wakes
5985/// up to a pull request that explains itself rather than to a silent queue.
5986async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5987    let body = stop_comment(&state.id, why);
5988    let (_, mut body) = crate::github_text::prepare(state, "", &body);
5989    if !body.contains(MARKER) {
5990        body = format!("{MARKER}\n{body}");
5991    }
5992    let posted = gh(
5993        repo,
5994        &[
5995            "pr".to_owned(),
5996            "comment".to_owned(),
5997            pr.number.to_string(),
5998            "--body".to_owned(),
5999            body,
6000        ],
6001    )
6002    .await;
6003    match posted {
6004        Ok((true, _)) => {}
6005        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
6006        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
6007    }
6008    state.status = RunStatus::Blocked;
6009    state.merge = Some(MergeOutcome {
6010        mode: MergeMode::Pr,
6011        ok: false,
6012        detail: why.to_owned(),
6013        empty: false,
6014    });
6015    state.event("land", format!("stopped: {why}"));
6016    state.save()?;
6017    Ok(())
6018}
6019
6020/// Run `gh` in `repo`, returning success and the combined output.
6021///
6022/// Combined because `gh` reports a refused merge on stderr and the pull request
6023/// json on stdout, and both are evidence.
6024///
6025/// `GH_REPO` is stripped from the child's environment: every call site here
6026/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
6027/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
6028/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
6029/// `GH_REPO` the operator happens to have exported for an unrelated script
6030/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
6031/// below) to a different repository than the one actually on disk - which
6032/// for the same-repo guard in [`correct_manual_merge`] would mean the check
6033/// could be made to agree with whatever repository a forged `--merged` URL
6034/// claims, defeating it entirely.
6035pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
6036    let out = tokio::process::Command::new("gh")
6037        .args(args)
6038        .current_dir(cwd)
6039        .env_remove("GH_REPO")
6040        .quiet()
6041        .stdin(std::process::Stdio::null())
6042        .output()
6043        .await
6044        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
6045    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
6046    let err = String::from_utf8_lossy(&out.stderr);
6047    if body.trim().is_empty() {
6048        body = err.into_owned();
6049    } else if !err.trim().is_empty() {
6050        body.push_str(&err);
6051    }
6052    Ok((out.status.success(), body.trim().to_owned()))
6053}
6054
6055/// Verdict of one entry in the status rollup.
6056#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6057pub(crate) enum Verdict {
6058    Pass,
6059    Fail,
6060    Pending,
6061    Unknown,
6062}
6063
6064impl Verdict {
6065    /// Short operator-facing check result.
6066    pub(crate) fn label(self) -> &'static str {
6067        match self {
6068            Self::Pass => "passed",
6069            Self::Fail => "failed",
6070            Self::Pending => "pending",
6071            Self::Unknown => "unknown",
6072        }
6073    }
6074}
6075
6076#[derive(Debug, Deserialize)]
6077#[serde(rename_all = "camelCase")]
6078struct GhPr {
6079    #[serde(default)]
6080    url: String,
6081    #[serde(default)]
6082    number: u64,
6083    #[serde(default)]
6084    state: String,
6085    #[serde(default)]
6086    title: String,
6087    #[serde(default)]
6088    status_check_rollup: Vec<GhCheck>,
6089    /// GitHub's own verdict on whether the pull request can be merged.
6090    ///
6091    /// Worth asking for because it is the only place the *required* check set
6092    /// is applied: the rollup lists every check equally, so a repository that
6093    /// deliberately does not require `coverage` still looks red here. See
6094    /// [`Blocking`].
6095    #[serde(default)]
6096    merge_state_status: String,
6097    /// The commit the pull request currently points at. Compared with the
6098    /// commit a fix round pushed, it is how the loop knows the forge has moved
6099    /// on and the rollup belongs to the new head.
6100    #[serde(default)]
6101    head_ref_oid: String,
6102    #[serde(default)]
6103    base_ref_name: String,
6104    #[serde(default)]
6105    reviews: Vec<GhReview>,
6106    #[serde(default)]
6107    comments: Vec<GhComment>,
6108}
6109
6110/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
6111/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
6112/// status API, which is how CodeRabbit reports - has `context`/`state` and no
6113/// conclusion at all.
6114#[derive(Debug, Deserialize)]
6115#[serde(rename_all = "camelCase")]
6116struct GhCheck {
6117    #[serde(default)]
6118    name: Option<String>,
6119    #[serde(default)]
6120    context: Option<String>,
6121    #[serde(default)]
6122    status: Option<String>,
6123    #[serde(default)]
6124    conclusion: Option<String>,
6125    #[serde(default)]
6126    state: Option<String>,
6127    #[serde(default)]
6128    details_url: Option<String>,
6129    #[serde(default)]
6130    target_url: Option<String>,
6131    /// Whether the base branch requires this check. Only the GraphQL node
6132    /// carries it; `None` is "not read", never "not required".
6133    #[serde(default)]
6134    is_required: Option<bool>,
6135}
6136
6137impl GhCheck {
6138    /// Name to show a human and hand to the fixer.
6139    fn label(&self) -> String {
6140        self.name
6141            .clone()
6142            .or_else(|| self.context.clone())
6143            .unwrap_or_else(|| "(unnamed check)".to_owned())
6144    }
6145
6146    /// Where this check's logs live, when it has any.
6147    fn url(&self) -> Option<&str> {
6148        self.details_url
6149            .as_deref()
6150            .or(self.target_url.as_deref())
6151            .filter(|u| !u.is_empty())
6152    }
6153
6154    /// Did it pass?
6155    ///
6156    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
6157    /// skips release and bot pull requests by design, and a skip that blocked
6158    /// landing would block exactly the pull requests that need no review.
6159    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
6160    /// merging over one is merging over a check that never ran.
6161    fn verdict(&self) -> Verdict {
6162        if let Some(status) = self.status.as_deref() {
6163            if !status.eq_ignore_ascii_case("COMPLETED") {
6164                return Verdict::Pending;
6165            }
6166        }
6167        let outcome = self
6168            .conclusion
6169            .as_deref()
6170            .or(self.state.as_deref())
6171            .unwrap_or("");
6172        match outcome.to_ascii_uppercase().as_str() {
6173            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
6174            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
6175            | "ACTION_REQUIRED" => Verdict::Fail,
6176            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
6177                Verdict::Pending
6178            }
6179            _ => Verdict::Unknown,
6180        }
6181    }
6182}
6183
6184#[derive(Debug, Deserialize)]
6185struct GhAuthor {
6186    #[serde(default)]
6187    login: String,
6188}
6189
6190#[derive(Debug, Deserialize)]
6191struct GhReview {
6192    #[serde(default)]
6193    author: GhAuthor,
6194    #[serde(default)]
6195    body: String,
6196}
6197
6198#[derive(Debug, Deserialize)]
6199struct GhComment {
6200    #[serde(default)]
6201    author: GhAuthor,
6202    #[serde(default)]
6203    body: String,
6204}
6205
6206#[derive(Debug, Deserialize)]
6207struct GhUser {
6208    #[serde(default)]
6209    login: String,
6210}
6211
6212#[derive(Debug, Deserialize)]
6213struct GhInline {
6214    #[serde(default)]
6215    user: GhUser,
6216    #[serde(default)]
6217    path: Option<String>,
6218    #[serde(default)]
6219    line: Option<u64>,
6220    #[serde(default)]
6221    body: String,
6222}
6223
6224impl Default for GhAuthor {
6225    fn default() -> Self {
6226        Self {
6227            login: "(unknown)".to_owned(),
6228        }
6229    }
6230}
6231
6232impl Default for GhUser {
6233    fn default() -> Self {
6234        Self {
6235            login: "(unknown)".to_owned(),
6236        }
6237    }
6238}
6239
6240#[cfg(test)]
6241pub(crate) mod tests {
6242    use super::*;
6243    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
6244
6245    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
6246        format!(
6247            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
6248        )
6249    }
6250
6251    #[test]
6252    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
6253        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
6254        assert_eq!(
6255            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
6256            Ok(())
6257        );
6258        for (json, why) in [
6259            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
6260            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
6261            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
6262            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
6263            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
6264            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
6265        ] {
6266            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
6267                .unwrap_err()
6268                .why;
6269            assert!(err.contains(why), "{json}: {err}");
6270        }
6271        // A head that moved on past what was verified is left alone.
6272        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
6273        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
6274        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
6275        assert!(
6276            !closable(
6277                &head_json("x", "main", "OPEN", false),
6278                "magi/27b2/A",
6279                "main",
6280                &[]
6281            )
6282            .unwrap_err()
6283            .retry
6284        );
6285        assert!(is_forge_url("https://github.com/o/r.git"));
6286        assert!(is_forge_url("git@github.com:o/r.git"));
6287        assert!(!is_forge_url("/tmp/origin.git"));
6288        assert!(!is_forge_url("C:\\work\\origin.git"));
6289        assert!(!is_forge_url("file:///tmp/origin.git"));
6290        assert!(forge_unavailable(
6291            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
6292        ));
6293        assert!(!forge_unavailable(
6294            "gh pr list failed: error connecting to api.github.com"
6295        ));
6296        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
6297        // A record that does not say whether it is a fork is not trusted.
6298        assert!(
6299            closable(
6300                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
6301                "b",
6302                "main",
6303                &["aaa".to_owned()]
6304            )
6305            .is_err()
6306        );
6307    }
6308
6309    #[test]
6310    fn the_close_comment_names_the_commit_on_the_base() {
6311        let e = crate::already::Evidence {
6312            proof: crate::already::Proof::PatchId,
6313            tip: "1234567890".to_owned(),
6314            commits: vec!["0e368de0000".to_owned()],
6315        };
6316        let c = superseded_comment("main", &e);
6317        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
6318    }
6319
6320    /// 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.
6321    const GREEN_OPEN: &str = r####"{
6322  "url": "https://github.com/yukimemi/magi/pull/10",
6323  "number": 10,
6324  "state": "OPEN",
6325  "mergeStateStatus": "CLEAN",
6326  "statusCheckRollup": [
6327    {
6328      "__typename": "CheckRun",
6329      "conclusion": "SKIPPED",
6330      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
6331      "name": "review",
6332      "status": "COMPLETED",
6333      "workflowName": "claude-review"
6334    },
6335    {
6336      "__typename": "CheckRun",
6337      "conclusion": "SUCCESS",
6338      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
6339      "name": "check (ubuntu-latest)",
6340      "status": "COMPLETED",
6341      "workflowName": "CI"
6342    },
6343    {
6344      "__typename": "CheckRun",
6345      "conclusion": "SUCCESS",
6346      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
6347      "name": "rustfmt",
6348      "status": "COMPLETED",
6349      "workflowName": "CI"
6350    },
6351    {
6352      "__typename": "StatusContext",
6353      "context": "CodeRabbit",
6354      "state": "SUCCESS",
6355      "targetUrl": ""
6356    }
6357  ],
6358  "reviews": [],
6359  "comments": [
6360    {
6361      "author": {
6362        "login": "coderabbitai"
6363      },
6364      "authorAssociation": "NONE",
6365      "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: 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"
6366    }
6367  ]
6368}"####;
6369
6370    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
6371    const RED_OPEN: &str = r####"{
6372  "url": "https://github.com/yukimemi/magi/pull/9",
6373  "number": 9,
6374  "state": "OPEN",
6375  "mergeStateStatus": "UNSTABLE",
6376  "statusCheckRollup": [
6377    {
6378      "__typename": "CheckRun",
6379      "conclusion": "SUCCESS",
6380      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6381      "name": "check (ubuntu-latest)",
6382      "status": "COMPLETED",
6383      "workflowName": "CI"
6384    },
6385    {
6386      "__typename": "CheckRun",
6387      "conclusion": "SUCCESS",
6388      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6389      "name": "rustfmt",
6390      "status": "COMPLETED",
6391      "workflowName": "CI"
6392    },
6393    {
6394      "__typename": "CheckRun",
6395      "conclusion": "FAILURE",
6396      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6397      "name": "editorconfig",
6398      "status": "COMPLETED",
6399      "workflowName": "CI"
6400    },
6401    {
6402      "__typename": "StatusContext",
6403      "context": "CodeRabbit",
6404      "state": "SUCCESS",
6405      "targetUrl": ""
6406    }
6407  ],
6408  "reviews": [],
6409  "comments": [
6410    {
6411      "author": {
6412        "login": "coderabbitai"
6413      },
6414      "authorAssociation": "NONE",
6415      "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"
6416    }
6417  ]
6418}"####;
6419
6420    /// 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.
6421    const PENDING_OPEN: &str = r####"{
6422  "url": "https://github.com/yukimemi/magi/pull/9",
6423  "number": 9,
6424  "state": "OPEN",
6425  "statusCheckRollup": [
6426    {
6427      "__typename": "CheckRun",
6428      "conclusion": "SUCCESS",
6429      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6430      "name": "check (ubuntu-latest)",
6431      "status": "COMPLETED",
6432      "workflowName": "CI"
6433    },
6434    {
6435      "__typename": "CheckRun",
6436      "conclusion": "SUCCESS",
6437      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6438      "name": "rustfmt",
6439      "status": "COMPLETED",
6440      "workflowName": "CI"
6441    },
6442    {
6443      "__typename": "CheckRun",
6444      "conclusion": null,
6445      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6446      "name": "editorconfig",
6447      "status": "IN_PROGRESS",
6448      "workflowName": "CI"
6449    },
6450    {
6451      "__typename": "StatusContext",
6452      "context": "CodeRabbit",
6453      "state": "SUCCESS",
6454      "targetUrl": ""
6455    }
6456  ],
6457  "reviews": [],
6458  "comments": []
6459}"####;
6460
6461    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
6462    const MERGED: &str = r####"{
6463  "url": "https://github.com/yukimemi/magi/pull/16",
6464  "number": 16,
6465  "state": "MERGED",
6466  "statusCheckRollup": [
6467    {
6468      "__typename": "CheckRun",
6469      "conclusion": "SUCCESS",
6470      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6471      "name": "check (ubuntu-latest)",
6472      "status": "COMPLETED",
6473      "workflowName": "CI"
6474    },
6475    {
6476      "__typename": "CheckRun",
6477      "conclusion": "SUCCESS",
6478      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6479      "name": "review",
6480      "status": "COMPLETED",
6481      "workflowName": "claude-review"
6482    }
6483  ],
6484  "reviews": [],
6485  "comments": []
6486}"####;
6487
6488    /// 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.
6489    const REVIEWED_OPEN: &str = r####"{
6490  "url": "https://github.com/yukimemi/magi/pull/12",
6491  "number": 12,
6492  "state": "OPEN",
6493  "statusCheckRollup": [
6494    {
6495      "__typename": "CheckRun",
6496      "conclusion": "SUCCESS",
6497      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6498      "name": "check (ubuntu-latest)",
6499      "status": "COMPLETED",
6500      "workflowName": "CI"
6501    },
6502    {
6503      "__typename": "CheckRun",
6504      "conclusion": "SUCCESS",
6505      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6506      "name": "review",
6507      "status": "COMPLETED",
6508      "workflowName": "claude-review"
6509    }
6510  ],
6511  "reviews": [
6512    {
6513      "author": {
6514        "login": "claude"
6515      },
6516      "state": "COMMENTED",
6517      "body": ""
6518    }
6519  ],
6520  "comments": [
6521    {
6522      "author": {
6523        "login": "coderabbitai"
6524      },
6525      "authorAssociation": "NONE",
6526      "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"
6527    },
6528    {
6529      "author": {
6530        "login": "claude"
6531      },
6532      "authorAssociation": "NONE",
6533      "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"
6534    }
6535  ]
6536}"####;
6537
6538    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
6539    const INLINE: &str = r####"[
6540  {
6541    "user": {
6542      "login": "claude[bot]"
6543    },
6544    "path": "src/graph.rs",
6545    "line": 231,
6546    "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"
6547  }
6548]"####;
6549
6550    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
6551    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6552<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6553
6554> [!IMPORTANT]
6555> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6556> 
6557> This repository does not receive automatic reviews because it has fewer than 10 stars.
6558> 
6559> <details>
6560> <summary>⚙️ Run configuration</summary>
6561> 
6562> **Configuration used**: defaults
6563> 
6564> **Review profile**: CHILL
6565> 
6566> **Plan**: Team
6567> 
6568> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6569> 
6570> </details>
6571
6572<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6573
6574<!-- tips_start -->
6575
6576---
6577
6578Thanks 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.
6579
6580<details>
6581<summary>❤️ Share</summary>
6582
6583- [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"####;
6584
6585    /// 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.
6586    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6587
6588---
6589### Reviewing PR #16
6590
6591- [x] Read AGENTS.md conventions
6592- [x] Review `src/daemon.rs` changes
6593- [x] Review `src/main.rs` changes (new `doctor` reporting)
6594- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6595- [x] Check test coverage for new behavior
6596- [x] Run verification commands (blocked — see note)
6597- [x] Post findings"####;
6598
6599    /// The same job's real comment on pull request #12 once it had something to say.
6600    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6601
6602---
6603### Review: `magi review <branch>` — cheap-half-only graph
6604
6605Read 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.
6606
6607**Correctness**
6608
6609- 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"####;
6610
6611    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6612        PrState {
6613            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6614            number: 16,
6615            state: PrLifecycle::Open,
6616            checks,
6617            // These tests are about red-means-fix, so a red here is one the
6618            // forge gates on. Without saying so they would assert the new
6619            // "merge past a check nobody requires" path by accident.
6620            blocking: if matches!(checks, Checks::Red) {
6621                Blocking::Yes
6622            } else {
6623                Blocking::No
6624            },
6625            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6626            review_comments: (0..comments)
6627                .map(|i| ReviewComment {
6628                    author: "coderabbitai".to_owned(),
6629                    path: Some("src/graph.rs".to_owned()),
6630                    line: Some(231),
6631                    body: format!("finding {i}"),
6632                })
6633                .collect(),
6634        }
6635    }
6636
6637    #[test]
6638    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6639        use CiExpectation::{Absent, Expected};
6640        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6641            let p = pr(checks, &["x"], 0);
6642            for waited in [Duration::ZERO, CHECKS_GRACE] {
6643                assert_eq!(
6644                    decide_with(&p, 0, 4, waited, Expected),
6645                    decide(&p, 0, 4, waited)
6646                );
6647            }
6648        }
6649        // Nothing will ever report: no wait, no give-up, no fix round.
6650        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6651            let p = pr(checks, &["x"], 0);
6652            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6653            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6654        }
6655        // Third-party statuses never change the review decision, even at budget.
6656        for comments in [0, 1] {
6657            for round in [0, 4] {
6658                let absent = decide_with(
6659                    &pr(Checks::Unknown, &[], comments),
6660                    round,
6661                    4,
6662                    CHECKS_GRACE,
6663                    Absent,
6664                );
6665                for checks in [Checks::Green, Checks::Red, Checks::Pending] {
6666                    assert_eq!(
6667                        decide_with(
6668                            &pr(checks, &["third-party"], comments),
6669                            round,
6670                            4,
6671                            Duration::ZERO,
6672                            Absent
6673                        ),
6674                        absent
6675                    );
6676                }
6677                if comments != 0 {
6678                    assert!(matches!(absent, Step::Fix { .. } | Step::GiveUp { .. }));
6679                    assert_eq!(matches!(absent, Step::GiveUp { .. }), round == 4);
6680                }
6681            }
6682        }
6683        let empty = pr(Checks::Unknown, &[], 0);
6684        for waited in [Duration::ZERO, CHECKS_GRACE + POLL] {
6685            assert_eq!(decide_with(&empty, 0, 4, waited, Absent), Step::Merge);
6686        }
6687        assert_eq!(
6688            decide_with(&empty, 0, 4, Duration::ZERO, Expected),
6689            Step::Wait
6690        );
6691        assert_eq!(
6692            decide_with(&empty, 0, 4, CHECKS_GRACE, Expected),
6693            Step::GiveUp { reason: "no check status is readable on the pull request after 3 minute(s); refusing to merge on a guess".to_owned() }
6694        );
6695        // A conflict is not a check: it still wants the rebase.
6696        let mut p = pr(Checks::Unknown, &[], 0);
6697        p.blocking = Blocking::Conflict;
6698        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6699        // And a pull request that left our hands is still done.
6700        p.state = PrLifecycle::Merged;
6701        assert_eq!(
6702            decide_with(&p, 0, 4, Duration::ZERO, Absent),
6703            Step::Done { merged: true }
6704        );
6705    }
6706
6707    #[test]
6708    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6709        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6710        assert_eq!(state.number, 10);
6711        assert_eq!(state.state, PrLifecycle::Open);
6712        assert_eq!(state.checks, Checks::Green);
6713        assert!(state.failing.is_empty());
6714        assert!(
6715            state.review_comments.is_empty(),
6716            "the only comment is CodeRabbit's trigger notice: {:?}",
6717            state.review_comments
6718        );
6719        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6720    }
6721
6722    #[test]
6723    fn a_failing_check_parses_as_red_and_is_named() {
6724        let state = parse_pr(RED_OPEN).expect("red fixture parses");
6725        assert_eq!(state.checks, Checks::Red);
6726        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6727        // The captured payload says `UNSTABLE` - mergeable, with a check
6728        // nobody requires red - which is exactly the shape that had to be
6729        // merged by hand. Asserted separately, in
6730        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
6731        // test is about is that a red check is *named*, so the reason a fixer
6732        // is handed says which one; so it asks the blocking question here.
6733        let mut blocking = state.clone();
6734        blocking.blocking = Blocking::Yes;
6735        match decide(&blocking, 0, 4, Duration::ZERO) {
6736            Step::Fix { reason } => {
6737                assert!(reason.contains("editorconfig"), "reason: {reason}");
6738                assert!(reason.contains("failing"), "reason: {reason}");
6739            }
6740            other => panic!("expected a fix round, got {other:?}"),
6741        }
6742    }
6743
6744    #[test]
6745    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6746        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6747        assert_eq!(state.checks, Checks::Pending);
6748        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6749    }
6750
6751    #[test]
6752    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6753        let state = parse_pr(MERGED).expect("merged fixture parses");
6754        assert_eq!(state.state, PrLifecycle::Merged);
6755        assert_eq!(
6756            decide(&state, 0, 4, Duration::ZERO),
6757            Step::Done { merged: true }
6758        );
6759    }
6760
6761    #[test]
6762    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6763        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6764        assert_eq!(state.checks, Checks::Green);
6765        let authors: Vec<&str> = state
6766            .review_comments
6767            .iter()
6768            .map(|c| c.author.as_str())
6769            .collect();
6770        assert_eq!(
6771            authors,
6772            vec!["claude"],
6773            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6774        );
6775        match decide(&state, 0, 4, Duration::ZERO) {
6776            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6777            other => panic!("expected a fix round, got {other:?}"),
6778        }
6779    }
6780
6781    #[test]
6782    fn inline_review_comments_keep_their_file_and_line() {
6783        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6784        assert_eq!(comments.len(), 1);
6785        assert_eq!(comments[0].author, "claude[bot]");
6786        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6787        assert_eq!(comments[0].line, Some(231));
6788        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6789    }
6790
6791    #[test]
6792    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6793        assert!(
6794            is_noise(CODERABBIT_TRIGGER),
6795            "CodeRabbit's trigger notice declares itself not a review"
6796        );
6797        assert!(
6798            is_noise(CLAUDE_CHECKLIST),
6799            "a progress checklist asks for nothing"
6800        );
6801        assert!(
6802            !is_noise(CLAUDE_FINDING),
6803            "a review that names a bug is input, not noise"
6804        );
6805
6806        let mut clean = pr(Checks::Green, &[], 0);
6807        clean.review_comments.push(ReviewComment {
6808            author: "coderabbitai".to_owned(),
6809            path: None,
6810            line: None,
6811            body: CODERABBIT_TRIGGER.to_owned(),
6812        });
6813        clean.review_comments.retain(|c| !is_noise(&c.body));
6814        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6815
6816        let mut found = pr(Checks::Green, &[], 0);
6817        found.review_comments.push(ReviewComment {
6818            author: "claude".to_owned(),
6819            path: None,
6820            line: None,
6821            body: CLAUDE_FINDING.to_owned(),
6822        });
6823        found.review_comments.retain(|c| !is_noise(&c.body));
6824        assert!(matches!(
6825            decide(&found, 0, 4, Duration::ZERO),
6826            Step::Fix { .. }
6827        ));
6828    }
6829
6830    #[test]
6831    fn the_policy_table_holds_for_every_combination_that_matters() {
6832        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6833            (
6834                "pending checks are waited for, even on the last round",
6835                pr(Checks::Pending, &[], 0),
6836                4,
6837                4,
6838                Duration::ZERO,
6839                Step::Wait,
6840            ),
6841            (
6842                "red checks are fixed",
6843                pr(Checks::Red, &["editorconfig"], 0),
6844                0,
6845                4,
6846                Duration::ZERO,
6847                Step::Fix {
6848                    reason: "1 check(s) failing: editorconfig".to_owned(),
6849                },
6850            ),
6851            (
6852                "green with comments is fixed, not merged",
6853                pr(Checks::Green, &[], 2),
6854                1,
6855                4,
6856                Duration::ZERO,
6857                Step::Fix {
6858                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6859                        .to_owned(),
6860                },
6861            ),
6862            (
6863                "green and clean merges",
6864                pr(Checks::Green, &[], 0),
6865                3,
6866                4,
6867                Duration::ZERO,
6868                Step::Merge,
6869            ),
6870            (
6871                "an unreadable rollup is waited on while the grace lasts",
6872                pr(Checks::Unknown, &[], 0),
6873                0,
6874                4,
6875                Duration::ZERO,
6876                Step::Wait,
6877            ),
6878            (
6879                "an unreadable rollup is never merged once the grace is spent",
6880                pr(Checks::Unknown, &[], 0),
6881                0,
6882                4,
6883                CHECKS_GRACE,
6884                Step::GiveUp {
6885                    reason: "no check status is readable on the pull request after 3 minute(s); \
6886                             refusing to merge on a guess"
6887                        .to_owned(),
6888                },
6889            ),
6890        ];
6891        for (what, state, round, budget, waited, want) in cases {
6892            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6893        }
6894    }
6895
6896    #[test]
6897    fn the_forge_verdict_survives_the_round_trip_from_gh() {
6898        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
6899        // requested but never parsed is the kind of thing that looks wired up
6900        // and answers `Unsaid` forever.
6901        let green = parse_pr(GREEN_OPEN).expect("parse");
6902        assert_eq!(green.blocking, Blocking::No);
6903        let red = parse_pr(RED_OPEN).expect("parse");
6904        assert_eq!(
6905            red.blocking,
6906            Blocking::No,
6907            "`UNSTABLE` is mergeable: the red check is one nobody requires"
6908        );
6909        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6910        // A payload from an older `gh` has no such field at all.
6911        let quiet =
6912            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6913        assert_eq!(quiet.blocking, Blocking::Unsaid);
6914    }
6915
6916    #[test]
6917    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6918        // Pull request 37's only red check was `editorconfig`, failing
6919        // because the action could not fetch its own binary after
6920        // editorconfig-checker v4 renamed its release assets. The repository
6921        // does not require it. magi answered by asking a fixer to repair a
6922        // change that was fine, and the pull request had to be merged by hand.
6923        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6924        nonblocking.blocking = Blocking::No;
6925        assert_eq!(
6926            decide(&nonblocking, 0, 4, Duration::ZERO),
6927            Step::Merge,
6928            "the forge says nothing is in the way, so nothing is"
6929        );
6930
6931        // The same red, gated on: that is a fix round, as before.
6932        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6933        blocking.blocking = Blocking::Yes;
6934        assert!(matches!(
6935            decide(&blocking, 0, 4, Duration::ZERO),
6936            Step::Fix { .. }
6937        ));
6938
6939        // A review comment still outranks green-enough: a non-required red
6940        // must not become a way to merge past an unanswered reviewer.
6941        let mut commented = pr(Checks::Red, &["coverage"], 1);
6942        commented.blocking = Blocking::No;
6943        assert!(matches!(
6944            decide(&commented, 0, 4, Duration::ZERO),
6945            Step::Fix { .. }
6946        ));
6947
6948        // And silence from the forge is not consent.
6949        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6950        unsaid.blocking = Blocking::Unsaid;
6951        assert_eq!(decide(&unsaid, 0, 4, Duration::ZERO), Step::Wait);
6952        assert!(matches!(
6953            decide(&unsaid, 0, 4, CHECKS_GRACE),
6954            Step::Fix { .. }
6955        ));
6956    }
6957
6958    #[test]
6959    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6960        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6961        red.blocking = Blocking::No;
6962        assert_eq!(
6963            decide(&red, 0, 4, Duration::ZERO),
6964            Step::Merge,
6965            "announcing must not change the decision"
6966        );
6967        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6968        assert!(said.contains("yukimemi/magi"), "{said}");
6969        assert!(said.contains("#16"), "{said}");
6970        assert!(
6971            said.contains("https://github.com/yukimemi/magi/pull/16"),
6972            "{said}"
6973        );
6974        assert!(
6975            said.contains("test (windows-latest)") && said.contains("coverage"),
6976            "{said}"
6977        );
6978
6979        // `failing` can be left over on a green observation; only `checks` counts.
6980        let green = pr(Checks::Green, &["stale"], 0);
6981        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6982    }
6983
6984    #[test]
6985    fn red_merge_notices_omit_empty_check_details() {
6986        for checks in [vec![], vec!["", "  "]] {
6987            let red = pr(Checks::Red, &checks, 0);
6988            let summary = red_merge_summary("owner/repo", &red).unwrap();
6989            let notice = crate::notices::merged_red("run-1", &summary);
6990            assert_eq!(
6991                notice.message,
6992                format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6993            );
6994            assert_eq!(notice.severity, crate::notices::Severity::Warn);
6995        }
6996        let red = pr(Checks::Red, &["", " test ", " "], 0);
6997        let summary = red_merge_summary("owner/repo", &red).unwrap();
6998        assert!(summary.contains("with red checks: test ("), "{summary}");
6999    }
7000
7001    #[test]
7002    fn the_repo_label_comes_from_the_pull_request_url() {
7003        let p = Path::new("/tmp/checkout");
7004        assert_eq!(
7005            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
7006            "yukimemi/magi"
7007        );
7008        assert_eq!(repo_label(p, "not a url"), "checkout");
7009    }
7010
7011    #[test]
7012    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
7013        // Pull requests 35 and 37 were both rebased by hand: a competition
7014        // that runs for two hours against a repository merging pull requests
7015        // all day conflicts on the way in, and that is arithmetic rather
7016        // than a defect in the change.
7017        let mut conflicted = pr(Checks::Green, &[], 0);
7018        conflicted.blocking = Blocking::Conflict;
7019        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
7020
7021        // Decided before the checks, and even with the rounds spent: every
7022        // check on a branch that cannot land is an answer about a state that
7023        // cannot land, and a conflict is not the change's fault.
7024        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
7025        red.blocking = Blocking::Conflict;
7026        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
7027
7028        // The lifecycle still wins over everything, conflict included.
7029        let mut merged = pr(Checks::Red, &[], 0);
7030        merged.blocking = Blocking::Conflict;
7031        merged.state = PrLifecycle::Merged;
7032        assert_eq!(
7033            decide(&merged, 0, 4, Duration::ZERO),
7034            Step::Done { merged: true }
7035        );
7036    }
7037
7038    #[test]
7039    fn the_forge_verdict_is_read_off_merge_state_status() {
7040        // The spellings that mean "mergeable". `UNSTABLE` is the one that
7041        // matters: mergeable, with a non-required check red or still running.
7042        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
7043            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
7044            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
7045        }
7046        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
7047        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
7048        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
7049        // An older `gh`, or a token without the scope, says nothing - and
7050        // refusing to guess is the rule everywhere else in this module.
7051        for quiet in ["", "UNKNOWN"] {
7052            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
7053            assert!(Blocking::of(quiet).stops_a_merge());
7054        }
7055    }
7056
7057    #[test]
7058    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
7059        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
7060        // The exact stderr from run ec12, in a jj-colocated repository.
7061        let jj = "could not determine current branch: failed to run git: not on any branch";
7062
7063        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
7064            .expect("the forge says merged, so it merged");
7065        assert!(landed.ok);
7066        assert!(
7067            landed.detail.contains("but the pull request is merged"),
7068            "the record must not read as a clean success: {}",
7069            landed.detail
7070        );
7071        assert!(
7072            landed.detail.contains("not on any branch"),
7073            "and it must keep what the command actually said: {}",
7074            landed.detail
7075        );
7076
7077        // A pull request still open means the merge really failed.
7078        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
7079        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
7080        // And an unreadable answer is not evidence of success.
7081        assert!(merged_after_all(&argv, jj, None).is_none());
7082    }
7083
7084    #[test]
7085    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
7086        let mut state = pr(Checks::Red, &["editorconfig"], 3);
7087        state.state = PrLifecycle::Closed;
7088        assert_eq!(
7089            decide(&state, 0, 4, Duration::ZERO),
7090            Step::Done { merged: false },
7091            "a human closing the pull request ends the loop, whatever CI says"
7092        );
7093    }
7094
7095    #[test]
7096    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
7097        let red = decide(
7098            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
7099            4,
7100            4,
7101            Duration::ZERO,
7102        );
7103        match red {
7104            Step::GiveUp { reason } => {
7105                assert!(reason.contains("editorconfig"), "reason: {reason}");
7106                assert!(reason.contains("test (macos)"), "reason: {reason}");
7107                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
7108            }
7109            other => panic!("expected a give-up, got {other:?}"),
7110        }
7111
7112        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
7113        match commented {
7114            Step::GiveUp { reason } => {
7115                assert!(reason.contains("unresolved"), "reason: {reason}");
7116                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
7117            }
7118            other => panic!("expected a give-up, got {other:?}"),
7119        }
7120    }
7121
7122    #[test]
7123    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
7124        let candidate_commit = "magi: candidate A (uncommitted work)";
7125        let subject = merge_subject(candidate_commit, "add retries to the uploader");
7126        let argv = merge_argv(16, &subject);
7127
7128        assert!(argv.contains(&"--squash".to_owned()));
7129        assert!(argv.contains(&"--delete-branch".to_owned()));
7130        assert!(argv.contains(&"--subject".to_owned()));
7131        assert_eq!(
7132            argv.last().map(String::as_str),
7133            Some("add retries to the uploader"),
7134            "the subject must not be the candidate commit message"
7135        );
7136        assert_ne!(subject, candidate_commit);
7137    }
7138
7139    #[test]
7140    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
7141        assert_eq!(
7142            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
7143            "feat: a queue, an unattended loop, and a phone UI"
7144        );
7145        assert_eq!(
7146            merge_subject("", "# port the retry logic\n\ndetails"),
7147            "port the retry logic",
7148            "an empty title falls back to the task's first line, heading marks stripped"
7149        );
7150    }
7151
7152    #[test]
7153    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
7154        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
7155        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
7156        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
7157        assert_eq!(job_of("https://coderabbit.ai/status"), None);
7158        assert_eq!(run_of(""), None);
7159    }
7160
7161    #[test]
7162    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
7163        let mut out = Vec::new();
7164        push_if_outstanding(
7165            &mut out,
7166            ReviewComment {
7167                author: "yukimemi".to_owned(),
7168                path: None,
7169                line: None,
7170                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
7171            },
7172        );
7173        assert!(out.is_empty());
7174    }
7175
7176    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
7177    /// falls back to the repository - which keeps these tests free of a
7178    /// worktree, a `git` invocation and a network.
7179    fn run_state() -> RunState {
7180        let mut state = RunState::new(
7181            std::path::PathBuf::from("/repo/magi"),
7182            "main".to_owned(),
7183            "abcdef1234".to_owned(),
7184            "add retries to the uploader".to_owned(),
7185            crate::config::Config::default(),
7186        );
7187        // Tests run in parallel against one persistent home and the ids
7188        // `RunState::new` draws from the clock can repeat, so two tests would
7189        // share a run's questions. The home also outlives the process, so the
7190        // counter alone would reuse an earlier run's ids.
7191        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7192        let nanos = std::time::SystemTime::now()
7193            .duration_since(std::time::UNIX_EPOCH)
7194            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
7195        state.id = format!(
7196            "20261004-{nanos:06}-{:04x}",
7197            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
7198        );
7199        state
7200    }
7201
7202    fn green_pr() -> PrState {
7203        PrState {
7204            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
7205            number: 42,
7206            state: PrLifecycle::Open,
7207            checks: Checks::Green,
7208            // The forge sees nothing in the way unless a test says otherwise.
7209            blocking: Blocking::No,
7210            failing: Vec::new(),
7211            review_comments: vec![ReviewComment {
7212                author: "coderabbitai".to_owned(),
7213                path: Some("src/land.rs".to_owned()),
7214                line: Some(212),
7215                body: "this branch never checks the exit code".to_owned(),
7216            }],
7217        }
7218    }
7219
7220    #[test]
7221    fn land_fixer_defers_review_and_verification_without_a_cache() {
7222        let state = run_state();
7223        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7224        assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
7225        assert!(p.contains("Commit in this worktree"), "{p}");
7226    }
7227
7228    #[test]
7229    fn github_facing_land_text_is_english_whatever_the_language() {
7230        let mut state = run_state();
7231        state.config.graph.language = "ja".to_owned();
7232        let comment = stop_comment(&state.id, "checks are still red");
7233        assert!(crate::github_text::check("", &comment).is_empty());
7234        assert!(comment.is_ascii(), "{comment}");
7235        assert!(comment.starts_with(MARKER));
7236
7237        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7238        let ja_at = p.find("Write all prose in ja").unwrap();
7239        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
7240        assert!(ja_at < rule_at, "{p}");
7241        assert!(p.contains("stays in Japanese"), "{p}");
7242
7243        state.config.graph.language = "en".to_owned();
7244        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7245        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
7246        assert!(!p.contains("does not apply"), "{p}");
7247    }
7248
7249    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
7250
7251    fn panel() -> String {
7252        approval_panel(
7253            &run_state(),
7254            &green_pr(),
7255            NUMSTAT,
7256            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
7257            &[
7258                "land: ask before merging".to_owned(),
7259                "land: colour the diff".to_owned(),
7260            ],
7261            "feat: merge approval from the phone",
7262        )
7263    }
7264
7265    #[test]
7266    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
7267        let html = panel();
7268        for needle in [
7269            "42",
7270            "main",
7271            "src/land.rs",
7272            "src/web.rs",
7273            "assets/logo.png",
7274            "feat: merge approval from the phone",
7275            "land: ask before merging",
7276            "land: colour the diff",
7277            "coderabbitai",
7278            "this branch never checks the exit code",
7279            "green",
7280        ] {
7281            assert!(html.contains(needle), "the panel must state `{needle}`");
7282        }
7283    }
7284
7285    async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
7286        crate::run::pin_test_home();
7287        let tmp = tempfile::tempdir().unwrap();
7288        let repo = tmp.path().join("repo");
7289        let remote = tmp.path().join("remote.git");
7290        std::fs::create_dir(&repo).unwrap();
7291        git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
7292            .await
7293            .unwrap();
7294        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
7295        git::git(&repo, &["config", "user.name", "Test"])
7296            .await
7297            .unwrap();
7298        git::git(&repo, &["config", "user.email", "test@example.test"])
7299            .await
7300            .unwrap();
7301        std::fs::write(repo.join("base"), "base").unwrap();
7302        git::git(&repo, &["add", "."]).await.unwrap();
7303        git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
7304        git::git(
7305            &repo,
7306            &["remote", "add", "origin", remote.to_str().unwrap()],
7307        )
7308        .await
7309        .unwrap();
7310        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
7311        git::git(&repo, &["checkout", "-b", "winner"])
7312            .await
7313            .unwrap();
7314        std::fs::write(repo.join("change"), "change").unwrap();
7315        git::git(&repo, &["add", "."]).await.unwrap();
7316        git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
7317        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
7318        git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
7319            .await
7320            .unwrap();
7321        let mut state = run_state();
7322        state.repo = repo.clone();
7323        state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
7324        state.config.graph.land = true;
7325        state.config.graph.land_approval = true;
7326        state.pr_head = Some("task-head".into());
7327        state.pr_pushed_head = Some(head.clone());
7328        state.candidates = vec![winning_candidate("change")];
7329        state.candidates[0].worktree = repo;
7330        state.candidates[0].branch = "winner".into();
7331        state.tally = Some(uncontested_tally());
7332        state.gate_ran = true;
7333        state.gate_head = Some(head.clone());
7334        state.land_sync_pending = true;
7335        state.save().unwrap();
7336        (tmp, state, head)
7337    }
7338
7339    async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
7340        let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
7341        resume_landing_head(state, head, &green_pr().url, &forge).await
7342    }
7343
7344    async fn advance_landing_base(state: &RunState) {
7345        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7346        std::fs::write(state.repo.join("advance"), "advance").unwrap();
7347        git::git(&state.repo, &["add", "."]).await.unwrap();
7348        git::git(&state.repo, &["commit", "-m", "advance base"])
7349            .await
7350            .unwrap();
7351        git::git(&state.repo, &["push", "origin", "main"])
7352            .await
7353            .unwrap();
7354        git::git(&state.repo, &["checkout", "winner"])
7355            .await
7356            .unwrap();
7357    }
7358
7359    #[tokio::test]
7360    async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
7361        let (_tmp, mut state, old) = landing_git_fixture().await;
7362        advance_landing_base(&state).await;
7363        state.config.verify.gate = vec!["test -f advance".into()];
7364        let head = resume_observed_head(&mut state, &old).await.unwrap();
7365        assert_ne!(head, old);
7366        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7367        assert!(state.gate_allows_progress());
7368        assert_eq!(
7369            git::remote_head(&state.repo, "origin", "task-head")
7370                .await
7371                .unwrap(),
7372            Some(head.clone())
7373        );
7374        assert_eq!(state.pr_pushed_head, Some(head));
7375        assert!(!state.land_sync_pending);
7376        assert!(state.config.graph.land_approval);
7377    }
7378
7379    fn landing_gate_fixer(state: &mut RunState, script: &str) {
7380        state.config.disk.min_free_bytes = 0;
7381        state.config.graph.gate_fix_rounds = 1;
7382        state.config.agents = vec![crate::config::AgentSpec {
7383            id: "mock".into(),
7384            kind: crate::config::AgentKind::Command,
7385            model: None,
7386            command: vec!["sh".into(), "-c".into(), script.into()],
7387            extra_args: Vec::new(),
7388            env: Default::default(),
7389            prompt_delivery: None,
7390            max_turn_secs: None,
7391        }];
7392        state.candidates[0].agent = "mock".into();
7393    }
7394
7395    #[tokio::test]
7396    async fn landing_resume_revalidates_an_operator_published_winner_extension() {
7397        let (_tmp, mut state, old) = landing_git_fixture().await;
7398        std::fs::write(state.repo.join("change"), "operator update").unwrap();
7399        git::commit_all(&state.repo, "extend winner").await.unwrap();
7400        let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7401        git::git(
7402            &state.repo,
7403            &["push", "origin", "HEAD:refs/heads/task-head"],
7404        )
7405        .await
7406        .unwrap();
7407        state.config.verify.gate = vec!["exit 1".into()];
7408        state.config.graph.gate_fix_rounds = 0;
7409        assert!(resume_observed_head(&mut state, &updated).await.is_err());
7410        assert_ne!(old, updated);
7411        assert_eq!(state.gate_head.as_ref(), Some(&updated));
7412        assert!(!state.gate_allows_progress());
7413        assert!(state.land_sync_pending);
7414        state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
7415        let head = resume_observed_head(&mut state, &updated).await.unwrap();
7416        assert_eq!(head, updated);
7417        assert!(state.gate_allows_progress());
7418        assert!(!state.land_sync_pending);
7419        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7420        land_with(&mut state, &green_pr().url, &forge)
7421            .await
7422            .unwrap();
7423        assert!(state.parked);
7424        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7425        assert!(forge.argvs().is_empty());
7426    }
7427
7428    #[tokio::test]
7429    async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
7430        for divergent in [false, true] {
7431            let (_tmp, mut state, old) = landing_git_fixture().await;
7432            if divergent {
7433                git::git(&state.repo, &["reset", "--hard", "origin/main"])
7434                    .await
7435                    .unwrap();
7436            }
7437            std::fs::write(state.repo.join("external"), "external").unwrap();
7438            git::commit_all(&state.repo, "external update")
7439                .await
7440                .unwrap();
7441            let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7442            git::git(
7443                &state.repo,
7444                &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
7445            )
7446            .await
7447            .unwrap();
7448            if !divergent {
7449                git::git(&state.repo, &["reset", "--hard", &old])
7450                    .await
7451                    .unwrap();
7452            }
7453            let error = resume_observed_head(&mut state, &external)
7454                .await
7455                .unwrap_err();
7456            assert!(
7457                error
7458                    .to_string()
7459                    .contains("public head moved outside this run")
7460            );
7461            assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
7462            assert_eq!(
7463                git::remote_head(&state.repo, "origin", "task-head")
7464                    .await
7465                    .unwrap(),
7466                Some(external)
7467            );
7468        }
7469    }
7470
7471    #[tokio::test]
7472    async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
7473        let (_tmp, mut state, old) = landing_git_fixture().await;
7474        advance_landing_base(&state).await;
7475        landing_gate_fixer(
7476            &mut state,
7477            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7478        );
7479        state.config.verify.gate =
7480            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7481        state.config.verify.e2e = vec!["test -f advance".into()];
7482        // First save a red gate without spending a fixer round. Resume must
7483        // still offer the remaining budget for this exact same failed head.
7484        state.config.graph.gate_fix_rounds = 0;
7485        assert!(resume_observed_head(&mut state, &old).await.is_err());
7486        assert_eq!(
7487            git::remote_head(&state.repo, "origin", "task-head")
7488                .await
7489                .unwrap(),
7490            Some(old.clone())
7491        );
7492        state.config.graph.gate_fix_rounds = 1;
7493        let head = resume_observed_head(&mut state, &old).await.unwrap();
7494        assert_eq!(state.gate_fixes.len(), 1);
7495        assert!(state.gate_fixes[0].committed);
7496        assert!(state.gate_allows_progress());
7497        assert_eq!(state.gate_head.as_ref(), Some(&head));
7498        assert_eq!(
7499            git::remote_head(&state.repo, "origin", "task-head")
7500                .await
7501                .unwrap(),
7502            Some(head.clone())
7503        );
7504        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7505        land_with(&mut state, &green_pr().url, &forge)
7506            .await
7507            .unwrap();
7508        assert!(state.parked);
7509        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7510        assert!(forge.argvs().is_empty());
7511    }
7512
7513    #[tokio::test]
7514    async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7515        let (_tmp, mut state, old) = landing_git_fixture().await;
7516        advance_landing_base(&state).await;
7517        landing_gate_fixer(
7518            &mut state,
7519            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7520        );
7521        state.config.verify.gate = vec!["echo still red; exit 1".into()];
7522        for _ in 0..2 {
7523            assert!(resume_observed_head(&mut state, &old).await.is_err());
7524            assert_eq!(state.gate_fixes.len(), 1);
7525            assert_eq!(
7526                git::remote_head(&state.repo, "origin", "task-head")
7527                    .await
7528                    .unwrap(),
7529                Some(old.clone())
7530            );
7531        }
7532    }
7533
7534    #[tokio::test]
7535    async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7536        let (_tmp, mut state, old) = landing_git_fixture().await;
7537        advance_landing_base(&state).await;
7538        landing_gate_fixer(
7539            &mut state,
7540            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7541        );
7542        state.config.verify.gate =
7543            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7544        // The repair makes the gate green while breaking an e2e invariant.
7545        state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7546        for resume in 0..3 {
7547            assert!(resume_observed_head(&mut state, &old).await.is_err());
7548            state = RunState::load(&state.id).unwrap();
7549            let evidence = state.gate_repair_e2e.as_ref().unwrap();
7550            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7551            assert_eq!(evidence.head, head);
7552            assert!(!evidence.passed);
7553            assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7554            assert_eq!(state.gate_fixes.len(), 1);
7555            assert_eq!(
7556                git::remote_head(&state.repo, "origin", "task-head")
7557                    .await
7558                    .unwrap(),
7559                Some(old.clone())
7560            );
7561            if resume > 0 {
7562                assert!(
7563                    state.gate_allows_progress(),
7564                    "a green gate must not bypass red e2e"
7565                );
7566            }
7567        }
7568        // Once e2e genuinely succeeds on the same SHA, the saved requirement
7569        // can be satisfied without another fixer or approval bypass.
7570        state.config.verify.e2e = vec!["grep -q fixed change".into()];
7571        let head = resume_observed_head(&mut state, &old).await.unwrap();
7572        assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7573        assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7574        assert_eq!(state.gate_fixes.len(), 1);
7575        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7576        land_with(&mut state, &green_pr().url, &forge)
7577            .await
7578            .unwrap();
7579        assert!(state.parked);
7580        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7581        assert!(forge.argvs().is_empty());
7582    }
7583
7584    #[tokio::test]
7585    async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7586        for phase in 0..3 {
7587            let (_tmp, mut state, old) = landing_git_fixture().await;
7588            advance_landing_base(&state).await;
7589            let head = resume_observed_head(&mut state, &old).await.unwrap();
7590            // Persist the shapes left by interruption, resource deferral,
7591            // and a successful check for a different (older) SHA.
7592            state.land_sync_pending = true;
7593            state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7594            state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7595                head: if phase == 2 {
7596                    old.clone()
7597                } else {
7598                    head.clone()
7599                },
7600                passed: phase == 2,
7601                outcomes: if phase == 1 {
7602                    vec![crate::run::CommandOutcome {
7603                        command: state.config.verify.e2e[0].clone(),
7604                        code: None,
7605                        output_tail: "verification resource unavailable".into(),
7606                        duration_ms: 0,
7607                        resource_blocked: true,
7608                    }]
7609                } else {
7610                    Vec::new()
7611                },
7612            });
7613            state.save().unwrap();
7614            for _ in 0..2 {
7615                state = RunState::load(&state.id).unwrap();
7616                assert!(resume_observed_head(&mut state, &head).await.is_err());
7617                let evidence = state.gate_repair_e2e.as_ref().unwrap();
7618                assert_eq!(evidence.head, head);
7619                assert!(!evidence.passed);
7620                assert!(!evidence.outcomes.is_empty());
7621                assert_eq!(
7622                    git::remote_head(&state.repo, "origin", "task-head")
7623                        .await
7624                        .unwrap(),
7625                    Some(head.clone())
7626                );
7627                assert!(state.land_sync_pending);
7628            }
7629        }
7630    }
7631
7632    async fn interrupt_landing_rebase(state: &mut RunState) {
7633        state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7634        state.save().unwrap();
7635        let scratch = state.dir().join("rebase");
7636        assert_eq!(
7637            crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7638                .await
7639                .unwrap(),
7640            crate::rebase::Rebased::Applied
7641        );
7642        assert!(!git::is_clean(&state.repo).await.unwrap());
7643    }
7644
7645    async fn landing_setup(state: &mut RunState) {
7646        git::git(
7647            &state.repo,
7648            &["config", "extensions.worktreeConfig", "true"],
7649        )
7650        .await
7651        .unwrap();
7652        state.config.worktree.setup =
7653            vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7654        crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7655            .await
7656            .unwrap();
7657        state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7658    }
7659
7660    #[tokio::test]
7661    async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7662        for phase in 0..3 {
7663            let (_tmp, mut state, old) = landing_git_fixture().await;
7664            advance_landing_base(&state).await;
7665            landing_setup(&mut state).await;
7666            if phase > 0 {
7667                interrupt_landing_rebase(&mut state).await;
7668                if phase == 2 {
7669                    // Crash after clean removed setup products, before setup.
7670                    git::sync_to_head(&state.repo).await.unwrap();
7671                    assert!(!state.repo.join(".env").exists());
7672                }
7673                state = RunState::load(&state.id).unwrap();
7674            }
7675            let head = resume_observed_head(&mut state, &old).await.unwrap();
7676            assert_ne!(head, old);
7677            assert!(state.gate_allows_progress());
7678            assert!(state.land_rebase_from.is_none());
7679            assert_eq!(
7680                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7681                "secret"
7682            );
7683            assert!(git::is_clean(&state.repo).await.unwrap());
7684            assert!(
7685                !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7686                    .await
7687                    .unwrap()
7688                    .contains(".env")
7689            );
7690        }
7691    }
7692
7693    #[tokio::test]
7694    async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7695        let (_tmp, mut state, _) = landing_git_fixture().await;
7696        advance_landing_base(&state).await;
7697        landing_setup(&mut state).await;
7698        interrupt_landing_rebase(&mut state).await;
7699        // Exercise unusual names without asking Windows to create an invalid
7700        // control-character filename. Unix also covers embedded newlines.
7701        let locals = [
7702            "local notes",
7703            #[cfg(unix)]
7704            "local\nnotes",
7705        ];
7706        let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7707            .await
7708            .unwrap();
7709        std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7710        for local in locals {
7711            std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7712            assert!(
7713                git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7714                    .await
7715                    .unwrap()
7716                    .ok()
7717            );
7718        }
7719        restore_landing_worktree(&mut state).await.unwrap();
7720        for local in locals {
7721            assert_eq!(
7722                std::fs::read_to_string(state.repo.join(local)).unwrap(),
7723                "keep these notes"
7724            );
7725        }
7726        assert_eq!(
7727            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7728            "secret"
7729        );
7730        assert!(git::is_clean(&state.repo).await.unwrap());
7731        assert!(state.land_rebase_from.is_none());
7732    }
7733
7734    #[tokio::test]
7735    async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7736        for local in ["advance", "advance/notes", "new-directory"] {
7737            let (_tmp, mut state, _) = landing_git_fixture().await;
7738            advance_landing_base(&state).await;
7739            // Also cover a local file blocking a new tracked directory.
7740            git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7741            std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7742            std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7743            git::git(&state.repo, &["add", "."]).await.unwrap();
7744            git::git(&state.repo, &["commit", "-m", "add directory"])
7745                .await
7746                .unwrap();
7747            git::git(&state.repo, &["push", "origin", "main"])
7748                .await
7749                .unwrap();
7750            git::git(&state.repo, &["checkout", "winner"])
7751                .await
7752                .unwrap();
7753            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7754                .await
7755                .unwrap();
7756            std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7757            let path = state.repo.join(local);
7758            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7759            std::fs::write(&path, "local notes").unwrap();
7760            interrupt_landing_rebase(&mut state).await;
7761            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7762            let checkpoint = state.land_rebase_from.clone();
7763            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7764            assert!(
7765                error.to_string().contains("conflicting with HEAD"),
7766                "{error:#}"
7767            );
7768            assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7769            assert_eq!(
7770                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7771                before
7772            );
7773            assert_eq!(state.land_rebase_from, checkpoint);
7774        }
7775    }
7776
7777    #[tokio::test]
7778    async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7779        for interrupted in [false, true] {
7780            let (_tmp, mut state, old) = landing_git_fixture().await;
7781            assert!(state.config.worktree.setup.is_empty());
7782            // A gate product exists before the base moves, without setup metadata.
7783            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7784                .await
7785                .unwrap();
7786            std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7787            state.config.verify.gate =
7788                vec!["mkdir -p target && printf artifact > target/probe".into()];
7789            state.gate_ran = false;
7790            resume_observed_head(&mut state, &old).await.unwrap();
7791            assert_eq!(
7792                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7793                "artifact"
7794            );
7795            advance_landing_base(&state).await;
7796            if interrupted {
7797                interrupt_landing_rebase(&mut state).await;
7798                state = RunState::load(&state.id).unwrap();
7799            }
7800            state.config.verify.gate =
7801                vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7802            let head = resume_observed_head(&mut state, &old).await.unwrap();
7803            assert_ne!(head, old);
7804            assert!(state.gate_allows_progress());
7805            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7806            assert_eq!(
7807                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7808                "artifact"
7809            );
7810            assert!(state.land_rebase_from.is_none());
7811            assert_eq!(
7812                git::remote_head(&state.repo, "origin", "task-head")
7813                    .await
7814                    .unwrap(),
7815                Some(head.clone())
7816            );
7817            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7818            land_with(&mut state, &green_pr().url, &forge)
7819                .await
7820                .unwrap();
7821            assert!(state.parked);
7822            assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7823            assert!(forge.argvs().is_empty());
7824        }
7825    }
7826
7827    #[tokio::test]
7828    #[cfg(unix)]
7829    async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7830        use std::os::fd::AsRawFd as _;
7831
7832        for interrupted in [false, true] {
7833            let (_tmp, mut state, _) = landing_git_fixture().await;
7834            advance_landing_base(&state).await;
7835            landing_setup(&mut state).await;
7836            if interrupted {
7837                interrupt_landing_rebase(&mut state).await;
7838            }
7839            state.config.worktree.setup =
7840                vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7841            let common = crate::repo_lock::common_dir(&state.repo)
7842                .await
7843                .unwrap()
7844                .unwrap();
7845            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7846            state.config.coordination = crate::config::test_coordination();
7847            state.config.graph.timeout_resource_wait = 0;
7848            let external = std::fs::OpenOptions::new()
7849                .read(true)
7850                .write(true)
7851                .create(true)
7852                .truncate(false)
7853                .open(common.join("updater.lock"))
7854                .unwrap();
7855            // SAFETY: the descriptor belongs to `external` and remains open.
7856            let locked =
7857                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7858            assert_eq!(locked, 0);
7859            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7860            let checkpoint = state.land_rebase_from.clone();
7861            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7862            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7863            assert_eq!(
7864                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7865                before
7866            );
7867            assert_eq!(state.land_rebase_from, checkpoint);
7868            assert_eq!(
7869                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7870                "secret"
7871            );
7872
7873            drop(external);
7874            state.config.graph.timeout_resource_wait = 2;
7875            restore_landing_worktree(&mut state).await.unwrap();
7876            assert!(git::is_clean(&state.repo).await.unwrap());
7877            assert!(state.land_rebase_from.is_none());
7878            assert_eq!(
7879                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7880                "restored"
7881            );
7882        }
7883    }
7884
7885    #[tokio::test]
7886    #[cfg(unix)]
7887    async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7888        use std::sync::atomic::{AtomicBool, Ordering};
7889
7890        let (_tmp, mut state, _) = landing_git_fixture().await;
7891        advance_landing_base(&state).await;
7892        landing_setup(&mut state).await;
7893        interrupt_landing_rebase(&mut state).await;
7894        let common = crate::repo_lock::common_dir(&state.repo)
7895            .await
7896            .unwrap()
7897            .unwrap();
7898        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7899        state.config.coordination = crate::config::test_coordination();
7900        let holder = crate::repo_lock::SeatLease::for_checkout(
7901            &state.repo,
7902            true,
7903            state.config.coordination.as_ref(),
7904        )
7905        .await
7906        .unwrap();
7907        assert!(holder.is_some());
7908        let cancelled = Arc::new(AtomicBool::new(false));
7909        let signal = cancelled.clone();
7910        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7911            signal.load(Ordering::SeqCst)
7912        });
7913        let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7914        let checkpoint = state.land_rebase_from.clone();
7915        let (result, ()) = tokio::join!(
7916            tokio::time::timeout(
7917                Duration::from_secs(2),
7918                wait.scope(restore_landing_worktree(&mut state)),
7919            ),
7920            async {
7921                tokio::time::sleep(Duration::from_millis(100)).await;
7922                cancelled.store(true, Ordering::SeqCst);
7923            }
7924        );
7925        let error = result
7926            .expect("cancellation must release the waiting driver")
7927            .unwrap_err();
7928        assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7929        assert_eq!(
7930            git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7931            before
7932        );
7933        assert_eq!(state.land_rebase_from, checkpoint);
7934        assert_eq!(
7935            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7936            "secret"
7937        );
7938        drop(holder);
7939        restore_landing_worktree(&mut state).await.unwrap();
7940        assert!(state.land_rebase_from.is_none());
7941        assert!(git::is_clean(&state.repo).await.unwrap());
7942    }
7943
7944    #[tokio::test]
7945    #[cfg(unix)]
7946    async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
7947        use std::sync::atomic::{AtomicBool, Ordering};
7948
7949        for e2e in [false, true] {
7950            let (_tmp, mut state, head) = landing_git_fixture().await;
7951            let common = crate::repo_lock::common_dir(&state.repo)
7952                .await
7953                .unwrap()
7954                .unwrap();
7955            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7956            state.config.coordination = crate::config::test_coordination();
7957            if e2e {
7958                state.config.verify.e2e = vec!["true".into()];
7959                state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7960                    head: head.clone(),
7961                    passed: false,
7962                    outcomes: Vec::new(),
7963                });
7964            } else {
7965                state.config.verify.gate = vec!["true".into()];
7966                state.gate_ran = false;
7967            }
7968            let holder = crate::repo_lock::SeatLease::for_checkout(
7969                &state.repo,
7970                true,
7971                state.config.coordination.as_ref(),
7972            )
7973            .await
7974            .unwrap();
7975            assert!(holder.is_some());
7976            let cancelled = Arc::new(AtomicBool::new(false));
7977            let signal = cancelled.clone();
7978            let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7979                signal.load(Ordering::SeqCst)
7980            });
7981            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7982            let url = green_pr().url;
7983            let (result, ()) = tokio::join!(
7984                tokio::time::timeout(
7985                    Duration::from_secs(2),
7986                    wait.scope(publish_landing_head(&mut state, &head, &url, &forge)),
7987                ),
7988                async {
7989                    tokio::time::sleep(Duration::from_millis(100)).await;
7990                    cancelled.store(true, Ordering::SeqCst);
7991                }
7992            );
7993            let error = result
7994                .expect("cancellation must release the verification waiter")
7995                .unwrap_err();
7996            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7997            assert!(forge.argvs().is_empty());
7998            assert_eq!(
7999                git::remote_head(&state.repo, "origin", "task-head")
8000                    .await
8001                    .unwrap(),
8002                Some(head.clone())
8003            );
8004            drop(holder);
8005            // Cancellation must release its reservation so another attempt can verify.
8006            assert_eq!(
8007                publish_landing_head(&mut state, &head, &url, &forge)
8008                    .await
8009                    .unwrap(),
8010                head
8011            );
8012        }
8013    }
8014
8015    #[tokio::test]
8016    async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
8017        for staged in [false, true] {
8018            let (_tmp, mut state, old) = landing_git_fixture().await;
8019            advance_landing_base(&state).await;
8020            interrupt_landing_rebase(&mut state).await;
8021            std::fs::write(state.repo.join("change"), "user edit").unwrap();
8022            if staged {
8023                git::git(&state.repo, &["add", "change"]).await.unwrap();
8024            }
8025            assert!(resume_observed_head(&mut state, &old).await.is_err());
8026            assert_eq!(
8027                std::fs::read_to_string(state.repo.join("change")).unwrap(),
8028                "user edit"
8029            );
8030            assert_eq!(
8031                git::remote_head(&state.repo, "origin", "task-head")
8032                    .await
8033                    .unwrap(),
8034                Some(old)
8035            );
8036        }
8037    }
8038
8039    #[tokio::test]
8040    async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
8041        let (_tmp, mut state, head) = landing_git_fixture().await;
8042        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
8043        let id = state.id.clone();
8044        let pr = land_with(&mut state, &green_pr().url, &forge)
8045            .await
8046            .unwrap();
8047        assert_eq!(pr.state, PrLifecycle::Open);
8048        assert_eq!(state.id, id);
8049        assert!(state.parked);
8050        assert_eq!(state.status, RunStatus::Landing);
8051        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
8052        assert!(forge.argvs().is_empty());
8053
8054        state.land_sync_pending = true;
8055        std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
8056        let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
8057        closed.pr.state = PrLifecycle::Closed;
8058        let forge = Scripted::new(vec![closed], vec![]);
8059        assert_eq!(
8060            land_with(&mut state, &green_pr().url, &forge)
8061                .await
8062                .unwrap()
8063                .state,
8064            PrLifecycle::Closed
8065        );
8066        assert_eq!(
8067            std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
8068            "do not touch"
8069        );
8070        assert!(forge.argvs().is_empty());
8071    }
8072
8073    #[tokio::test]
8074    async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
8075        let (_tmp, mut state, old) = landing_git_fixture().await;
8076        advance_landing_base(&state).await;
8077        state.config.verify.gate = vec!["exit 1".into()];
8078        assert!(resume_observed_head(&mut state, &old).await.is_err());
8079        assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
8080        assert!(!state.gate_allows_progress());
8081        assert_eq!(
8082            git::remote_head(&state.repo, "origin", "task-head")
8083                .await
8084                .unwrap(),
8085            Some(old)
8086        );
8087        assert!(state.land_sync_pending);
8088    }
8089
8090    #[tokio::test]
8091    async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
8092        let (_tmp, mut state, old) = landing_git_fixture().await;
8093        advance_landing_base(&state).await;
8094        let pushed = resume_observed_head(&mut state, &old).await.unwrap();
8095        // Model a crash after the push but before saving its published SHA.
8096        state.pr_pushed_head = Some(old.clone());
8097        state.land_sync_pending = true;
8098        assert_eq!(
8099            resume_observed_head(&mut state, &pushed).await.unwrap(),
8100            pushed
8101        );
8102        // A writer races the observed PR snapshot: the explicit remote lease
8103        // must reject the push even though preflight saw our expected head.
8104        git::git(
8105            &state.repo,
8106            &["push", "--force", "origin", "main:refs/heads/task-head"],
8107        )
8108        .await
8109        .unwrap();
8110        assert!(
8111            publish_landing_head(
8112                &mut state,
8113                &pushed,
8114                &green_pr().url,
8115                &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
8116            )
8117            .await
8118            .unwrap_err()
8119            .to_string()
8120            .contains("publishing gated")
8121        );
8122        assert!(
8123            resume_observed_head(&mut state, "foreign-head")
8124                .await
8125                .is_err()
8126        );
8127    }
8128    #[tokio::test]
8129    async fn landing_publication_refuses_a_pr_closed_during_gate() {
8130        let (_tmp, mut state, head) = landing_git_fixture().await;
8131        let mut closed = seen(&head, Checks::Green, "CLEAN", false);
8132        closed.pr.state = PrLifecycle::Closed;
8133        let forge = Scripted::new(vec![closed], vec![]);
8134        assert!(
8135            publish_landing_head(&mut state, &head, &green_pr().url, &forge)
8136                .await
8137                .unwrap_err()
8138                .to_string()
8139                .contains("PR closed or merged")
8140        );
8141    }
8142
8143    /// A candidate selected by the uncontested tally.
8144    fn winning_candidate(summary: &str) -> Candidate {
8145        Candidate {
8146            index: 0,
8147            label: 'A',
8148            agent: "opus".to_owned(),
8149            branch: "magi/x/A".to_owned(),
8150            worktree: PathBuf::from("/wt/A"),
8151            summary: summary.to_owned(),
8152            stat: String::new(),
8153            files: 1,
8154            commits: 1,
8155            empty: false,
8156            failed: None,
8157            verified_noop: None,
8158            duration_ms: 0,
8159            folded: false,
8160        }
8161    }
8162
8163    fn uncontested_tally() -> Tally {
8164        Tally {
8165            first_choice: BTreeMap::from([('A', 1)]),
8166            borda: BTreeMap::new(),
8167            winner: 'A',
8168            rankings: 1,
8169            unanimous_initial: true,
8170            deliberated: false,
8171            changed_votes: 0,
8172            unanimous_final: true,
8173            tie_break: None,
8174            judges: 1,
8175            present: 1,
8176            quorum: 1,
8177            met_quorum: true,
8178            uncontested: None,
8179        }
8180    }
8181
8182    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
8183        ReviewRecord {
8184            attempts: 0,
8185            reviewer,
8186            agent: agent.to_owned(),
8187            summary: summary.to_owned(),
8188            findings: Vec::new(),
8189            vote: None,
8190            failed: None,
8191            duration_ms: 0,
8192        }
8193    }
8194
8195    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
8196        let answered = reviews.len();
8197        ReviewRound {
8198            round,
8199            head: "abc1234".to_owned(),
8200            verified_head: None,
8201            verified_at: None,
8202            reviews,
8203            e2e: Vec::new(),
8204            verify_retried: false,
8205            e2e_deferred: false,
8206            e2e_defer_reason: None,
8207            fix: None,
8208            blocking: 0,
8209            answered,
8210            expected: answered,
8211            clean: true,
8212            progressed: false,
8213            vote_split: false,
8214            reconsideration: Vec::new(),
8215            verdict: None,
8216        }
8217    }
8218
8219    #[test]
8220    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
8221        let en = panel();
8222        assert!(en.contains("Task"), "{en}");
8223        assert!(en.contains("add retries to the uploader"), "{en}");
8224
8225        let mut state = run_state();
8226        state.config.graph.language = "ja".to_owned();
8227        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8228        assert!(ja.contains("タスク"), "{ja}");
8229        assert!(
8230            ja.contains("add retries to the uploader"),
8231            "the task itself is not translated: {ja}"
8232        );
8233    }
8234
8235    #[test]
8236    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
8237        // `run_state()` has no candidates, no tally and no reviews - exactly
8238        // the shape a run has before anything has judged or reviewed it, and
8239        // the panel must not print an empty box for either.
8240        let html = panel();
8241        assert!(!html.contains("What changed"), "{html}");
8242        assert!(!html.contains("Review verdict"), "{html}");
8243    }
8244
8245    #[test]
8246    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
8247        let mut state = run_state();
8248        state.candidates = vec![winning_candidate("")];
8249        state.tally = Some(uncontested_tally());
8250        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8251        assert!(
8252            !html.contains("What changed"),
8253            "an empty summary must not render an empty box: {html}"
8254        );
8255    }
8256
8257    #[test]
8258    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
8259        let mut state = run_state();
8260        state.candidates = vec![winning_candidate(
8261            "Added a retry loop around the uploader PUT call.",
8262        )];
8263        state.tally = Some(uncontested_tally());
8264        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8265        assert!(en.contains("What changed"), "{en}");
8266        assert!(
8267            en.contains("Added a retry loop around the uploader PUT call."),
8268            "{en}"
8269        );
8270
8271        state.config.graph.language = "ja".to_owned();
8272        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8273        assert!(ja.contains("変更内容"), "{ja}");
8274        assert!(
8275            ja.contains("Added a retry loop around the uploader PUT call."),
8276            "{ja}"
8277        );
8278    }
8279
8280    #[test]
8281    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
8282        let mut state = run_state();
8283        state.reviews = vec![
8284            review_round(
8285                1,
8286                vec![review_record(1, "alpha", "found a race, sent back")],
8287            ),
8288            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
8289        ];
8290        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8291        assert!(en.contains("Review verdict"), "{en}");
8292        assert!(en.contains("race is fixed, clean"), "{en}");
8293        assert!(
8294            !en.contains("found a race, sent back"),
8295            "only the round that actually cleared the merge should show: {en}"
8296        );
8297
8298        state.config.graph.language = "ja".to_owned();
8299        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8300        assert!(ja.contains("レビューの結論"), "{ja}");
8301        assert!(ja.contains("レビュアー"), "{ja}");
8302        assert!(ja.contains("race is fixed, clean"), "{ja}");
8303    }
8304
8305    /// The `incomplete_review = "warn"` policy (see
8306    /// `graph::Runner::review_loop`) can push a `clean` round to
8307    /// `state.reviews` while one seat's own record still has `failed: Some`
8308    /// and an empty `summary` - a seat that never answered, not one that
8309    /// answered with nothing to say.
8310    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
8311        ReviewRecord {
8312            attempts: 0,
8313            reviewer,
8314            agent: agent.to_owned(),
8315            summary: String::new(),
8316            findings: Vec::new(),
8317            vote: None,
8318            failed: Some(reason.to_owned()),
8319            duration_ms: 0,
8320        }
8321    }
8322
8323    #[test]
8324    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
8325        let mut state = run_state();
8326        state.reviews = vec![review_round(
8327            1,
8328            vec![
8329                review_record(1, "alpha", "clean, nothing to add"),
8330                unanswered_review_record(2, "beta", "timed out"),
8331            ],
8332        )];
8333        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8334        assert!(en.contains("clean, nothing to add"), "{en}");
8335        assert!(
8336            en.contains("produced no answer: timed out"),
8337            "a seat that never answered must say so, not render a blank box: {en}"
8338        );
8339        assert!(
8340            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
8341            "no reviewer box may be left empty: {en}"
8342        );
8343
8344        state.config.graph.language = "ja".to_owned();
8345        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8346        assert!(ja.contains("回答なし: timed out"), "{ja}");
8347    }
8348
8349    #[test]
8350    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
8351        let html = panel();
8352        assert!(!html.contains("<script"), "no script survives the csp");
8353        assert!(!html.contains("<form"), "form-action is 'none'");
8354        let pr = green_pr();
8355        assert_eq!(
8356            html.matches("http").count(),
8357            html.matches(pr.url.as_str()).count(),
8358            "the only http url in the panel is the pull request's own link"
8359        );
8360        assert!(
8361            html.contains("target=\"_blank\""),
8362            "the PR link must open in a new tab so it escapes the iframe"
8363        );
8364        assert!(
8365            html.contains("rel=\"noopener noreferrer\""),
8366            "external links opening in new tabs must carry noopener noreferrer"
8367        );
8368    }
8369
8370    #[test]
8371    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
8372        let html = panel();
8373        assert!(
8374            html.contains(">+</span>"),
8375            "an added line carries a `+` in the gutter, not only a background"
8376        );
8377        assert!(
8378            html.contains(">-</span>"),
8379            "a removed line carries a `-` in the gutter, not only a background"
8380        );
8381        assert!(
8382            html.contains(">new line</span>"),
8383            "the marker is moved to the gutter, so the body is printed once without it"
8384        );
8385    }
8386
8387    #[test]
8388    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
8389        let total = DIFF_MAX_LINES + 100;
8390        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
8391        let html = approval_panel(
8392            &run_state(),
8393            &green_pr(),
8394            NUMSTAT,
8395            &diff,
8396            &[],
8397            "feat: something long",
8398        );
8399        assert!(
8400            html.contains(&format!("100 of {total} diff lines omitted")),
8401            "the note must say exactly how much was cut"
8402        );
8403        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
8404        assert!(
8405            !html.contains(&format!("line {DIFF_MAX_LINES}")),
8406            "nothing past the threshold is rendered"
8407        );
8408        assert!(
8409            html.contains("/repo/magi"),
8410            "the note says where the rest is"
8411        );
8412    }
8413
8414    #[test]
8415    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
8416        let html = approval_panel(
8417            &run_state(),
8418            &green_pr(),
8419            "1\t2\tsrc/<b>&\"x\"'.rs",
8420            "",
8421            &[],
8422            "subject",
8423        );
8424        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
8425        assert!(
8426            !html.contains("<b>"),
8427            "an agent-influenced path must never become markup"
8428        );
8429    }
8430
8431    #[tokio::test]
8432    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
8433        let a = std::path::PathBuf::from("/repo/a");
8434        let b = std::path::PathBuf::from("/repo/b");
8435
8436        let held = repo_merge_lock(&a).lock_owned().await;
8437
8438        // A second, concurrent land run against the *same* repository must
8439        // wait - `try_lock` fails while `held` is alive.
8440        assert!(
8441            repo_merge_lock(&a).try_lock().is_err(),
8442            "a second merge into the same repository must not proceed concurrently"
8443        );
8444
8445        // A run against a *different* repository must not be blocked by it -
8446        // this is what keeps a slow rebase or `gh pr merge` in one
8447        // repository from also stalling a land-approval resume in another.
8448        assert!(
8449            repo_merge_lock(&b).try_lock().is_ok(),
8450            "a different repository's merge lock must be independent"
8451        );
8452
8453        drop(held);
8454        assert!(
8455            repo_merge_lock(&a).try_lock().is_ok(),
8456            "the lock is released once the holder is done"
8457        );
8458    }
8459
8460    pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
8461        let mut state = landing_state();
8462        let mut rec = review_record(1, "alpha", "precise rejection reason");
8463        rec.vote = Some(if reject {
8464            crate::verdict::ReviewVote::Reject
8465        } else {
8466            crate::verdict::ReviewVote::Approve
8467        });
8468        if major {
8469            rec.findings = contested().findings;
8470        }
8471        let mut round = review_round(1, vec![rec]);
8472        round.clean = false;
8473        round.blocking = usize::from(major);
8474        state.reviews.push(round);
8475        state.config.graph.review_rounds = 1;
8476        state
8477    }
8478
8479    #[test]
8480    fn approval_policy_controls_are_independent_and_keep_all_reasons() {
8481        for every in [false, true] {
8482            for contested in [false, true] {
8483                for major in [false, true] {
8484                    for reject in [false, true] {
8485                        let mut state = repair_state(major, reject);
8486                        state.config.graph.land_approval = every;
8487                        state.config.graph.hold_contested_merge = contested;
8488                        let reasons = approval_reasons(&state);
8489                        assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
8490                        assert_eq!(
8491                            reasons.contains(&ApprovalReason::OpenBlockingFindings),
8492                            contested && major
8493                        );
8494                        assert_eq!(
8495                            reasons.contains(&ApprovalReason::FinalReject),
8496                            contested && reject
8497                        );
8498                        assert_eq!(
8499                            reasons.len(),
8500                            usize::from(every)
8501                                + usize::from(contested && major)
8502                                + usize::from(contested && reject)
8503                        );
8504                    }
8505                }
8506            }
8507        }
8508    }
8509
8510    #[test]
8511    fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8512        use crate::verdict::ReviewVote::{Approve, Reject};
8513        for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8514            let mut state = repair_state(false, initial_reject);
8515            state.reviews[0]
8516                .reconsideration
8517                .push(crate::run::ReviewRevoteRecord {
8518                    reviewer: 1,
8519                    agent: "alpha".into(),
8520                    vote: Some(final_vote),
8521                    reason: "changed after reading the panel <evidence>".into(),
8522                    failed: None,
8523                });
8524            assert_eq!(
8525                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8526                final_vote == Reject
8527            );
8528            for language in ["en", "ja"] {
8529                state.config.graph.language = language.into();
8530                let evidence = approval_evidence(&state);
8531                assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8532                assert!(evidence.contains("changed after reading the panel <evidence>"));
8533                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8534                assert!(html.contains("changed after reading the panel &lt;evidence&gt;"));
8535                assert!(!html.contains("<evidence>"));
8536            }
8537            state.reviews[0].reconsideration[0].vote = None;
8538            state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8539            assert_eq!(
8540                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8541                initial_reject
8542            );
8543            assert!(approval_evidence(&state).contains("timed out"));
8544        }
8545    }
8546
8547    #[tokio::test]
8548    async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8549        for every in [false, true] {
8550            for contested in [false, true] {
8551                for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8552                {
8553                    let mut state = repair_state(major, reject);
8554                    state.config.graph.land_approval = every;
8555                    state.config.graph.hold_contested_merge = contested;
8556                    let forge = Scripted::new(
8557                        vec![seen("abc", Checks::Green, "CLEAN", false)],
8558                        vec![(true, "")],
8559                    );
8560                    land_with(&mut state, URL, &forge).await.unwrap();
8561                    let expected = every || (contested && (major || reject));
8562                    assert_eq!(state.land_approval.is_some(), expected);
8563                    assert_eq!(forge.calls().contains(&"merge"), !expected);
8564                    if expected {
8565                        assert!(state.parked);
8566                    }
8567                }
8568            }
8569        }
8570    }
8571
8572    #[tokio::test]
8573    async fn reviewed_branch_hold_is_read_before_landing_decision() {
8574        for (base, control) in [
8575            (false, "land_approval"),
8576            (true, "land_approval"),
8577            (false, "hold_contested_merge"),
8578        ] {
8579            let (_tmp, mut state, _) = landing_git_fixture().await;
8580            state.config.graph.land_approval = base;
8581            state.config.graph.hold_contested_merge = false;
8582            state.reviewed_commits = Some(Vec::new());
8583            if control == "hold_contested_merge" {
8584                state.reviews = repair_state(false, true).reviews;
8585            }
8586            std::fs::write(
8587                state.repo.join("magi.toml"),
8588                format!("[graph]\n{control} = {}\n", !base),
8589            )
8590            .unwrap();
8591            git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8592            git::git(&state.repo, &["commit", "-m", "approval policy"])
8593                .await
8594                .unwrap();
8595            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8596            state.land_sync_pending = false;
8597            state.pr_pushed_head = Some(head.clone());
8598            state.gate_head = Some(head.clone());
8599            let forge = Scripted::new(
8600                vec![seen(&head, Checks::Green, "CLEAN", false)],
8601                vec![(true, "")],
8602            );
8603            land_with(&mut state, URL, &forge).await.unwrap();
8604            assert!(
8605                state.land_approval.is_some(),
8606                "base={base}, control={control}"
8607            );
8608            assert!(!forge.calls().contains(&"merge"));
8609            if !base {
8610                let question = ask::Questions::open()
8611                    .get(&state.land_approval.as_ref().unwrap().question)
8612                    .unwrap();
8613                assert!(question.detail.contains(&format!(
8614                    "{control}=true from the reviewed branch's magi.toml"
8615                )));
8616            }
8617        }
8618    }
8619
8620    #[tokio::test]
8621    async fn reviewed_branch_hold_asks_and_survives_removal_and_resume() {
8622        let (_tmp, mut state, _) = landing_git_fixture().await;
8623        state.config.graph.land_approval = false;
8624        state.config.graph.hold_contested_merge = false;
8625        state.reviewed_commits = Some(Vec::new());
8626        std::fs::write(
8627            state.repo.join("magi.toml"),
8628            "[graph]\nland_approval = true\n[verify]\ngate = ['untrusted']\n",
8629        )
8630        .unwrap();
8631        git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8632        git::git(&state.repo, &["commit", "-m", "require approval"])
8633            .await
8634            .unwrap();
8635        let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8636        let gate = state.config.verify.gate.clone();
8637        state.observe_branch_owner_holds(&head).await;
8638        assert_eq!(state.config.verify.gate, gate);
8639        assert_eq!(approval_reasons(&state), [ApprovalReason::EveryMerge]);
8640        git::git(&state.repo, &["rm", "magi.toml"]).await.unwrap();
8641        git::git(&state.repo, &["commit", "-m", "remove config"])
8642            .await
8643            .unwrap();
8644        let later = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8645        state = serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
8646        state.observe_branch_owner_holds(&later).await;
8647        assert_eq!(state.branch_owner_holds[0].head, head);
8648        state.land_sync_pending = false;
8649        state.pr_pushed_head = Some(later.clone());
8650        state.gate_head = Some(later.clone());
8651        let forge = Scripted::new(
8652            vec![seen(&later, Checks::Green, "CLEAN", false)],
8653            vec![(true, "")],
8654        );
8655        land_with(&mut state, URL, &forge).await.unwrap();
8656        assert!(!forge.calls().contains(&"merge"));
8657        let question = ask::Questions::open()
8658            .get(&state.land_approval.as_ref().unwrap().question)
8659            .unwrap();
8660        assert!(question.detail.contains("Approval required because:"));
8661        assert!(
8662            question
8663                .detail
8664                .contains("land_approval=true from the reviewed branch's magi.toml")
8665        );
8666    }
8667
8668    #[tokio::test]
8669    async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8670        let mut state = repair_state(false, true);
8671        state.reviews[0].clean = true;
8672        let mut finding = contested().findings.remove(0);
8673        finding.severity = crate::verdict::Severity::Minor;
8674        state.reviews[0].reviews[0].findings.push(finding);
8675        assert!(state.open_findings().is_empty());
8676        let mut pr = green_pr();
8677        pr.review_comments.clear();
8678        for language in ["en", "ja"] {
8679            state.config.graph.language = language.into();
8680            let html = approval_panel(&state, &pr, "", "", &[], "subject");
8681            assert!(html.contains("[R3-1-1] minor"));
8682            assert!(html.contains(if language == "ja" {
8683                "内部指摘の解決を意味するものではありません"
8684            } else {
8685                "This does not mean internal findings are resolved"
8686            }));
8687            assert!(
8688                html.find(if language == "ja" {
8689                    "承認が必要な理由:"
8690                } else {
8691                    "Approval required because:"
8692                })
8693                .unwrap()
8694                    < html.find("[R3-1-1]").unwrap()
8695            );
8696        }
8697        state.config.graph.language = "en".into();
8698        let observed = seen("abc", Checks::Green, "CLEAN", false);
8699        assert_eq!(
8700            approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8701                .await
8702                .unwrap(),
8703            ApprovalGate::Pending
8704        );
8705        let id = state.land_approval.as_ref().unwrap().question.clone();
8706        let q = ask::Questions::open().get(&id).unwrap();
8707        assert!(q.detail.starts_with("Approval required because:"));
8708        assert!(q.detail.contains("final reviewer reject"));
8709        assert!(q.detail.contains("[R3-1-1] minor"));
8710        assert!(
8711            q.detail
8712                .contains("No adoption or resolution evidence recorded")
8713        );
8714        state.config.graph.land_approval = false;
8715        state.config.graph.hold_contested_merge = false;
8716        state.reviews.clear();
8717        assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8718        assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8719        assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8720        let forge = Scripted::new(vec![observed], vec![]);
8721        land_with(&mut state, URL, &forge).await.unwrap();
8722        assert!(!forge.calls().contains(&"merge"));
8723        assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8724        assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8725    }
8726
8727    #[tokio::test]
8728    async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8729        let mut state = repair_state(false, true);
8730        state.config.graph.land_approval = false;
8731        let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8732        let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8733        let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8734        state.reviews[0].reviews[0].summary = long_reason.clone();
8735        for i in 1..=10 {
8736            let mut finding = contested().findings.remove(0);
8737            finding.id = format!("R1-1-{i}");
8738            finding.severity = if i == 9 {
8739                crate::verdict::Severity::Major
8740            } else {
8741                crate::verdict::Severity::Minor
8742            };
8743            finding.title = long_title.clone();
8744            finding.detail = long_detail.clone();
8745            state.reviews[0].reviews[0].findings.push(finding);
8746        }
8747        state.reviews[0].fix = Some(crate::run::FixRecord {
8748            agent: "fixer".into(),
8749            addressed: vec!["R1-1-9".into()],
8750            rejected: vec![crate::verdict::Rejection {
8751                id: "R1-1-10".into(),
8752                why: long_reason.clone(),
8753            }],
8754            notes: long_detail.clone(),
8755            committed: false,
8756            failed: None,
8757            duration_ms: 0,
8758            continuation: None,
8759        });
8760        assert_eq!(
8761            approval_reasons(&state),
8762            [
8763                ApprovalReason::OpenBlockingFindings,
8764                ApprovalReason::FinalReject
8765            ]
8766        );
8767        let excerpt = approval_excerpt(&long_reason, 300);
8768        assert_eq!(excerpt.chars().count(), 300);
8769        assert!(excerpt.ends_with('…'));
8770        let boundary = "拒".repeat(300);
8771        assert_eq!(approval_excerpt(&boundary, 300), boundary);
8772        for language in ["en", "ja"] {
8773            state.config.graph.language = language.into();
8774            for revote in [
8775                None,
8776                Some(crate::verdict::ReviewVote::Reject),
8777                Some(crate::verdict::ReviewVote::Approve),
8778            ] {
8779                state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8780                state.reviews[0].reconsideration.clear();
8781                if let Some(vote) = revote {
8782                    state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8783                    state.reviews[0]
8784                        .reconsideration
8785                        .push(crate::run::ReviewRevoteRecord {
8786                            reviewer: 1,
8787                            agent: "alpha".into(),
8788                            vote: Some(vote),
8789                            reason: long_reason.clone(),
8790                            failed: None,
8791                        });
8792                }
8793                let evidence = approval_evidence(&state);
8794                let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8795                assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8796                assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8797                assert_eq!(
8798                    approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8799                    final_vote == crate::verdict::ReviewVote::Reject
8800                );
8801                assert!(evidence.contains(&excerpt));
8802                assert!(evidence.contains("[R1-1-8] minor"));
8803                assert!(evidence.contains("[R1-1-9] major"));
8804                assert!(evidence.contains("[R1-1-10] minor"));
8805                for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8806                    assert!(!evidence.contains(tail));
8807                }
8808                assert!(evidence.contains(if language == "ja" {
8809                    "採用申告(解決未確認)"
8810                } else {
8811                    "claimed addressed (resolution unconfirmed)"
8812                }));
8813                assert!(evidence.contains(if language == "ja" {
8814                    "不採用理由"
8815                } else {
8816                    "declined"
8817                }));
8818                let major_evidence = evidence
8819                    .split("[R1-1-9] major")
8820                    .nth(1)
8821                    .unwrap()
8822                    .split("[R1-1-10]")
8823                    .next()
8824                    .unwrap();
8825                assert!(major_evidence.contains(if language == "ja" {
8826                    "採用申告(解決未確認)"
8827                } else {
8828                    "claimed addressed (resolution unconfirmed)"
8829                }));
8830                let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8831                assert!(last_evidence.contains(if language == "ja" {
8832                    "不採用理由"
8833                } else {
8834                    "declined"
8835                }));
8836                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8837                assert!(html.contains(&esc(&evidence)));
8838                assert_eq!(
8839                    approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8840                        .await
8841                        .unwrap(),
8842                    ApprovalGate::Pending
8843                );
8844                let id = &state.land_approval.take().unwrap().question;
8845                let q = ask::Questions::open().get(id).unwrap();
8846                assert!(q.detail.starts_with(&evidence));
8847            }
8848            state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8849        }
8850    }
8851
8852    #[test]
8853    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8854        for (major, reject) in [(true, false), (false, true), (true, true)] {
8855            let state = repair_state(major, reject);
8856            assert!(repair_available(&state));
8857            assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8858        }
8859        assert!(!repair_available(&repair_state(false, false)));
8860        let mut clean = repair_state(true, false);
8861        clean.reviews[0].clean = true;
8862        assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8863        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8864    }
8865
8866    #[tokio::test]
8867    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8868        let mut state = repair_state(true, false);
8869        let pr = green_pr();
8870        approval_gate(&mut state, &pr, "feat: x", None, "abc")
8871            .await
8872            .unwrap();
8873        let store = ask::Questions::open();
8874        let id = state.land_approval.as_ref().unwrap().question.clone();
8875        // Simulate an unanswered two-choice question created by an older build.
8876        store
8877            .update(&id, |q| {
8878                q.choices = vec![APPROVE.into(), HOLD.into()];
8879                Ok(())
8880            })
8881            .unwrap();
8882        let upgraded = store.get(&id).unwrap();
8883        assert_eq!(upgraded.choices, approval_choices(&state));
8884        assert!(!upgraded.actions.contains_key(HOLD));
8885        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
8886        assert!(
8887            store
8888                .list()
8889                .iter()
8890                .any(|q| q.id == id && q.choices == upgraded.choices)
8891        );
8892        assert!(
8893            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
8894        );
8895        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
8896        store
8897            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
8898            .unwrap();
8899        // An earlier build may have recorded the repair answer and stopped
8900        // before any driver consumed it. Reconcile that durable decision too.
8901        store
8902            .update(&id, |q| {
8903                q.actions.clear();
8904                Ok(())
8905            })
8906            .unwrap();
8907        assert_eq!(
8908            store.get(&id).unwrap().chosen_action(),
8909            Some(&ask::ChoiceAction::Resume {
8910                run: state.id.clone()
8911            })
8912        );
8913        let binding = state.land_approval.clone();
8914        assert_eq!(
8915            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8916                .await
8917                .unwrap(),
8918            ApprovalGate::Repair
8919        );
8920        assert!(state.land_approval.is_none());
8921        assert_eq!(state.merge_repairs.len(), 1);
8922        let request = &state.merge_repairs[0];
8923        assert_eq!(request.question, id);
8924        assert_eq!(request.head, "abc");
8925        assert_eq!(request.pr, pr.url);
8926        assert_eq!(
8927            request.reviews[0].reviews[0].summary,
8928            "precise rejection reason"
8929        );
8930        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
8931        assert_eq!(
8932            request.reviews[0].reviews[0].findings[0].detail,
8933            state.reviews[0].reviews[0].findings[0].detail
8934        );
8935        // A repeated consumption of the same answer never dispatches another repair.
8936        state.land_approval = binding;
8937        assert_eq!(
8938            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8939                .await
8940                .unwrap(),
8941            ApprovalGate::Repair
8942        );
8943        assert_eq!(state.merge_repairs.len(), 1);
8944        state.land_approval = None;
8945        state.config.graph.land_approval = false;
8946        state.config.graph.hold_contested_merge = false;
8947        state.reviews.clear();
8948        assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
8949        assert_eq!(
8950            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8951                .await
8952                .unwrap(),
8953            ApprovalGate::Pending
8954        );
8955        let fresh_id = &state.land_approval.as_ref().unwrap().question;
8956        assert_ne!(fresh_id, &id);
8957        let fresh = store.get(fresh_id).unwrap();
8958        assert!(
8959            fresh
8960                .detail
8961                .contains("fresh owner approval after merge repair")
8962        );
8963        assert!(fresh.detail.contains("No review recorded"));
8964        assert!(!fresh.detail.contains("precise rejection reason"));
8965    }
8966
8967    #[tokio::test]
8968    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
8969        for choice in [REPAIR, HOLD] {
8970            let mut state = repair_state(false, true);
8971            let observed = seen("abc", Checks::Green, "CLEAN", false);
8972            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
8973                .await
8974                .unwrap();
8975            let id = state.land_approval.as_ref().unwrap().question.clone();
8976            ask::Questions::open()
8977                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
8978                .unwrap();
8979            let forge = Scripted::new(vec![observed], vec![(true, "")]);
8980            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8981                .await
8982                .unwrap();
8983            assert!(
8984                forge
8985                    .argvs()
8986                    .iter()
8987                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
8988            );
8989            assert!(!forge.calls().contains(&"fix"));
8990            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
8991            assert_ne!(state.status, RunStatus::Merged);
8992        }
8993    }
8994
8995    #[test]
8996    fn only_the_merge_choice_merges_and_silence_holds() {
8997        let table = [
8998            (None, Approval::Hold),
8999            (Some("merge"), Approval::Merge),
9000            (Some(" merge\n"), Approval::Merge),
9001            (Some("hold"), Approval::Hold),
9002            (Some(""), Approval::Hold),
9003            (Some("yes"), Approval::Hold),
9004        ];
9005        for (answer, want) in table {
9006            assert_eq!(
9007                approval(answer),
9008                want,
9009                "answer {answer:?} must resolve to {want:?}"
9010            );
9011        }
9012    }
9013
9014    #[tokio::test]
9015    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
9016        let mut state = landing_state();
9017        state.config.graph.land_approval = true;
9018        let pr = green_pr();
9019
9020        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
9021            .await
9022            .unwrap();
9023        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
9024        assert!(
9025            !state.parked,
9026            "approval_gate itself never sets `parked`; only its caller does"
9027        );
9028
9029        let store = ask::Questions::open();
9030        let filed: Vec<_> = store
9031            .list()
9032            .into_iter()
9033            .filter(|q| q.run == state.id)
9034            .collect();
9035        assert_eq!(filed.len(), 1, "exactly one question is filed");
9036        assert_eq!(filed[0].node, APPROVAL_NODE);
9037        assert_eq!(filed[0].choices, approval_choices(&state));
9038        assert!(filed[0].status.open());
9039
9040        // A second visit - standing in for a resumed run whose slot the
9041        // daemon handed to something else while nobody had answered - must
9042        // find the same question rather than filing a second one.
9043        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
9044            .await
9045            .unwrap();
9046        assert_eq!(again, ApprovalGate::Pending);
9047        let still_one = store
9048            .list()
9049            .into_iter()
9050            .filter(|q| q.run == state.id)
9051            .count();
9052        assert_eq!(
9053            still_one, 1,
9054            "asking twice must not double-file the question"
9055        );
9056    }
9057
9058    #[tokio::test]
9059    async fn approving_the_existing_question_is_read_back_as_approved() {
9060        crate::run::pin_test_home();
9061        let mut state = run_state();
9062        state.config.graph.land_approval = true;
9063        let pr = green_pr();
9064        assert_eq!(
9065            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9066                .await
9067                .unwrap(),
9068            ApprovalGate::Pending
9069        );
9070
9071        let store = ask::Questions::open();
9072        let mut q = store
9073            .list()
9074            .into_iter()
9075            .find(|q| q.run == state.id)
9076            .expect("filed above");
9077        let talks = crate::talk::Talks::open();
9078        let cfg = crate::config::Config {
9079            agents: vec![crate::config::AgentSpec {
9080                id: "mock".into(),
9081                kind: crate::config::AgentKind::Command,
9082                model: None,
9083                command: vec!["true".into()],
9084                extra_args: Vec::new(),
9085                env: Default::default(),
9086                prompt_delivery: None,
9087                max_turn_secs: None,
9088            }],
9089            ..crate::config::Config::default()
9090        };
9091        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
9092        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
9093        crate::talk::drain(&mut talk, &talks).unwrap();
9094        assert_eq!(
9095            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9096                .await
9097                .unwrap(),
9098            ApprovalGate::Pending,
9099            "consultation cannot approve or hold the merge"
9100        );
9101        q = store.get(&q.id).unwrap();
9102        assert!(q.answer.is_none());
9103        talk.turns.push(crate::talk::Turn {
9104            sender: None,
9105            breaks: Some(Vec::new()),
9106            messages: Vec::new(),
9107            posted_by: None,
9108            who: crate::talk::Who::Operator,
9109            body: "merge abc".into(),
9110            at: Timestamp::now(),
9111            attachments: Vec::new(),
9112            usage: None,
9113        });
9114        talks.put(&mut talk).unwrap();
9115        crate::consult::validate_answer(
9116            &q,
9117            &talks,
9118            &talk.id,
9119            crate::queue::CHAT_NODE,
9120            APPROVE,
9121            Some("merge abc"),
9122        )
9123        .unwrap();
9124        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9125        store.put(&mut q).unwrap();
9126
9127        assert_eq!(
9128            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9129                .await
9130                .unwrap(),
9131            ApprovalGate::Approved
9132        );
9133    }
9134
9135    #[tokio::test]
9136    async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
9137        crate::run::pin_test_home();
9138        let store = ask::Questions::open();
9139        for via_list in [false, true] {
9140            let mut state = repair_state(false, true);
9141            let pr = green_pr();
9142            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9143                .await
9144                .unwrap();
9145            let id = state.land_approval.as_ref().unwrap().question.clone();
9146            store
9147                .update(&id, |q| {
9148                    q.actions.insert(
9149                        HOLD.to_owned(),
9150                        ask::ChoiceAction::Resume {
9151                            run: state.id.clone(),
9152                        },
9153                    );
9154                    Ok(())
9155                })
9156                .unwrap();
9157            let q = if via_list {
9158                store.list().into_iter().find(|q| q.id == id).unwrap()
9159            } else {
9160                store.get(&id).unwrap()
9161            };
9162            assert!(!q.actions.contains_key(HOLD));
9163            store
9164                .update(&id, |q| {
9165                    // Check the persisted migration, without another read upgrade.
9166                    assert!(!q.actions.contains_key(HOLD));
9167                    assert!(!upgrade_repair_choice(q, &state));
9168                    q.answer(ask::Answer::Choice(HOLD.to_owned()))
9169                })
9170                .unwrap();
9171            assert!(store.get(&id).unwrap().chosen_action().is_none());
9172            assert_eq!(
9173                approval_gate(&mut state, &pr, "feat: x", None, "abc")
9174                    .await
9175                    .unwrap(),
9176                ApprovalGate::Held
9177            );
9178        }
9179    }
9180
9181    #[tokio::test]
9182    async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
9183        crate::run::pin_test_home();
9184        let store = ask::Questions::open();
9185
9186        let mut held_state = run_state();
9187        held_state.config.graph.land_approval = true;
9188        let pr = green_pr();
9189        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9190            .await
9191            .unwrap();
9192        let mut q = store
9193            .list()
9194            .into_iter()
9195            .find(|q| q.run == held_state.id)
9196            .expect("filed above");
9197        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
9198        store.put(&mut q).unwrap();
9199        assert_eq!(
9200            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9201                .await
9202                .unwrap(),
9203            ApprovalGate::Held
9204        );
9205
9206        let mut abandoned_state = run_state();
9207        abandoned_state.config.graph.land_approval = true;
9208        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9209            .await
9210            .unwrap();
9211        let mut q = store
9212            .list()
9213            .into_iter()
9214            .find(|q| q.run == abandoned_state.id)
9215            .expect("filed above");
9216        q.abandon("no answer within the timeout");
9217        store.put(&mut q).unwrap();
9218        assert_eq!(
9219            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9220                .await
9221                .unwrap(),
9222            ApprovalGate::Pending,
9223            "an expired approval must ask again without merging"
9224        );
9225        assert_eq!(
9226            store.get(&q.id).unwrap().status,
9227            ask::QuestionStatus::Abandoned
9228        );
9229        assert_ne!(
9230            abandoned_state.land_approval.as_ref().unwrap().question,
9231            q.id
9232        );
9233    }
9234
9235    fn contested() -> ContestedHandoff {
9236        let finding = |id: &str, n: u32| crate::verdict::Finding {
9237            id: id.to_owned(),
9238            severity: crate::verdict::Severity::Major,
9239            file: Some("src/a.rs".to_owned()),
9240            line: Some(n),
9241            title: format!("problem {id}"),
9242            detail: String::new(),
9243        };
9244        ContestedHandoff {
9245            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
9246            rejecters: vec![(1, "alpha".to_owned())],
9247        }
9248    }
9249
9250    #[test]
9251    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
9252        let mut state = run_state();
9253        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
9254        state.contested_handoff = Some(contested());
9255        assert!(contested_to_ask(&state).is_some());
9256        state.config.graph.hold_contested_merge = false;
9257        assert!(
9258            contested_to_ask(&state).is_none(),
9259            "the switch restores today"
9260        );
9261    }
9262
9263    #[test]
9264    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
9265        let q = ask::Question::new(
9266            "run-1".to_owned(),
9267            APPROVAL_NODE.to_owned(),
9268            "land".to_owned(),
9269            "Merge?".to_owned(),
9270            String::new(),
9271            vec![APPROVE.to_owned(), HOLD.to_owned()],
9272        );
9273        let none = deputy_brief(&q, None);
9274        assert!(none.contains("could not be read"), "{none}");
9275        assert!(none.contains("Silence is a hold"), "{none}");
9276
9277        let mut state = run_state();
9278        state.pr = Some(crate::run::PrRecord {
9279            url: "https://example.test/pull/7".to_owned(),
9280            number: 7,
9281            state: "open".to_owned(),
9282            checks: "green".to_owned(),
9283            round: 0,
9284            rounds: 3,
9285            red_at_merge: Vec::new(),
9286        });
9287        state.contested_handoff = Some(contested());
9288        let b = deputy_brief(&q, Some(&state));
9289        assert!(b.contains("https://example.test/pull/7"), "{b}");
9290        assert!(b.contains("not recorded in this legacy question"), "{b}");
9291        assert!(!b.contains("This question was filed although"));
9292        state.contested_handoff = None;
9293        assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
9294    }
9295
9296    #[test]
9297    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
9298        for lang in ["en", "ja"] {
9299            let mut cfg = crate::config::Config::default();
9300            cfg.graph.language = lang.to_owned();
9301            let w = words(&cfg.graph.language);
9302            let text = w.approval_detail(
9303                "https://github.com/yukimemi/magi/pull/42",
9304                "main",
9305                "feat: x",
9306                Some(&contested()),
9307            );
9308            assert!(text.contains("pull/42"), "{text}");
9309            assert!(
9310                text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
9311                "{text}"
9312            );
9313            assert!(text.contains("R3-1-5"), "{text}");
9314            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
9315            assert!(text.contains("2"), "the rest are counted: {text}");
9316            assert!(text.contains("#1 (alpha)"), "{text}");
9317        }
9318        let plain = words("en").approval_detail("u", "main", "s", None);
9319        assert!(!plain.contains("reject"), "{plain}");
9320    }
9321
9322    #[tokio::test]
9323    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
9324        crate::run::pin_test_home();
9325        let mut state = run_state();
9326        state.config.graph.land_approval = false;
9327        state.contested_handoff = Some(contested());
9328        let pr = green_pr();
9329        let c = contested_to_ask(&state);
9330        assert_eq!(
9331            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9332                .await
9333                .unwrap(),
9334            ApprovalGate::Pending,
9335            "silence is a hold"
9336        );
9337        let store = ask::Questions::open();
9338        let filed: Vec<_> = store
9339            .list()
9340            .into_iter()
9341            .filter(|q| q.run == state.id)
9342            .collect();
9343        assert_eq!(filed.len(), 1);
9344        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
9345
9346        assert_eq!(
9347            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9348                .await
9349                .unwrap(),
9350            ApprovalGate::Pending
9351        );
9352        let mut q = store
9353            .list()
9354            .into_iter()
9355            .find(|q| q.run == state.id)
9356            .unwrap();
9357        assert_eq!(q.id, filed[0].id, "the same question after a resume");
9358        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9359        store.put(&mut q).unwrap();
9360        assert_eq!(
9361            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9362                .await
9363                .unwrap(),
9364            ApprovalGate::Approved
9365        );
9366    }
9367
9368    #[test]
9369    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
9370        let rows = parse_numstat(NUMSTAT);
9371        assert_eq!(
9372            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
9373            ["src/web.rs", "src/land.rs", "assets/logo.png"]
9374        );
9375        assert_eq!(rows[2].added, None, "a binary file has no line counts");
9376    }
9377    #[test]
9378    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
9379        // Reported from a real run: the merge question arrived in English on a
9380        // repository with `language = "ja"`. magi's own strings have to follow
9381        // that setting too - "it is a literal in Rust" is not an answer.
9382        let mut state = run_state();
9383        state.config.graph.language = "ja".to_owned();
9384        let pr = green_pr();
9385        let commits = ["c1".to_owned()];
9386
9387        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9388        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
9389        assert!(ja.contains("squash されるコミット"), "{ja}");
9390        assert!(ja.contains("レビューコメント"), "{ja}");
9391        assert!(ja.contains("差分"), "{ja}");
9392        assert!(
9393            !ja.contains("Commits being squashed"),
9394            "no English left over"
9395        );
9396
9397        let w = words("ja");
9398        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
9399        assert!(
9400            w.approval_detail("http://x/1", "main", "feat: x", None)
9401                .contains("パネル")
9402        );
9403
9404        // The evidence itself is language-neutral and must survive either way.
9405        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
9406        assert!(ja.contains("feat: x"), "nor is the merge subject");
9407
9408        // English stays the default, and a language magi cannot check falls
9409        // back to it rather than shipping a guess.
9410        state.config.graph.language = "en".to_owned();
9411        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9412        assert!(en.contains("Commits being squashed"), "{en}");
9413        assert_eq!(words("Klingon").html_lang, "en");
9414    }
9415
9416    /// A `gh pr list` result naming exactly one pull request whose base and
9417    /// merge time both fit the run is exactly the case
9418    /// [`find_external_merge`] exists to act on.
9419    #[test]
9420    fn pick_open_pr_classifies_by_count_and_base() {
9421        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
9422        assert_eq!(
9423            pick_open_pr(one, "main").unwrap(),
9424            OpenPr::One {
9425                url: "https://x/pull/58".into(),
9426                title: "t".into()
9427            }
9428        );
9429        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
9430        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
9431        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
9432                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
9433        assert_eq!(
9434            pick_open_pr(two, "main").unwrap(),
9435            OpenPr::Many(vec!["u1".into(), "u2".into()])
9436        );
9437        // A fork's same-named branch is not a pull request for this head.
9438        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
9439        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
9440        assert!(pick_open_pr("not json", "main").is_err());
9441        // An incomplete record is an error, never "nothing open".
9442        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
9443        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
9444    }
9445
9446    #[test]
9447    fn pick_merged_pr_picks_the_unique_match() {
9448        let json = r#"[
9449            {"url": "https://github.com/o/r/pull/42", "number": 42,
9450             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
9451        ]"#;
9452        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9453        let found = pick_merged_pr(json, "main", created_at)
9454            .expect("valid json")
9455            .expect("one unambiguous match");
9456        assert_eq!(found.url, "https://github.com/o/r/pull/42");
9457        assert_eq!(found.number, 42);
9458    }
9459
9460    /// Two candidates surviving the filter is exactly as uninformative as
9461    /// zero — a branch name can be reused across runs — so neither is
9462    /// preferred over the other and nothing is recorded automatically.
9463    #[test]
9464    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
9465        let json = r#"[
9466            {"url": "https://github.com/o/r/pull/42", "number": 42,
9467             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
9468            {"url": "https://github.com/o/r/pull/43", "number": 43,
9469             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
9470        ]"#;
9471        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9472        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9473    }
9474
9475    /// A pull request that targets a different base branch cannot be this
9476    /// run's, whatever its head branch is named — a reused branch name from
9477    /// an unrelated task must not be recorded as this run's merge.
9478    #[test]
9479    fn pick_merged_pr_ignores_a_different_base_branch() {
9480        let json = r#"[
9481            {"url": "https://github.com/o/r/pull/42", "number": 42,
9482             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
9483        ]"#;
9484        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9485        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9486    }
9487
9488    /// A pull request merged before this run was even created cannot be this
9489    /// run's winner, no matter how its head branch is spelled.
9490    #[test]
9491    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
9492        let json = r#"[
9493            {"url": "https://github.com/o/r/pull/42", "number": 42,
9494             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
9495        ]"#;
9496        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9497        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9498    }
9499
9500    #[test]
9501    fn slug_of_pr_url_reads_host_owner_and_repo() {
9502        assert_eq!(
9503            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
9504            Some("github.com/yukimemi/shun")
9505        );
9506    }
9507
9508    #[test]
9509    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
9510        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
9511        assert_eq!(slug_of_pr_url("not a url at all"), None);
9512        assert_eq!(slug_of_pr_url("https://github.com"), None);
9513    }
9514
9515    #[test]
9516    fn slug_of_repo_url_reads_host_owner_and_repo() {
9517        assert_eq!(
9518            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
9519            Some("github.com/yukimemi/magi")
9520        );
9521        assert_eq!(slug_of_repo_url("https://github.com"), None);
9522    }
9523
9524    #[test]
9525    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
9526        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
9527            .expect("same repo, different case");
9528    }
9529
9530    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
9531    /// own in-progress run and rewrote its status from a pull request in a
9532    /// completely different repository. This is the guard that must catch
9533    /// that even when an explicit (but wrong) id is given.
9534    #[test]
9535    fn ensure_same_repo_refuses_a_different_repo() {
9536        let err =
9537            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
9538        let msg = format!("{err:#}");
9539        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9540        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
9541    }
9542
9543    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
9544    /// instance mirroring a `github.com` repository's name, say) must not be
9545    /// treated as the same repository just because the trailing path
9546    /// matches.
9547    #[test]
9548    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
9549        let err = ensure_same_repo(
9550            "github.com/yukimemi/magi",
9551            "github.example.com/yukimemi/magi",
9552        )
9553        .unwrap_err();
9554        let msg = format!("{err:#}");
9555        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9556        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
9557    }
9558
9559    /// No winner decided yet means there is no branch to ask GitHub about at
9560    /// all — `find_external_merge` must return `None` without ever spawning
9561    /// `gh`, which this proves by never providing a real repository to spawn
9562    /// it in.
9563    #[tokio::test]
9564    async fn find_external_merge_returns_none_without_a_winner() {
9565        let state = RunState::new(
9566            PathBuf::from("/no/such/repo"),
9567            "main".to_owned(),
9568            "0000000000000000000000000000000000000000".to_owned(),
9569            "irrelevant".to_owned(),
9570            crate::config::Config::default(),
9571        );
9572        assert_eq!(find_external_merge(&state).await.unwrap(), None);
9573    }
9574
9575    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
9576        let mut run = RunState::new(
9577            PathBuf::from(repo),
9578            "main".to_owned(),
9579            "abcdef1234".to_owned(),
9580            "x".to_owned(),
9581            crate::config::Config::default(),
9582        );
9583        run.id = id.to_owned();
9584        run.status = status;
9585        run.driver_exited = true;
9586        run.pr = Some(crate::run::PrRecord {
9587            number: url.rsplit('/').next().unwrap().parse().unwrap(),
9588            url: url.to_owned(),
9589            state: state.to_owned(),
9590            checks: "red".to_owned(),
9591            round: 0,
9592            rounds: 2,
9593            red_at_merge: Vec::new(),
9594        });
9595        run.save_under(home).unwrap();
9596    }
9597
9598    fn recorded(home: &Path, id: &str) -> String {
9599        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9600        serde_json::from_str::<RunState>(&body)
9601            .unwrap()
9602            .pr
9603            .unwrap()
9604            .state
9605    }
9606
9607    const PR: &str = "https://github.com/o/r/pull/7";
9608
9609    #[test]
9610    fn write_through_updates_predecessors_and_siblings_only() {
9611        let tmp = tempfile::tempdir().unwrap();
9612        let h = tmp.path();
9613        pr_run(
9614            h,
9615            "20261004-100000-aaaa",
9616            "/repo/r",
9617            RunStatus::Superseded,
9618            PR,
9619            "open",
9620        );
9621        pr_run(
9622            h,
9623            "20261004-100100-bbbb",
9624            "/repo/r",
9625            RunStatus::Blocked,
9626            PR,
9627            "open",
9628        );
9629        // Not terminal: a driver may be writing it.
9630        pr_run(
9631            h,
9632            "20261004-100200-cccc",
9633            "/repo/r",
9634            RunStatus::Landing,
9635            PR,
9636            "open",
9637        );
9638        // Another repository's pull request with the same number.
9639        pr_run(
9640            h,
9641            "20261004-100300-dddd",
9642            "/repo/other",
9643            RunStatus::Blocked,
9644            "https://github.com/o/other/pull/7",
9645            "open",
9646        );
9647        // A different pull request of the same repository.
9648        pr_run(
9649            h,
9650            "20261004-100400-eeee",
9651            "/repo/r",
9652            RunStatus::Blocked,
9653            "https://github.com/o/r/pull/8",
9654            "open",
9655        );
9656        pr_run(
9657            h,
9658            "20261004-100500-ffff",
9659            "/repo/r",
9660            RunStatus::Merged,
9661            PR,
9662            "open",
9663        );
9664        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9665
9666        assert_eq!(
9667            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9668            2
9669        );
9670        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9671        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9672        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9673        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9674        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9675        // The source's own record is the caller's to write.
9676        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9677        // Idempotent.
9678        assert_eq!(
9679            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9680            0
9681        );
9682        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9683        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9684    }
9685
9686    #[test]
9687    fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9688        let home = tempfile::tempdir().unwrap();
9689        let live_id = "20261004-100000-aaaa";
9690        let unknown_id = "20261004-100100-bbbb";
9691        for id in [live_id, unknown_id] {
9692            pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9693            let mut s = RunState::load_under(id, home.path()).unwrap();
9694            s.driver_exited = false;
9695            // Model legacy driver evidence, not a newly reserved unstarted run.
9696            s.driver_claimed = None;
9697            if id == live_id {
9698                s.driver_pid = Some(std::process::id());
9699                s.driver_started_at = crate::proc::process_started_at(std::process::id());
9700            }
9701            s.save_under(home.path()).unwrap();
9702            if id == unknown_id {
9703                assert_eq!(
9704                    s.liveness_under(home.path(), false),
9705                    crate::run::Liveness::Unknown
9706                );
9707            }
9708        }
9709        let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9710        assert_eq!(apply_pr_states(home.path(), &known), 0);
9711        for id in [live_id, unknown_id] {
9712            assert_eq!(recorded(home.path(), id), "open");
9713        }
9714    }
9715
9716    #[test]
9717    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9718        let tmp = tempfile::tempdir().unwrap();
9719        let h = tmp.path();
9720        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9721        pr_run(
9722            h,
9723            "20261004-100000-aaaa",
9724            "/repo/r",
9725            RunStatus::Superseded,
9726            &url(1),
9727            "open",
9728        );
9729        pr_run(
9730            h,
9731            "20261004-100100-bbbb",
9732            "/repo/r",
9733            RunStatus::Blocked,
9734            &url(2),
9735            "open",
9736        );
9737        pr_run(
9738            h,
9739            "20261004-100200-cccc",
9740            "/repo/r",
9741            RunStatus::Ready,
9742            &url(3),
9743            "open",
9744        );
9745        pr_run(
9746            h,
9747            "20261004-100300-dddd",
9748            "/repo/r",
9749            RunStatus::Ready,
9750            &url(4),
9751            "open",
9752        );
9753        pr_run(
9754            h,
9755            "20261004-100400-eeee",
9756            "/repo/r",
9757            RunStatus::Implementing,
9758            &url(1),
9759            "open",
9760        );
9761        assert_eq!(stale_open_prs(h).len(), 4);
9762
9763        let mut known = BTreeMap::new();
9764        known.insert(url(1), PrLifecycle::Merged);
9765        known.insert(url(2), PrLifecycle::Closed);
9766        known.insert(url(3), PrLifecycle::Open);
9767        // #4: the forge could not be read, so it has no answer.
9768        assert_eq!(apply_pr_states(h, &known), 2);
9769        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9770        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9771        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9772        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9773        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9774        assert_eq!(apply_pr_states(h, &known), 0);
9775    }
9776
9777    // --- the land loop against a scripted forge -------------------------
9778
9779    use std::collections::VecDeque;
9780    use std::sync::Mutex;
9781
9782    /// Answers views from a script (the last one repeats), merges from a
9783    /// queue, and records every call so a test can assert the order.
9784    struct Scripted {
9785        evidence: Mutex<Option<CiEvidence>>,
9786        views: Mutex<VecDeque<Seen>>,
9787        merges: Mutex<VecDeque<(bool, String)>>,
9788        fix: Mutex<Option<Fixed>>,
9789        log: Mutex<Vec<&'static str>>,
9790        argvs: Mutex<Vec<Vec<String>>>,
9791        required: Mutex<Option<BTreeSet<String>>>,
9792        /// Set once a merge answered ok: the forge then reports `merged`,
9793        /// unless `queued` says the merge only entered a queue.
9794        merged: Mutex<bool>,
9795        queued: Mutex<bool>,
9796        /// Views fail once a merge answered ok.
9797        unreadable_after_merge: Mutex<bool>,
9798        /// Workflow runs asked to rerun, and whether that call fails.
9799        reruns: Mutex<Vec<String>>,
9800        rerun_fails: Mutex<bool>,
9801        /// Unix seconds; every poll moves it on by `POLL`.
9802        clock: Mutex<i64>,
9803    }
9804
9805    impl Scripted {
9806        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9807            Self {
9808                evidence: Mutex::new(None),
9809                views: Mutex::new(views.into()),
9810                merges: Mutex::new(
9811                    merges
9812                        .into_iter()
9813                        .map(|(ok, m)| (ok, m.to_owned()))
9814                        .collect(),
9815                ),
9816                fix: Mutex::new(None),
9817                log: Mutex::new(Vec::new()),
9818                argvs: Mutex::new(Vec::new()),
9819                required: Mutex::new(None),
9820                merged: Mutex::new(false),
9821                queued: Mutex::new(false),
9822                unreadable_after_merge: Mutex::new(false),
9823                reruns: Mutex::new(Vec::new()),
9824                rerun_fails: Mutex::new(false),
9825                clock: Mutex::new(1_000),
9826            }
9827        }
9828        fn argvs(&self) -> Vec<Vec<String>> {
9829            self.argvs.lock().unwrap().clone()
9830        }
9831        fn calls(&self) -> Vec<&'static str> {
9832            self.log.lock().unwrap().clone()
9833        }
9834    }
9835
9836    impl Forge for Scripted {
9837        async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
9838            self.evidence.lock().unwrap().clone()
9839        }
9840        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9841            self.log.lock().unwrap().push("view");
9842            if *self.unreadable_after_merge.lock().unwrap()
9843                && !self.argvs.lock().unwrap().is_empty()
9844            {
9845                anyhow::bail!("forge unreachable");
9846            }
9847            let mut v = self.views.lock().unwrap();
9848            let mut seen = if v.len() > 1 {
9849                v.pop_front().unwrap()
9850            } else {
9851                v[0].clone()
9852            };
9853            if *self.merged.lock().unwrap() {
9854                seen.pr.state = PrLifecycle::Merged;
9855            }
9856            Ok(seen)
9857        }
9858        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9859            self.log.lock().unwrap().push("merge");
9860            self.argvs.lock().unwrap().push(argv.to_vec());
9861            let out = self
9862                .merges
9863                .lock()
9864                .unwrap()
9865                .pop_front()
9866                .expect("unscripted merge");
9867            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9868            {
9869                *self.merged.lock().unwrap() = true;
9870            }
9871            Ok(out)
9872        }
9873        async fn poll(&self) {
9874            self.log.lock().unwrap().push("poll");
9875            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9876        }
9877        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9878            self.log.lock().unwrap().push("rerun");
9879            self.reruns.lock().unwrap().push(run.to_owned());
9880            if *self.rerun_fails.lock().unwrap() {
9881                anyhow::bail!("gh run rerun: no");
9882            }
9883            Ok(())
9884        }
9885        fn now(&self) -> i64 {
9886            *self.clock.lock().unwrap()
9887        }
9888        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9889            self.required.lock().unwrap().clone()
9890        }
9891        async fn fix(
9892            &self,
9893            _state: &mut RunState,
9894            _pr: &PrState,
9895            _round: usize,
9896            _budget: usize,
9897            _reason: &str,
9898            _logs: &str,
9899        ) -> Result<Fixed> {
9900            self.log.lock().unwrap().push("fix");
9901            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
9902        }
9903    }
9904
9905    const REFUSED: &str =
9906        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
9907
9908    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
9909        let mut pr = green_pr();
9910        pr.checks = checks;
9911        pr.blocking = Blocking::of(merge_state);
9912        if !comments {
9913            pr.review_comments.clear();
9914        }
9915        Seen {
9916            pr,
9917            title: "feat: x".to_owned(),
9918            failing_urls: Vec::new(),
9919            head: head.to_owned(),
9920            rollup_head: head.to_owned(),
9921            merge_state: merge_state.to_owned(),
9922            contexts: Vec::new(),
9923            base: "main".to_owned(),
9924        }
9925    }
9926
9927    fn landing_state() -> RunState {
9928        crate::run::pin_test_home();
9929        let mut state = run_state();
9930        state.config.graph.land_approval = false;
9931        state
9932    }
9933
9934    #[test]
9935    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
9936        assert!(!awaiting_new_head(None, "aaa"));
9937        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
9938        assert!(awaiting_new_head(Some("abc123"), "def456"));
9939        assert!(awaiting_new_head(Some("abc123"), ""));
9940    }
9941
9942    #[test]
9943    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
9944        let open = |c, m: &str| seen("a", c, m, false);
9945        let table = [
9946            (None, false, Refused::Pending),
9947            (
9948                Some(open(Checks::Pending, "BLOCKED")),
9949                false,
9950                Refused::Pending,
9951            ),
9952            (
9953                Some(open(Checks::Unknown, "BLOCKED")),
9954                false,
9955                Refused::Pending,
9956            ),
9957            (
9958                Some(open(Checks::Green, "UNKNOWN")),
9959                false,
9960                Refused::Pending,
9961            ),
9962            (Some(open(Checks::Green, "")), false, Refused::Pending),
9963            (
9964                Some(open(Checks::Green, "BLOCKED")),
9965                false,
9966                Refused::Recheck,
9967            ),
9968            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
9969        ];
9970        for (after, rechecked, want) in table {
9971            assert_eq!(
9972                classify_refusal(after.as_ref(), rechecked, "a", CiExpectation::Expected),
9973                want
9974            );
9975        }
9976        let mut closed = open(Checks::Green, "CLEAN");
9977        closed.pr.state = PrLifecycle::Closed;
9978        assert_eq!(
9979            classify_refusal(Some(&closed), false, "a", CiExpectation::Expected),
9980            Refused::Final
9981        );
9982    }
9983
9984    #[tokio::test]
9985    async fn a_normal_landing_merges_on_the_first_look() {
9986        let mut state = landing_state();
9987        let forge = Scripted::new(
9988            vec![seen("a", Checks::Green, "CLEAN", false)],
9989            vec![(true, "")],
9990        );
9991        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9992            .await
9993            .unwrap();
9994        // One fresh read, then the head-bound merge.
9995        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9996        assert_eq!(state.status, RunStatus::Merged);
9997    }
9998
9999    #[tokio::test]
10000    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
10001        let mut state = landing_state();
10002        let forge = Scripted::new(
10003            vec![seen("a", Checks::Green, "CLEAN", false)],
10004            std::iter::repeat_n((true, ""), 100).collect(),
10005        );
10006        *forge.queued.lock().unwrap() = true;
10007        let task = async {
10008            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10009                .await
10010                .unwrap();
10011        };
10012        // Still open after the command succeeded: it keeps watching and
10013        // never records a merge (it stops at the wait ceiling instead).
10014        task.await;
10015        assert_ne!(state.status, RunStatus::Merged);
10016    }
10017
10018    #[tokio::test]
10019    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
10020        let mut state = landing_state();
10021        let forge = Scripted::new(
10022            vec![seen("a", Checks::Green, "CLEAN", false)],
10023            std::iter::repeat_n((true, ""), 100).collect(),
10024        );
10025        *forge.unreadable_after_merge.lock().unwrap() = true;
10026        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10027            .await
10028            .ok();
10029        assert_ne!(state.status, RunStatus::Merged);
10030    }
10031
10032    #[tokio::test]
10033    async fn a_task_public_head_is_awaited_on_land_entry() {
10034        let mut state = landing_state();
10035        state.pr_head = Some("magi/task-abcd-1234".into());
10036        state.pr_pushed_head = Some("new".into());
10037        let forge = Scripted::new(
10038            vec![
10039                seen("old", Checks::Green, "CLEAN", false),
10040                seen("new", Checks::Green, "CLEAN", false),
10041            ],
10042            vec![(true, "")],
10043        );
10044        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10045            .await
10046            .unwrap();
10047        assert_eq!(
10048            forge.calls(),
10049            ["view", "poll", "view", "view", "merge", "view"]
10050        );
10051        assert_eq!(state.status, RunStatus::Merged);
10052    }
10053
10054    #[tokio::test]
10055    async fn a_superseded_public_head_owner_cannot_reenter_land() {
10056        let mut state = landing_state();
10057        state.pr_taken_over_by = Some("next".into());
10058        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
10059        assert!(
10060            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10061                .await
10062                .is_err()
10063        );
10064        assert!(forge.calls().is_empty());
10065    }
10066
10067    #[tokio::test]
10068    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
10069        let mut state = landing_state();
10070        let forge = Scripted::new(
10071            vec![
10072                seen("old", Checks::Green, "CLEAN", true),
10073                // The forge has not moved to the new head yet: still green.
10074                seen("old", Checks::Green, "CLEAN", true),
10075                seen("new", Checks::Pending, "BLOCKED", true),
10076                seen("new", Checks::Green, "CLEAN", true),
10077            ],
10078            vec![(true, "")],
10079        );
10080        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10081            head: "NEW".to_owned(),
10082        });
10083        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10084            .await
10085            .unwrap();
10086        assert_eq!(
10087            forge.calls(),
10088            [
10089                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
10090                "view"
10091            ]
10092        );
10093        assert_eq!(state.status, RunStatus::Merged);
10094    }
10095
10096    #[tokio::test]
10097    async fn a_head_that_never_arrives_stops_naming_both_commits() {
10098        let mut state = landing_state();
10099        let forge = Scripted::new(
10100            vec![
10101                seen("old", Checks::Green, "CLEAN", true),
10102                seen("someone-elses", Checks::Green, "CLEAN", true),
10103            ],
10104            vec![],
10105        );
10106        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10107            head: "mine".to_owned(),
10108        });
10109        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10110            .await
10111            .unwrap();
10112        assert!(!forge.calls().contains(&"merge"));
10113        let why = state.merge.as_ref().unwrap().detail.clone();
10114        assert!(
10115            why.contains("mine") && why.contains("someone-elses"),
10116            "{why}"
10117        );
10118        assert_eq!(state.status, RunStatus::Blocked);
10119    }
10120
10121    #[tokio::test]
10122    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
10123        let mut state = landing_state();
10124        let forge = Scripted::new(
10125            vec![
10126                seen("a", Checks::Green, "CLEAN", false),
10127                seen("a", Checks::Green, "CLEAN", false),
10128                seen("a", Checks::Pending, "BLOCKED", false),
10129                seen("a", Checks::Pending, "BLOCKED", false),
10130                seen("a", Checks::Green, "CLEAN", false),
10131            ],
10132            vec![(false, REFUSED), (true, "")],
10133        );
10134        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10135            .await
10136            .unwrap();
10137        assert_eq!(
10138            forge.calls(),
10139            [
10140                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
10141                "view"
10142            ]
10143        );
10144        assert_eq!(state.status, RunStatus::Merged);
10145    }
10146
10147    #[tokio::test]
10148    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
10149        let mut state = landing_state();
10150        let forge = Scripted::new(
10151            vec![
10152                seen("a", Checks::Green, "CLEAN", false),
10153                seen("a", Checks::Green, "BLOCKED", false),
10154            ],
10155            vec![(false, REFUSED), (false, REFUSED)],
10156        );
10157        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10158            .await
10159            .unwrap();
10160        // One re-look is allowed for the forge's own lag, then it is final.
10161        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
10162        let why = state.merge.as_ref().unwrap().detail.clone();
10163        assert!(
10164            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
10165            "{why}"
10166        );
10167        assert_eq!(state.status, RunStatus::Blocked);
10168    }
10169
10170    #[test]
10171    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
10172        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
10173        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
10174        // The pull request is still on the commit before the push.
10175        assert_eq!(bound_head("old", "old", Some("new")), None);
10176        // The checks are the previous commit's.
10177        assert_eq!(bound_head("new", "old", Some("new")), None);
10178        assert_eq!(bound_head("new", "old", None), None);
10179        // Nothing readable.
10180        assert_eq!(bound_head("", "", None), None);
10181        assert_eq!(bound_head("", "", Some("new")), None);
10182        assert_eq!(bound_head("abc", "", None), None);
10183    }
10184
10185    fn view_json(head: &str) -> String {
10186        format!(
10187            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
10188            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
10189            "reviews":[],"comments":[]}}"#
10190        )
10191    }
10192
10193    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
10194        format!(
10195            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
10196            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
10197            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
10198            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
10199        )
10200    }
10201
10202    #[test]
10203    fn rollup_is_bound_to_the_commit_in_the_same_node() {
10204        // The view already points at the new head, but the node still answers
10205        // for the old commit with its red check: the head stays unbound.
10206        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
10207        assert_eq!(s.rollup_head, "old");
10208        assert_eq!(s.pr.checks, Checks::Red);
10209        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
10210        assert_eq!(s.failing_urls.len(), 1);
10211    }
10212
10213    #[test]
10214    fn checks_come_from_the_node_not_the_view() {
10215        let view = view_json("new").replace(
10216            r#""reviews""#,
10217            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
10218        );
10219        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
10220        assert_eq!(s.pr.checks, Checks::Green);
10221        assert!(s.pr.failing.is_empty());
10222        assert_eq!(
10223            bound_head(&s.head, &s.rollup_head, Some("new")),
10224            Some("new")
10225        );
10226    }
10227
10228    #[test]
10229    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
10230        for node in [
10231            None,
10232            Some("not json".to_owned()),
10233            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
10234            Some(node_json("new", "SUCCESS", true)),
10235        ] {
10236            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
10237            assert!(s.rollup_head.is_empty());
10238            assert_eq!(s.pr.checks, Checks::Unknown);
10239            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
10240        }
10241    }
10242
10243    #[test]
10244    fn the_merge_command_is_pinned_to_the_observed_head() {
10245        let argv = merge_argv_at(7, "feat: x", "deadbeef");
10246        let at = argv
10247            .iter()
10248            .position(|a| a == "--match-head-commit")
10249            .unwrap();
10250        assert_eq!(argv[at + 1], "deadbeef");
10251    }
10252
10253    #[tokio::test]
10254    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
10255        let mut state = landing_state();
10256        // The pull request is on the pushed head but the rollup is still the
10257        // previous commit's red, non-required result.
10258        let mut stale = seen("new", Checks::Red, "CLEAN", false);
10259        stale.rollup_head = "old".to_owned();
10260        let forge = Scripted::new(
10261            vec![seen("old", Checks::Green, "CLEAN", true), stale],
10262            vec![],
10263        );
10264        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10265            head: "new".to_owned(),
10266        });
10267        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10268            .await
10269            .unwrap();
10270        assert!(!forge.calls().contains(&"merge"));
10271        assert_eq!(state.status, RunStatus::Blocked);
10272        let why = state.merge.as_ref().unwrap().detail.clone();
10273        assert!(why.contains("new") && why.contains("old"), "{why}");
10274    }
10275
10276    #[test]
10277    fn a_refusal_read_against_another_commits_checks_is_pending() {
10278        let mut after = seen("a", Checks::Green, "BLOCKED", false);
10279        after.rollup_head = "old".to_owned();
10280        assert_eq!(
10281            classify_refusal(Some(&after), true, "a", CiExpectation::Expected),
10282            Refused::Pending
10283        );
10284    }
10285
10286    #[tokio::test]
10287    async fn absent_ci_empty_rollup_reaches_approval_then_merges_on_the_approved_head() {
10288        let mut state = landing_state();
10289        state.config.merge.ci = crate::config::MergeCi::None;
10290        state.config.graph.land_approval = true;
10291        let forge = Scripted::new(
10292            vec![seen("a", Checks::Unknown, "CLEAN", false)],
10293            vec![(true, "")],
10294        );
10295        land_with(&mut state, URL, &forge).await.unwrap();
10296        assert!(state.parked);
10297        assert_eq!(forge.calls(), ["view"]);
10298        let store = ask::Questions::open();
10299        let mut q = store
10300            .get(&state.land_approval.as_ref().unwrap().question)
10301            .unwrap();
10302        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10303        store.put(&mut q).unwrap();
10304
10305        land_with(&mut state, URL, &forge).await.unwrap();
10306        assert_eq!(forge.calls(), ["view", "view", "view", "merge", "view"]);
10307        assert_eq!(state.status, RunStatus::Merged);
10308        assert_eq!(
10309            value_of(&forge.argvs()[0], "--match-head-commit"),
10310            Some("a")
10311        );
10312    }
10313
10314    #[tokio::test]
10315    async fn absent_ci_ignores_failed_workflow_runs_without_rerunning_them() {
10316        let mut state = landing_state();
10317        state.config.merge.ci = crate::config::MergeCi::None;
10318        let forge = Scripted::new(vec![red("a", Some("7"))], vec![(true, "")]);
10319        land_with(&mut state, URL, &forge).await.unwrap();
10320        assert!(forge.reruns.lock().unwrap().is_empty());
10321        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10322        assert_eq!(state.status, RunStatus::Merged);
10323    }
10324
10325    #[test]
10326    fn absent_ci_still_requires_a_readable_matching_head_and_resolved_comments() {
10327        let shown = BTreeSet::new();
10328        let guard = |s: Option<&Seen>| {
10329            direct_merge_is_safe(s, "a", &shown, 0, 4, Duration::ZERO, CiExpectation::Absent)
10330        };
10331        let mut fresh = seen("a", Checks::Unknown, "CLEAN", false);
10332        assert!(guard(Some(&fresh)));
10333        assert!(!guard(None));
10334        fresh.rollup_head = "old".to_owned();
10335        assert!(!guard(Some(&fresh)));
10336        fresh.rollup_head = "a".to_owned();
10337        fresh.head = "b".to_owned();
10338        assert!(!guard(Some(&fresh)));
10339        assert!(!guard(Some(&seen("a", Checks::Unknown, "CLEAN", true))));
10340        assert!(!guard(Some(&seen("a", Checks::Unknown, "DIRTY", false))));
10341    }
10342
10343    #[test]
10344    fn absent_ci_refusal_does_not_wait_for_third_party_checks() {
10345        for checks in [Checks::Unknown, Checks::Pending, Checks::Red, Checks::Green] {
10346            let after = seen("a", checks, "BLOCKED", false);
10347            assert_eq!(
10348                classify_refusal(Some(&after), false, "a", CiExpectation::Absent),
10349                Refused::Recheck
10350            );
10351            assert_eq!(
10352                classify_refusal(Some(&after), true, "a", CiExpectation::Absent),
10353                Refused::Final
10354            );
10355        }
10356    }
10357
10358    #[tokio::test]
10359    async fn a_matching_head_with_red_non_required_checks_still_merges() {
10360        let mut state = landing_state();
10361        let forge = Scripted::new(
10362            vec![seen("a", Checks::Red, "CLEAN", false)],
10363            vec![(true, "")],
10364        );
10365        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10366            .await
10367            .unwrap();
10368        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10369        assert_eq!(state.status, RunStatus::Merged);
10370    }
10371
10372    #[tokio::test]
10373    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
10374        let mut state = landing_state();
10375        let forge = Scripted::new(
10376            vec![
10377                seen("a", Checks::Green, "CLEAN", false),
10378                seen("a", Checks::Green, "CLEAN", false),
10379                // Re-viewed after the refusal: someone pushed.
10380                seen("b", Checks::Green, "BLOCKED", false),
10381                seen("b", Checks::Green, "CLEAN", false),
10382            ],
10383            vec![(false, REFUSED), (true, "")],
10384        );
10385        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10386            .await
10387            .unwrap();
10388        assert_eq!(
10389            forge.calls(),
10390            [
10391                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
10392            ]
10393        );
10394        assert_eq!(state.status, RunStatus::Merged);
10395    }
10396
10397    fn merged_view(head: &str) -> Seen {
10398        let mut m = seen(head, Checks::Green, "CLEAN", false);
10399        m.pr.state = PrLifecycle::Merged;
10400        m
10401    }
10402
10403    fn has(argv: &[String], flag: &str) -> bool {
10404        argv.iter().any(|a| a == flag)
10405    }
10406
10407    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
10408        let at = argv.iter().position(|a| a == flag)?;
10409        argv.get(at + 1).map(String::as_str)
10410    }
10411
10412    const URL: &str = "https://github.com/o/r/pull/42";
10413
10414    #[tokio::test]
10415    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
10416        let mut state = landing_state();
10417        let forge = Scripted::new(
10418            vec![
10419                seen("abc", Checks::Green, "CLEAN", false),
10420                seen("abc", Checks::Green, "CLEAN", false),
10421            ],
10422            vec![(true, "")],
10423        );
10424        land_with(&mut state, URL, &forge).await.unwrap();
10425        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10426        let argv = &forge.argvs()[0];
10427        assert!(has(argv, "--squash") && has(argv, "--subject"));
10428        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
10429        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
10430        assert_eq!(state.status, RunStatus::Merged);
10431        assert!(state.land_armed_head.is_none());
10432    }
10433
10434    #[tokio::test]
10435    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
10436        let mut state = landing_state();
10437        state.land_armed_head = Some("a".to_owned());
10438        let forge = Scripted::new(
10439            vec![seen("a", Checks::Green, "CLEAN", false)],
10440            vec![(true, ""), (true, "")],
10441        );
10442        land_with(&mut state, URL, &forge).await.unwrap();
10443        let argvs = forge.argvs();
10444        assert!(has(&argvs[0], "--disable-auto"));
10445        assert!(!has(&argvs[1], "--auto"));
10446        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
10447        assert_eq!(state.status, RunStatus::Merged);
10448        assert!(state.land_armed_head.is_none());
10449    }
10450
10451    #[tokio::test]
10452    async fn an_approval_never_carries_over_to_a_new_head() {
10453        crate::run::pin_test_home();
10454        let mut state = run_state();
10455        state.config.graph.land_approval = true;
10456        let pr = green_pr();
10457        let store = ask::Questions::open();
10458
10459        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
10460            .await
10461            .unwrap();
10462        let mut q = store
10463            .list()
10464            .into_iter()
10465            .find(|q| q.run == state.id)
10466            .unwrap();
10467        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10468        store.put(&mut q).unwrap();
10469        assert_eq!(
10470            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
10471                .await
10472                .unwrap(),
10473            ApprovalGate::Approved,
10474            "the same head keeps its approval"
10475        );
10476
10477        // A new head is a new question, not the old word.
10478        assert_eq!(
10479            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10480                .await
10481                .unwrap(),
10482            ApprovalGate::Pending
10483        );
10484        let all: Vec<_> = store
10485            .list()
10486            .into_iter()
10487            .filter(|q| q.run == state.id)
10488            .collect();
10489        assert_eq!(all.len(), 2);
10490
10491        // A question recorded before heads were tracked is not reused either.
10492        state.land_approval = None;
10493        assert_eq!(
10494            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10495                .await
10496                .unwrap(),
10497            ApprovalGate::Pending
10498        );
10499        let open = store
10500            .list()
10501            .into_iter()
10502            .filter(|q| q.run == state.id && q.status.open())
10503            .count();
10504        assert_eq!(open, 1, "the superseded question was retired");
10505    }
10506
10507    #[tokio::test]
10508    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
10509        let mut state = landing_state();
10510        let forge = Scripted::new(
10511            vec![
10512                seen("a", Checks::Green, "CLEAN", false),
10513                // The fresh read before the merge: someone pushed.
10514                seen("b", Checks::Green, "CLEAN", false),
10515            ],
10516            vec![(true, "")],
10517        );
10518        land_with(&mut state, URL, &forge).await.unwrap();
10519        let argvs = forge.argvs();
10520        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
10521        assert!(!has(&argvs[0], "--auto"));
10522        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
10523        assert_eq!(state.status, RunStatus::Merged);
10524    }
10525
10526    fn passing(label: &str) -> CheckInfo {
10527        CheckInfo {
10528            label: label.to_owned(),
10529            verdict: Verdict::Pass,
10530            required: Some(false),
10531            run: None,
10532        }
10533    }
10534
10535    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
10536        (label.to_owned(), run.map(str::to_owned))
10537    }
10538
10539    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
10540        LandReruns {
10541            head: head.to_owned(),
10542            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
10543        }
10544    }
10545
10546    #[test]
10547    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
10548        let f = [failing("test (macos)", Some("7"))];
10549        assert_eq!(
10550            rerun_step(&f, None, "h", 100),
10551            RerunStep::Rerun(vec!["7".to_owned()])
10552        );
10553        let rec = reran("h", &[("7", 100)]);
10554        let grace = crate::release_watch::RERUN_GRACE;
10555        assert_eq!(
10556            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
10557            RerunStep::Grace
10558        );
10559        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
10560    }
10561
10562    #[test]
10563    fn a_failure_without_a_run_id_goes_to_the_fixer() {
10564        let f = [failing("a", Some("7")), failing("ci/external", None)];
10565        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
10566        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
10567    }
10568
10569    #[test]
10570    fn a_moved_head_forgets_the_reruns() {
10571        let f = [failing("a", Some("7"))];
10572        let rec = reran("old", &[("7", 100)]);
10573        assert_eq!(
10574            rerun_step(&f, Some(&rec), "new", 101),
10575            RerunStep::Rerun(vec!["7".to_owned()])
10576        );
10577    }
10578
10579    #[test]
10580    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
10581        let f = [
10582            failing("a", Some("7")),
10583            failing("b", Some("7")),
10584            failing("c", Some("8")),
10585        ];
10586        assert_eq!(
10587            rerun_step(&f, None, "h", 0),
10588            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
10589        );
10590        let rec = reran("h", &[("7", 0)]);
10591        assert_eq!(
10592            rerun_step(&f, Some(&rec), "h", 1),
10593            RerunStep::Rerun(vec!["8".to_owned()])
10594        );
10595    }
10596
10597    #[test]
10598    fn only_blocking_failures_are_candidates_for_a_rerun() {
10599        let check = |label: &str, verdict, required, run: &str| CheckInfo {
10600            label: label.to_owned(),
10601            verdict,
10602            required,
10603            run: Some(run.to_owned()),
10604        };
10605        let got = rerunnable(&[
10606            check("passed", Verdict::Pass, Some(true), "1"),
10607            check("optional", Verdict::Fail, Some(false), "2"),
10608            check("required", Verdict::Fail, Some(true), "3"),
10609            check("unknown", Verdict::Fail, None, "4"),
10610        ]);
10611        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
10612        assert_eq!(labels, ["required", "unknown"]);
10613    }
10614
10615    fn unrelated_ci_fixture() -> CiEvidence {
10616        let failure = CiFailure {
10617            path: "tests/web_render.rs".into(),
10618            start_line: 42,
10619            end_line: 42,
10620            title: "question_identity_disclosure_copy_and_live_updates".into(),
10621            message: "assertion failed".into(),
10622        };
10623        CiEvidence {
10624            head: "a".into(),
10625            base: "base-sha".into(),
10626            changed: vec![
10627                "AGENTS.md".into(),
10628                "src/talk.rs".into(),
10629                "tests/consult_investigation.rs".into(),
10630            ],
10631            checks: vec![CiCheckEvidence {
10632                label: "coverage".into(),
10633                head_check: 70,
10634                base_check: 60,
10635                app: 1,
10636                run: "7".into(),
10637                base_run: "6".into(),
10638                head_failures: vec![failure.clone()],
10639                base_failures: vec![failure],
10640                rerun_exhausted: true,
10641            }],
10642        }
10643    }
10644
10645    #[test]
10646    fn unrelated_ci_requires_complete_structural_evidence_for_every_failure() {
10647        let good = unrelated_ci_fixture();
10648        let red = vec!["coverage".into()];
10649        assert!(good.unrelated("a", &red));
10650        assert!(!good.unrelated("another-head", &red));
10651        assert!(!good.unrelated("a", &["coverage".into(), "test".into()]));
10652        assert!(!good.unrelated("a", &[]));
10653        for mutation in 0..8 {
10654            let mut bad = good.clone();
10655            match mutation {
10656                0 => bad.checks[0].rerun_exhausted = false,
10657                1 => bad.checks[0].base_failures.clear(),
10658                2 => bad.checks[0].head_failures.clear(),
10659                3 => bad.changed.push("tests/web_render.rs".into()),
10660                4 => bad.changed.push(".github/workflows/ci.yml".into()),
10661                5 => bad.checks[0].base_failures[0].message = "different failure".into(),
10662                6 => bad.checks.push(bad.checks[0].clone()),
10663                _ => bad.base.clear(),
10664            }
10665            assert!(!bad.unrelated("a", &red), "mutation {mutation}");
10666        }
10667    }
10668
10669    #[test]
10670    fn red_unknown_merge_state_waits_before_repair_but_never_implies_merge() {
10671        let pr = seen("a", Checks::Red, "UNKNOWN", false).pr;
10672        assert_eq!(decide(&pr, 0, 2, Duration::ZERO), Step::Wait);
10673        assert!(matches!(decide(&pr, 0, 2, CHECKS_GRACE), Step::Fix { .. }));
10674        assert!(matches!(
10675            decide(&pr, 2, 2, CHECKS_GRACE),
10676            Step::GiveUp { .. }
10677        ));
10678        let optional = CheckInfo {
10679            label: "coverage".into(),
10680            verdict: Verdict::Fail,
10681            required: Some(false),
10682            run: Some("7".into()),
10683        };
10684        assert!(rerunnable_with(std::slice::from_ref(&optional), false).is_empty());
10685        assert_eq!(
10686            rerunnable_with(&[optional], true),
10687            vec![("coverage".into(), Some("7".into()))]
10688        );
10689    }
10690
10691    #[tokio::test]
10692    async fn unrelated_ci_after_a_comment_round_does_not_charge_a_no_commit_stop() {
10693        let mut state = landing_state();
10694        let mut red = coverage_unknown();
10695        red.pr = seen("a", Checks::Red, "BLOCKED", true).pr;
10696        red.pr.failing = vec!["coverage".into()];
10697        let forge = Scripted::new(vec![red], vec![]);
10698        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10699        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10700        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10701        assert!(forge.calls().contains(&"fix"));
10702        assert!(state.land_unrelated.is_some());
10703        assert!(state.parked);
10704        assert!(
10705            !state
10706                .events
10707                .iter()
10708                .any(|e| e.message.contains("fixer produced no commit"))
10709        );
10710    }
10711
10712    fn coverage_unknown() -> Seen {
10713        let mut s = red("a", Some("7"));
10714        s.pr.blocking = Blocking::Unsaid;
10715        s.merge_state = "UNKNOWN".into();
10716        s.pr.failing = vec!["coverage".into()];
10717        s.contexts[0].label = "coverage".into();
10718        s.contexts[0].required = Some(false);
10719        s
10720    }
10721
10722    #[tokio::test]
10723    async fn run_196c_unknown_then_unstable_does_not_spend_a_fix_round() {
10724        let mut state = landing_state();
10725        let unknown = coverage_unknown();
10726        let mut unstable = unknown.clone();
10727        unstable.pr.blocking = Blocking::of("UNSTABLE");
10728        unstable.merge_state = "UNSTABLE".into();
10729        let forge = Scripted::new(vec![unknown, unstable], vec![(true, "")]);
10730        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10731        assert!(!forge.calls().contains(&"fix"));
10732        assert!(!forge.calls().contains(&"rerun"));
10733        assert_eq!(state.status, RunStatus::Merged);
10734        assert_eq!(state.pr.unwrap().round, 0);
10735    }
10736
10737    #[tokio::test]
10738    async fn run_196c_persistent_unknown_reruns_optional_once_then_parks_with_evidence() {
10739        let mut state = landing_state();
10740        let forge = Scripted::new(vec![coverage_unknown()], vec![]);
10741        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10742        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10743        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10744        assert!(!forge.calls().contains(&"fix"));
10745        assert!(state.parked);
10746        assert_eq!(state.status, RunStatus::Landing);
10747        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10748        let hold = state.land_unrelated.as_ref().unwrap();
10749        let q = ask::Questions::open().get(&hold.question).unwrap();
10750        assert!(
10751            q.actions
10752                .values()
10753                .any(|a| matches!(a, ask::ChoiceAction::Resume { .. }))
10754        );
10755        assert!(
10756            state
10757                .events
10758                .iter()
10759                .any(|e| e.message.contains("[ci-unrelated]") && e.message.contains("base-sha"))
10760        );
10761        let first = state.land_unsaid.clone();
10762        // A resume on this head neither extends UNKNOWN grace nor reruns 7.
10763        state.parked = false;
10764        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10765        assert_eq!(state.land_unsaid, first);
10766        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10767    }
10768
10769    fn red(head: &str, run: Option<&str>) -> Seen {
10770        let mut s = seen(head, Checks::Red, "BLOCKED", false);
10771        s.pr.failing = vec!["test (macos)".to_owned()];
10772        s.contexts = vec![CheckInfo {
10773            label: "test (macos)".to_owned(),
10774            verdict: Verdict::Fail,
10775            required: Some(true),
10776            run: run.map(str::to_owned),
10777        }];
10778        s
10779    }
10780
10781    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
10782
10783    #[tokio::test]
10784    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
10785        let mut state = landing_state();
10786        let forge = Scripted::new(
10787            vec![
10788                red("a", Some("7")),
10789                seen("a", Checks::Pending, "BLOCKED", false),
10790                seen("a", Checks::Green, "CLEAN", false),
10791            ],
10792            vec![(true, "")],
10793        );
10794        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10795        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10796        assert!(!forge.calls().contains(&"fix"));
10797        assert_eq!(state.status, RunStatus::Merged);
10798        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10799        let argvs = forge.argvs();
10800        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
10801        assert!(
10802            state
10803                .events
10804                .iter()
10805                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
10806        );
10807    }
10808
10809    #[tokio::test]
10810    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
10811        let mut state = landing_state();
10812        let forge = Scripted::new(
10813            vec![
10814                red("a", Some("7")),
10815                seen("a", Checks::Green, "CLEAN", false),
10816            ],
10817            vec![(true, "")],
10818        );
10819        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10820        // The red view repeats for the grace, then the fixer is asked once.
10821        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10822        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
10823        let calls = forge.calls();
10824        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
10825        let fix_at = calls.iter().position(|c| *c == "fix");
10826        assert!(fix_at.is_none_or(|f| f > rerun_at));
10827    }
10828
10829    #[tokio::test]
10830    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
10831        let mut state = landing_state();
10832        let mut v = red("a", Some("7"));
10833        v.pr.checks = Checks::Pending;
10834        v.contexts.push(CheckInfo {
10835            label: "slow".to_owned(),
10836            verdict: Verdict::Pending,
10837            required: Some(true),
10838            run: Some("8".to_owned()),
10839        });
10840        let forge = Scripted::new(
10841            vec![v, seen("a", Checks::Green, "CLEAN", false)],
10842            vec![(true, "")],
10843        );
10844        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10845        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10846        assert_eq!(state.status, RunStatus::Merged);
10847    }
10848
10849    #[test]
10850    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10851        let mut v = red("a", Some("7"));
10852        v.contexts.push(CheckInfo {
10853            label: "same run, still going".to_owned(),
10854            verdict: Verdict::Pending,
10855            required: Some(true),
10856            run: Some("7".to_owned()),
10857        });
10858        assert!(rerunnable(&v.contexts).is_empty());
10859    }
10860
10861    #[tokio::test]
10862    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10863        let mut state = landing_state();
10864        state.config.graph.land_rounds = 0;
10865        let forge = Scripted::new(
10866            vec![
10867                red("a", Some("7")),
10868                seen("a", Checks::Green, "CLEAN", false),
10869            ],
10870            vec![(true, "")],
10871        );
10872        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10873        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10874        assert_eq!(state.status, RunStatus::Merged);
10875        assert!(!forge.calls().contains(&"fix"));
10876        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10877    }
10878
10879    #[tokio::test]
10880    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
10881        let mut state = landing_state();
10882        state.config.graph.land_rounds = 1;
10883        let forge = Scripted::new(
10884            vec![
10885                seen("a", Checks::Green, "CLEAN", true),
10886                red("b", Some("7")),
10887                seen("b", Checks::Green, "CLEAN", false),
10888            ],
10889            vec![(true, "")],
10890        );
10891        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10892            head: "b".to_owned(),
10893        });
10894        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10895        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10896        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
10897        assert_eq!(state.pr.as_ref().unwrap().round, 1);
10898        assert_eq!(state.status, RunStatus::Merged);
10899        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
10900        assert_eq!(
10901            value_of(&forge.argvs()[0], "--match-head-commit"),
10902            Some("b")
10903        );
10904    }
10905
10906    #[tokio::test]
10907    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
10908        let mut state = landing_state();
10909        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10910        *forge.rerun_fails.lock().unwrap() = true;
10911        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10912        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10913        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
10914        assert_eq!(
10915            forge.calls().iter().filter(|c| **c == "fix").count(),
10916            1,
10917            "{:?}",
10918            forge.calls()
10919        );
10920    }
10921
10922    #[tokio::test]
10923    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
10924        let mut state = landing_state();
10925        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10926        // The record was written just before the previous pass died.
10927        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
10928        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10929        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10930        assert!(forge.reruns.lock().unwrap().is_empty());
10931    }
10932
10933    #[tokio::test]
10934    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
10935        let mut state = landing_state();
10936        state.config.graph.rerun_flaky_checks = false;
10937        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10938        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10939        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10940        assert!(forge.reruns.lock().unwrap().is_empty());
10941        assert!(forge.calls().contains(&"fix"));
10942
10943        let mut state = landing_state();
10944        let forge = Scripted::new(vec![red("a", None)], vec![]);
10945        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10946        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10947        assert!(forge.reruns.lock().unwrap().is_empty());
10948        assert!(forge.calls().contains(&"fix"));
10949    }
10950
10951    fn names(xs: &[&str]) -> BTreeSet<String> {
10952        xs.iter().map(|x| (*x).to_owned()).collect()
10953    }
10954
10955    #[test]
10956    fn a_required_check_the_rollup_never_listed_is_named() {
10957        let req = names(&["build"]);
10958        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
10959        assert!(why.contains("never reported: build"), "{why}");
10960        assert!(!why.contains("probably waiting for a review"), "{why}");
10961    }
10962
10963    #[test]
10964    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
10965        let why = waiting_on("BLOCKED", &[passing("review")], None);
10966        assert!(why.contains("could not be read"), "{why}");
10967        assert!(!why.contains("probably waiting for a review"), "{why}");
10968    }
10969
10970    #[test]
10971    fn all_required_reported_keeps_the_review_guess() {
10972        let req = names(&["build"]);
10973        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10974        assert!(why.contains("probably waiting for a review"), "{why}");
10975        assert!(!why.contains("never reported"), "{why}");
10976    }
10977
10978    #[test]
10979    fn required_names_match_the_rollup_ignoring_case_only() {
10980        let req = names(&["Build"]);
10981        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10982        assert!(!why.contains("never reported"), "{why}");
10983    }
10984
10985    #[test]
10986    fn required_contexts_are_read_from_protection_and_rulesets() {
10987        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
10988        assert_eq!(
10989            parse_classic_required(classic),
10990            Some(names(&["build", "lint"]))
10991        );
10992        let rules = r#"[{"type":"pull_request","parameters":{}},
10993            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
10994        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
10995        assert_eq!(parse_ruleset_required("nope"), None);
10996        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
10997    }
10998
10999    #[test]
11000    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
11001        let shown = BTreeSet::new();
11002        let ok = seen("a", Checks::Green, "CLEAN", false);
11003        let guard = |s: Option<&Seen>| {
11004            direct_merge_is_safe(
11005                s,
11006                "A",
11007                &shown,
11008                0,
11009                4,
11010                Duration::ZERO,
11011                CiExpectation::Expected,
11012            )
11013        };
11014        assert!(guard(Some(&ok)));
11015        assert!(!guard(None));
11016        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
11017        let mut stale = ok.clone();
11018        stale.rollup_head = "old".to_owned();
11019        assert!(!guard(Some(&stale)));
11020        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
11021        assert!(!guard(Some(&merged_view("a"))));
11022    }
11023
11024    #[test]
11025    fn the_rollup_node_carries_whether_each_check_is_required() {
11026        let node = node_json("new", "SUCCESS", false)
11027            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
11028        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
11029        assert_eq!(s.contexts.len(), 1);
11030        assert_eq!(s.contexts[0].required, Some(true));
11031        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
11032        assert_eq!(s.contexts[0].required, None);
11033    }
11034
11035    #[tokio::test]
11036    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
11037        let mut state = landing_state();
11038        state.land_armed_head = Some("a".to_owned());
11039        let forge = Scripted::new(
11040            vec![seen("a", Checks::Green, "CLEAN", true)],
11041            vec![(false, "disable exploded")],
11042        );
11043        land_with(&mut state, URL, &forge).await.unwrap();
11044        assert!(!forge.calls().contains(&"fix"));
11045        assert_eq!(state.status, RunStatus::Blocked);
11046        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
11047        let why = state.merge.as_ref().unwrap().detail.clone();
11048        assert!(why.contains("disable exploded"), "{why}");
11049    }
11050}