Skip to main content

magi/
land.rs

1//! Landing the winner: watch the pull request, fix what it complains about,
2//! and merge it.
3//!
4//! Opening the pull request used to be where magi stopped and the operator
5//! started: watch the checks, read what the review bots found, push a fix,
6//! wait again, merge. That loop is mechanical, it takes an hour of wall-clock
7//! time per pull request, and doing it by hand six times in one session is how
8//! a queue that drains unattended stops being unattended. So it lives here.
9//!
10//! # Shape
11//!
12//! [`PrState`] is one observation of a pull request and [`decide`] is the whole
13//! policy as a *pure* function of it. Nothing in [`decide`] talks to `gh`,
14//! which is what makes "green with an unresolved comment is a fix, not a merge"
15//! an assertion in a test rather than a claim in a comment. [`land`] is the
16//! only part that performs I/O: observe, decide, act, repeat.
17//!
18//! # What it refuses to do
19//!
20//! Merging is the one irreversible thing magi can do to a repository, so the
21//! loop is built to stop rather than to guess:
22//!
23//! * A red pull request is never merged. When the budget runs out the pull
24//!   request is left open with a comment naming what is still failing, because
25//!   a magi that force-merges a red pull request is worse than one that stops.
26//! * A pull request whose checks cannot be read at all (`gh` reported no
27//!   rollup) is not merged either. Landing is for repositories with CI; with no
28//!   signal there is nothing to be green.
29//! * A pull request a human merged or closed underneath us is
30//!   [`Step::Done`] - the person won, and their decision is not an error.
31//!
32//! # Why `--subject` is not optional
33//!
34//! A candidate branch holds one commit whose subject is
35//! `magi: candidate A (uncommitted work)`, and `gh pr merge --squash` prefers a
36//! single commit's message over the pull request title. Merging without
37//! [`merge_argv`]'s explicit `--subject` therefore writes a `main` history that
38//! says nothing about what landed. `AGENTS.md` records the trap; this module is
39//! where it is prevented.
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::MergeMode;
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{
58    ContestedHandoff, LandApproval, LandReruns, MergeOutcome, RunState, RunStatus, tail,
59};
60
61/// How often the pull request is re-read while its checks are still running.
62///
63/// Thirty seconds: a CI matrix takes minutes, so anything shorter is spent
64/// entirely on `gh` invocations, and anything much longer adds latency to every
65/// single round of a loop that already waits for agents.
66pub const POLL: Duration = Duration::from_secs(30);
67
68/// How long one wait may last before landing gives up on the checks finishing.
69///
70/// A workflow that has not settled in forty-five minutes is stuck on a runner
71/// queue, a missing approval, or a hung job - none of which more polling fixes,
72/// and all of which a person needs to see.
73pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
74
75/// How long the checks may stay unreadable before landing gives up on them.
76///
77/// GitHub registers a workflow run some seconds after the branch is pushed, so
78/// immediately after a pull request is opened "no checks" and "no CI in this
79/// repository" look identical. Measured on run 01c2: magi opened pull request
80/// 22, read `unknown` four seconds later, refused to merge on a guess and
81/// marked the run blocked - and every check on that pull request was green
82/// minutes afterwards, with the whole competition then re-run from scratch for
83/// a task that was already finished. Three minutes is well past the observed
84/// registration delay and still bounded, so a repository that genuinely has no
85/// checks costs one three-minute wait and then says so.
86pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
87
88/// Bytes of failing log kept per check. The fixer needs the assertion and the
89/// frame around it, not the forty thousand lines of `cargo` output above it.
90const LOG_TAIL: usize = 4_000;
91
92/// Failing checks whose logs are fetched. Beyond a handful the failures share a
93/// cause, and fetching each one costs a `gh` round trip.
94const MAX_LOGS: usize = 3;
95
96/// Marker carried by every comment magi posts on a pull request.
97///
98/// Without it magi's own "still failing" comment is indistinguishable from a
99/// reviewer's, and the next observation would hand magi's own prose to the
100/// fixer as a finding.
101pub const MARKER: &str = "<!-- magi:land -->";
102
103/// Markers a bot puts in a comment to say that the comment is not a review.
104///
105/// CodeRabbit labels its own machinery in HTML comments - the trigger notice,
106/// the walkthrough summary, the "thanks for using" footer - and its actual
107/// findings arrive as *inline* review comments with a path and a line. Taking
108/// the bot at its word is more honest than guessing from prose, and it is the
109/// difference between a fix round that has something to fix and one that asks
110/// an agent to act on a quota notice.
111const NOT_A_REVIEW: [&str; 3] = [
112    "skip review by coderabbit.ai",
113    "summarize by coderabbit.ai",
114    "<!-- tips_start -->",
115];
116
117/// Where a pull request is in its life.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrLifecycle {
120    /// Still ours to land.
121    Open,
122    /// Already merged, by us or by a person.
123    Merged,
124    /// Closed without merging.
125    Closed,
126}
127
128/// The aggregate verdict of a pull request's checks.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Checks {
131    /// At least one check has not finished.
132    Pending,
133    /// Every check passed (a skipped check counts as passed: the review
134    /// workflow skips release and bot pull requests by design).
135    Green,
136    /// At least one check finished without passing.
137    Red,
138    /// Nothing readable - no rollup at all, or a status magi does not know.
139    Unknown,
140}
141
142impl PrLifecycle {
143    /// Stable lower-case name, as the API and the reports spell it.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::Open => "open",
147            Self::Merged => "merged",
148            Self::Closed => "closed",
149        }
150    }
151}
152
153impl Checks {
154    /// Stable lower-case name, as the API and the reports spell it.
155    pub fn as_str(self) -> &'static str {
156        match self {
157            Self::Pending => "pending",
158            Self::Green => "green",
159            Self::Red => "red",
160            Self::Unknown => "unknown",
161        }
162    }
163}
164
165/// One outstanding review comment, human or bot.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ReviewComment {
168    /// Login of whoever wrote it.
169    pub author: String,
170    /// File it was left on, for inline review comments.
171    pub path: Option<String>,
172    /// Line it was left on, when the comment is inline and still anchored.
173    pub line: Option<u64>,
174    /// The comment itself, as written.
175    pub body: String,
176}
177
178/// One observation of a pull request.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct PrState {
181    /// Pull request url, as `gh` reports it.
182    pub url: String,
183    /// Pull request number.
184    pub number: u64,
185    /// Open, merged, or closed.
186    pub state: PrLifecycle,
187    /// Aggregate check verdict.
188    pub checks: Checks,
189    /// Names of the checks that finished without passing.
190    pub failing: Vec<String>,
191    /// Comments that still want an answer, human and bot.
192    pub review_comments: Vec<ReviewComment>,
193    /// Whether the forge itself considers the failures blocking.
194    pub blocking: Blocking,
195}
196
197/// Whether a failing check actually stands between the pull request and
198/// `main`, according to the forge.
199///
200/// The rollup lists every check equally, so `coverage` going red on a
201/// repository that deliberately does not require it looked exactly like a
202/// broken build - and magi answered by spending a fix round on a change that
203/// was fine. Pull request 37 had to be merged by hand for that reason: the
204/// only red check was `editorconfig`, which was failing because the *action*
205/// could not fetch its own binary, and which the repository does not require.
206///
207/// `mergeStateStatus` is where GitHub applies the required-check set, so it
208/// is the one field that can tell the difference.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Blocking {
211    /// Required checks are satisfied and the branch merges cleanly.
212    No,
213    /// Something required is failing or missing.
214    Yes,
215    /// The branch no longer merges: the base moved under it.
216    Conflict,
217    /// The forge did not say - an older `gh`, or a token without the scope.
218    /// Treated as `Yes`, because refusing to guess is the rule everywhere
219    /// else in this module.
220    Unsaid,
221}
222
223impl Blocking {
224    /// Read `mergeStateStatus`, which is upper-case in `gh`'s output.
225    fn of(raw: &str) -> Self {
226        match raw.to_ascii_uppercase().as_str() {
227            // Mergeable. `UNSTABLE` is the interesting one: mergeable, with a
228            // non-required check failing or still running.
229            "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
230            "DIRTY" => Self::Conflict,
231            "" | "UNKNOWN" => Self::Unsaid,
232            // BLOCKED, BEHIND, DRAFT: something has to change first.
233            _ => Self::Yes,
234        }
235    }
236
237    /// Does this stand between the pull request and the base branch?
238    #[must_use]
239    pub fn stops_a_merge(self) -> bool {
240        !matches!(self, Self::No)
241    }
242}
243
244/// What the loop decided to do next. Pure, so the policy is testable.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Step {
247    /// Checks are still running; re-read the pull request after [`POLL`].
248    Wait,
249    /// The base moved and the branch no longer merges: rebase it.
250    ///
251    /// Not a fix round. Nothing is wrong with the change - a competition
252    /// that runs for two hours against a repository merging pull requests
253    /// all day conflicts on the way in, and that is arithmetic rather than a
254    /// defect. Pull requests 35 and 37 were both rebased by hand for exactly
255    /// this.
256    Rebase,
257    /// Red checks or unresolved comments; run a fix round.
258    Fix {
259        /// What is unhappy, in one line, for the run log and the fix prompt.
260        reason: String,
261    },
262    /// Green and nothing outstanding; merge it.
263    Merge,
264    /// The pull request left our hands.
265    Done {
266        /// Did it land, or was it closed?
267        merged: bool,
268    },
269    /// Stop and leave the pull request to a person.
270    GiveUp {
271        /// Why magi stopped, in one line.
272        reason: String,
273    },
274}
275
276/// The outcome to record when `gh pr merge` exits non-zero, given what the
277/// pull request looked like immediately afterwards.
278///
279/// `gh pr merge` merges server-side first and only then does local work -
280/// deleting the branch, switching back to a base branch - so a non-zero exit
281/// does not mean the merge did not happen. In a jj-colocated repository it
282/// reliably does not mean that: git HEAD is detached, and `--delete-branch`
283/// ends with "could not determine current branch: not on any branch" *after*
284/// the merge has landed. Run ec12 merged pull request 28 into `main` and
285/// recorded `ok: false`, and its task was held waiting for a merge that was
286/// already done.
287///
288/// So the forge is asked, and its answer wins - the same authority [`decide`]
289/// gives the pull request's own state over everything else. The recorded
290/// detail carries both facts, because "the command failed and the merge
291/// happened anyway" is exactly what someone reading the run later needs to
292/// know.
293///
294/// `None` means the merge really did not happen, including when the pull
295/// request could not be read at all: an unreadable answer is not evidence of
296/// success.
297pub(crate) fn merged_after_all(
298    argv: &[String],
299    stderr: &str,
300    after: Option<PrLifecycle>,
301) -> Option<MergeOutcome> {
302    if after? != PrLifecycle::Merged {
303        return None;
304    }
305    Some(MergeOutcome {
306        mode: MergeMode::Pr,
307        ok: true,
308        detail: format!(
309            "gh {} (the command reported `{}`, but the pull request is merged)",
310            argv.join(" "),
311            stderr.trim()
312        ),
313        empty: false,
314    })
315}
316
317/// Decide the next step. No I/O.
318///
319/// `round` counts the fix rounds already spent, so `round == budget` means the
320/// budget is gone. A wait never spends a round: waiting is free, and a slow CI
321/// must not consume the allowance meant for actual fixes.
322///
323/// The order of the tests is the policy:
324///
325/// 1. **The pull request's own state wins.** A merge or a close that happened
326///    underneath us is the end of the story regardless of what the checks say.
327/// 2. **Pending beats red.** A check that is still running may yet fail, and one
328///    fix round that addresses every failure is cheaper than two that each
329///    address half - the fix pushes and restarts the whole suite anyway.
330/// 3. **Comments outrank green.** An unresolved comment holds the merge even
331///    when CI is happy; that is what a review is for.
332/// 4. **Unreadable is not absent.** Checks that cannot be read yet are waited
333///    on for [`CHECKS_GRACE`], because a pull request opened a moment ago has
334///    not been given its workflow runs yet. Past the grace they are treated as
335///    genuinely missing and magi stops rather than merge on a guess.
336pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
337    decide_with(pr, round, budget, waited, CiExpectation::Expected)
338}
339
340/// Whether the repository's CI is expected to report on this pull request.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum CiExpectation {
343    /// Checks will come: wait for them, judge them (the default, and what
344    /// [`decide`] always uses).
345    Expected,
346    /// The repository explicitly disables CI expectations with `[merge] ci = "none"`.
347    /// Ignore all check statuses while retaining conflicts and review comments.
348    Absent,
349}
350
351/// [`decide`] with the CI expectation made explicit. `Absent` ignores the
352/// rollup, but still requires conflicts and review comments to be resolved.
353pub fn decide_with(
354    pr: &PrState,
355    round: usize,
356    budget: usize,
357    waited: Duration,
358    ci: CiExpectation,
359) -> Step {
360    match pr.state {
361        PrLifecycle::Merged => return Step::Done { merged: true },
362        PrLifecycle::Closed => return Step::Done { merged: false },
363        PrLifecycle::Open => {}
364    }
365
366    // Before the checks: every check on a branch that cannot land is an
367    // answer about a state that cannot land.
368    if pr.blocking == Blocking::Conflict {
369        return Step::Rebase;
370    }
371
372    let spent = round >= budget;
373    let checks = match ci {
374        CiExpectation::Expected => pr.checks,
375        CiExpectation::Absent => Checks::Green,
376    };
377    match checks {
378        Checks::Pending => Step::Wait,
379        Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
380        Checks::Unknown => Step::GiveUp {
381            reason: format!(
382                "no check status is readable on the pull request after {} minute(s); \
383                 refusing to merge on a guess",
384                CHECKS_GRACE.as_secs() / 60
385            ),
386        },
387        // Red, but the forge says it does not stand in the way: the failing
388        // checks are ones this repository chose not to require. Spending a fix
389        // round on them asks an agent to repair something nobody is gating on
390        // - and pull request 37's only red check was an *action* that could
391        // not fetch its own binary. Merge, and name them so the record is
392        // honest about what was red when it landed.
393        Checks::Red if pr.blocking == Blocking::Unsaid && waited < CHECKS_GRACE => Step::Wait,
394        Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
395        Checks::Red => {
396            let what = format!(
397                "{} check(s) failing: {}",
398                pr.failing.len(),
399                pr.failing.join(", ")
400            );
401            if spent {
402                Step::GiveUp {
403                    reason: format!("{what} — still red after {budget} fix round(s)"),
404                }
405            } else {
406                Step::Fix { reason: what }
407            }
408        }
409        Checks::Green if pr.review_comments.is_empty() => Step::Merge,
410        Checks::Green => {
411            let what = format!(
412                "{} but {} review comment(s) are unresolved: {}",
413                if ci == CiExpectation::Absent {
414                    "CI is not expected"
415                } else {
416                    "checks are green"
417                },
418                pr.review_comments.len(),
419                authors(&pr.review_comments)
420            );
421            if spent {
422                Step::GiveUp {
423                    reason: format!("{what} — still unresolved after {budget} fix round(s)"),
424                }
425            } else {
426                Step::Fix { reason: what }
427            }
428        }
429    }
430}
431
432/// Distinct comment authors, in the order they first appear.
433fn authors(comments: &[ReviewComment]) -> String {
434    let mut seen: Vec<&str> = Vec::new();
435    for c in comments {
436        if !seen.contains(&c.author.as_str()) {
437            seen.push(&c.author);
438        }
439    }
440    seen.join(", ")
441}
442
443/// The argv magi merges with, minus the program name.
444///
445/// `--subject` is the point of this function existing: see the module docs.
446pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
447    vec![
448        "pr".to_owned(),
449        "merge".to_owned(),
450        number.to_string(),
451        "--squash".to_owned(),
452        "--delete-branch".to_owned(),
453        "--subject".to_owned(),
454        subject.to_owned(),
455    ]
456}
457
458/// [`merge_argv`] pinned to the commit the decision was made about.
459///
460/// `--match-head-commit` makes the forge refuse the merge if the branch moved
461/// after it was observed, so a push landing between the look and the merge is
462/// never merged unseen.
463pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
464    let mut argv = merge_argv(number, subject);
465    argv.push("--match-head-commit".to_owned());
466    argv.push(head.to_owned());
467    argv
468}
469
470/// Take auto-merge back. magi no longer arms auto-merge, but a run recorded by
471/// an older build may still have one standing on the forge, so the disarm
472/// paths (and `RunState::land_armed_head`) stay. Run before anything that changes the head, so an
473/// armed merge never outlives the commit that was approved for it.
474pub fn disable_automerge_argv(number: u64) -> Vec<String> {
475    ["pr", "merge", &number.to_string(), "--disable-auto"]
476        .map(str::to_owned)
477        .to_vec()
478}
479
480/// May the direct merge go ahead, judged from a fresh read?
481///
482/// The pull request must still be open on the approved head, the checks must
483/// have been read for that very head, and the policy must still say merge.
484/// Pure, so the head guard is asserted without a forge.
485fn direct_merge_is_safe(
486    fresh: Option<&Seen>,
487    approved_head: &str,
488    shown: &BTreeSet<String>,
489    round: usize,
490    budget: usize,
491    waited: Duration,
492    ci: CiExpectation,
493) -> bool {
494    let Some(fresh) = fresh else {
495        return false;
496    };
497    if fresh.pr.state != PrLifecycle::Open {
498        return false;
499    }
500    let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
501        return false;
502    };
503    if !bound.eq_ignore_ascii_case(approved_head) {
504        return false;
505    }
506    let mut pr = fresh.pr.clone();
507    pr.review_comments.retain(|c| !shown.contains(&c.body));
508    decide_with(&pr, round, budget, waited, ci) == Step::Merge
509}
510
511/// What GitHub is still waiting for, for the stop reason when an armed merge
512/// does not happen in time. No I/O.
513///
514/// Required checks that are pending or failing are named. A check whose
515/// required-ness could not be read is never assumed optional: every unsettled
516/// check is listed and the reason says so.
517fn waiting_on(
518    merge_state: &str,
519    contexts: &[CheckInfo],
520    required_set: Option<&BTreeSet<String>>,
521) -> String {
522    let state = if merge_state.is_empty() {
523        "unknown"
524    } else {
525        merge_state
526    };
527    let tag = |c: &CheckInfo| match c.verdict {
528        Verdict::Fail => "failed",
529        _ => "pending",
530    };
531    let unsettled: Vec<&CheckInfo> = contexts
532        .iter()
533        .filter(|c| c.verdict != Verdict::Pass)
534        .collect();
535    let required: Vec<String> = unsettled
536        .iter()
537        .filter(|c| c.required == Some(true))
538        .map(|c| format!("{} ({})", c.label, tag(c)))
539        .collect();
540    let unknown: Vec<String> = unsettled
541        .iter()
542        .filter(|c| c.required.is_none())
543        .map(|c| format!("{} ({})", c.label, tag(c)))
544        .collect();
545    // Required contexts the rollup never listed: nothing reported them, so no
546    // pending/failing entry exists to name. Matched case-insensitively against
547    // the labels as the rollup spells them, and no further.
548    let never: Vec<&str> = required_set
549        .map(|set| {
550            set.iter()
551                .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
552                .map(String::as_str)
553                .collect()
554        })
555        .unwrap_or_default();
556    let mut out = format!("merge state: {state}");
557    if !never.is_empty() {
558        let _ = write!(
559            out,
560            "; required checks never reported: {}",
561            never.join(", ")
562        );
563    }
564    if !required.is_empty() {
565        let _ = write!(
566            out,
567            "; required checks not passing: {}",
568            required.join(", ")
569        );
570    }
571    if !unknown.is_empty() {
572        let _ = write!(
573            out,
574            "; whether these are required could not be read, so they may be: {}",
575            unknown.join(", ")
576        );
577    }
578    if required.is_empty() && unknown.is_empty() && never.is_empty() {
579        if required_set.is_some() {
580            out.push_str(
581                "; no required check is pending, failing or unreported, so GitHub is probably \
582                 waiting for a review or another branch rule",
583            );
584        } else {
585            out.push_str(
586                "; the required check list could not be read, so a required check that was \
587                 never reported cannot be ruled out",
588            );
589        }
590    }
591    out
592}
593
594/// The squash subject to merge under.
595///
596/// The pull request title, unless it is empty or is a candidate branch's commit
597/// subject that leaked into the title - in which case the task's own first line
598/// is used, because `magi: candidate A (uncommitted work)` in `main` tells a
599/// reader nothing about what landed.
600pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
601    let title = pr_title.trim();
602    if !title.is_empty() && !title.starts_with("magi: candidate") {
603        return title.to_owned();
604    }
605    let first = instruction
606        .lines()
607        .map(str::trim)
608        .find(|l| !l.is_empty())
609        .unwrap_or("magi: land the winning candidate");
610    first.trim_start_matches(['#', ' ']).to_owned()
611}
612
613/// The choice that lets the merge happen, verbatim as the owner taps it.
614pub const APPROVE: &str = "merge";
615
616/// The choice that leaves the pull request open.
617pub const HOLD: &str = "hold";
618
619/// A bounded repair followed by a new review and a fresh owner decision.
620pub const REPAIR: &str = "fix findings and re-review";
621
622/// Whether review evidence offers a meaningful repair choice. This does not
623/// decide whether owner approval is required.
624pub fn repair_available(state: &RunState) -> bool {
625    state.reviews.last().is_some_and(|r| {
626        state.open_findings().iter().any(|f| f.severity.blocks())
627            || r.final_votes()
628                .iter()
629                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
630    })
631}
632
633/// All applicable reasons for consulting the owner, independent of repair choices.
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub enum ApprovalReason {
636    /// Universal owner approval is enabled for every merge.
637    EveryMerge,
638    /// Contested-merge approval is enabled and blocking findings remain open.
639    OpenBlockingFindings,
640    /// Contested-merge approval is enabled and a final reviewer vote rejects.
641    FinalReject,
642    /// An existing owner decision must be honored even if controls change.
643    ExistingDecision,
644    /// Merge repair requires a fresh owner decision, even on the same head.
645    AfterRepair,
646}
647
648/// Disabling fresh approval controls never bypasses an existing owner decision
649/// or the fresh decision owed after repair, even if repair kept the same head.
650pub fn approval_reasons(state: &RunState) -> Vec<ApprovalReason> {
651    let mut reasons = Vec::new();
652    if state.owner_hold(crate::config::OwnerHold::LandApproval) {
653        reasons.push(ApprovalReason::EveryMerge);
654    }
655    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
656        if state.open_findings().iter().any(|f| f.severity.blocks()) {
657            reasons.push(ApprovalReason::OpenBlockingFindings);
658        }
659        if state.reviews.last().is_some_and(|r| {
660            r.final_votes()
661                .iter()
662                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
663        }) {
664            reasons.push(ApprovalReason::FinalReject);
665        }
666    }
667    if state.land_approval.is_some() {
668        reasons.push(ApprovalReason::ExistingDecision);
669    }
670    if !state.merge_repairs.is_empty() {
671        reasons.push(ApprovalReason::AfterRepair);
672    }
673    reasons
674}
675
676/// Bound free-form approval evidence by characters, including an explicit
677/// ellipsis when abbreviated. Keep UTF-8 text intact in questions and panels.
678fn approval_excerpt(text: &str, max_chars: usize) -> String {
679    if text.chars().count() <= max_chars {
680        text.to_owned()
681    } else {
682        let mut excerpt: String = text.chars().take(max_chars.saturating_sub(1)).collect();
683        excerpt.push('…');
684        excerpt
685    }
686}
687
688/// Persisted verbatim in the question and panel at creation. Deputies and
689/// resumed questions use that snapshot, never reconstruct past policy from
690/// today's settings. Fixer claims and successful gates are not resolutions.
691fn approval_evidence(state: &RunState) -> String {
692    const TITLE_CHARS: usize = 100;
693    const REASON_CHARS: usize = 300;
694    let reason = |text: &str| approval_excerpt(text, REASON_CHARS);
695    let ja = words(&state.config.graph.language).html_lang == "ja";
696    let mut s = if ja {
697        "承認が必要な理由:\n"
698    } else {
699        "Approval required because:\n"
700    }
701    .to_owned();
702    for reason in approval_reasons(state) {
703        let label = match (reason, ja) {
704            (ApprovalReason::EveryMerge, false) => {
705                "land_approval=true: owner approval for every merge"
706            }
707            (ApprovalReason::EveryMerge, true) => {
708                "land_approval=true: すべてのマージに所有者の承認が必要"
709            }
710            (ApprovalReason::OpenBlockingFindings, false) => {
711                "hold_contested_merge=true: open Major-or-above internal findings"
712            }
713            (ApprovalReason::OpenBlockingFindings, true) => {
714                "hold_contested_merge=true: Major 以上の内部指摘が未解決"
715            }
716            (ApprovalReason::FinalReject, false) => {
717                "hold_contested_merge=true: final reviewer reject after reconsideration (even without Major findings)"
718            }
719            (ApprovalReason::FinalReject, true) => {
720                "hold_contested_merge=true: 再考後の最終票が reject(Major 指摘がなくても承認が必要)"
721            }
722            (ApprovalReason::ExistingDecision, false) => {
723                "existing owner decision: changing controls cannot bypass it"
724            }
725            (ApprovalReason::ExistingDecision, true) => {
726                "既存の所有者判断: 設定変更では迂回できません"
727            }
728            (ApprovalReason::AfterRepair, false) => {
729                "fresh owner approval after merge repair, even with an unchanged head"
730            }
731            (ApprovalReason::AfterRepair, true) => {
732                "マージ修復後の新しい所有者承認(head に変更がなくても必要)"
733            }
734        };
735        let control = match reason {
736            ApprovalReason::EveryMerge => Some(crate::config::OwnerHold::LandApproval),
737            ApprovalReason::OpenBlockingFindings | ApprovalReason::FinalReject => {
738                Some(crate::config::OwnerHold::HoldContestedMerge)
739            }
740            _ => None,
741        };
742        let source = control
743            .filter(|c| !c.base_value(&state.config.graph))
744            .and_then(|c| state.branch_owner_holds.iter().find(|h| h.control == c));
745        if let Some(source) = source {
746            if ja {
747                let _ = writeln!(
748                    s,
749                    "- {}=true(レビュー対象ブランチの {} 由来, {})",
750                    source.control.key(),
751                    source.file,
752                    source.head
753                );
754            } else {
755                let _ = writeln!(
756                    s,
757                    "- {}=true from the reviewed branch's {} ({})",
758                    source.control.key(),
759                    source.file,
760                    source.head
761                );
762            }
763        }
764        let _ = writeln!(s, "- {label}");
765    }
766    s.push_str(if ja {
767        "\n再考後の各レビュアーの最終票:\n"
768    } else {
769        "\nFinal reviewer votes after reconsideration:\n"
770    });
771    if let Some(round) = state.reviews.last() {
772        let votes = round.final_votes();
773        for r in &round.reviews {
774            let vote = votes
775                .iter()
776                .find(|(seat, _, _)| *seat == r.reviewer)
777                .map(|(_, _, v)| v.label())
778                .unwrap_or(if ja { "記録なし" } else { "not recorded" });
779            let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
780            let rv = round
781                .reconsideration
782                .iter()
783                .find(|rv| rv.reviewer == r.reviewer);
784            if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
785                let initial = r.vote.map(|v| v.label()).unwrap_or("?");
786                let _ = writeln!(
787                    s,
788                    "  {}: {initial} → {vote}",
789                    if ja { "再考" } else { "reconsideration" }
790                );
791                let _ = writeln!(
792                    s,
793                    "  {}: {}",
794                    if ja { "理由" } else { "reason" },
795                    reason(if rv.reason.trim().is_empty() {
796                        if ja { "記録なし" } else { "not recorded" }
797                    } else {
798                        &rv.reason
799                    })
800                );
801            } else {
802                let _ = writeln!(
803                    s,
804                    "  {}: {}",
805                    if ja {
806                        "初回票を保持・理由"
807                    } else {
808                        "initial vote retained; reason"
809                    },
810                    reason(if r.summary.trim().is_empty() {
811                        if ja { "記録なし" } else { "not recorded" }
812                    } else {
813                        &r.summary
814                    })
815                );
816                if let Some(rv) = rv {
817                    let _ = writeln!(
818                        s,
819                        "  {}: {}",
820                        if ja {
821                            "再投票の回答なし"
822                        } else {
823                            "no reconsideration vote"
824                        },
825                        reason(rv.failed.as_deref().unwrap_or(if ja {
826                            "理由の記録なし"
827                        } else {
828                            "reason not recorded"
829                        }))
830                    );
831                }
832            }
833            if let Some(failed) = &r.failed {
834                let _ = writeln!(
835                    s,
836                    "  {}: {}",
837                    if ja { "回答なし" } else { "no answer" },
838                    reason(failed)
839                );
840            }
841        }
842    } else {
843        s.push_str(if ja {
844            "レビュー記録なし\n"
845        } else {
846            "No review recorded\n"
847        });
848    }
849    s.push_str(if ja { "\n内部レビュー指摘(最終ラウンド): 採用申告や gate 成功だけでは個別指摘の解決確認にはなりません。\n" } else { "\nInternal review findings (last round): adoption claims or a passing gate alone do not confirm individual resolution.\n" });
850    let findings = state.last_round_findings();
851    for f in &findings {
852        let _ = writeln!(
853            s,
854            "[{}] {} {}:{}: {}\n{}",
855            f.id,
856            f.severity,
857            f.file.as_deref().unwrap_or("?"),
858            f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
859            approval_excerpt(&f.title, TITLE_CHARS),
860            reason(&f.detail)
861        );
862        let mut evidence = false;
863        for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
864            state
865                .operator_fixes
866                .iter()
867                .filter_map(|request| request.fix.as_ref()),
868        ) {
869            if fix.addressed.contains(&f.id) {
870                evidence = true;
871                let _ = writeln!(
872                    s,
873                    "  {} ({}): {}",
874                    if ja {
875                        "採用申告(解決未確認)"
876                    } else {
877                        "claimed addressed (resolution unconfirmed)"
878                    },
879                    fix.agent,
880                    reason(&fix.notes)
881                );
882            }
883            for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
884                evidence = true;
885                let _ = writeln!(
886                    s,
887                    "  {}: {}",
888                    if ja { "不採用理由" } else { "declined" },
889                    reason(&rejection.why)
890                );
891            }
892        }
893        if !evidence {
894            s.push_str(if ja {
895                "  採用・解決確認の記録なし\n"
896            } else {
897                "  No adoption or resolution evidence recorded\n"
898            });
899        }
900    }
901    if findings.is_empty() {
902        s.push_str(if ja {
903            "最終ラウンドに指摘の記録なし\n"
904        } else {
905            "No findings recorded in the last round\n"
906        });
907    }
908    s
909}
910
911/// Shared choices for web, CLI and consultation questions.
912pub fn approval_choices(state: &RunState) -> Vec<String> {
913    let mut choices = vec![
914        APPROVE.to_owned(),
915        HOLD.to_owned(),
916        REPAIR.to_owned(),
917        crate::merge_decision::WITHDRAW.to_owned(),
918        crate::merge_decision::DISMISS.to_owned(),
919    ];
920    choices.extend(
921        state
922            .config
923            .agents
924            .iter()
925            .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
926    );
927    choices
928}
929
930/// Extend unanswered legacy cards in place. Answered cards keep their choices;
931/// a legacy repair answer gains only its missing dispatch action.
932pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
933    if q.node != APPROVAL_NODE
934        || !state
935            .land_approval
936            .as_ref()
937            .is_some_and(|a| a.question == q.id)
938    {
939        return false;
940    }
941    let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
942    if q.status.open() {
943        // Older cards attached Resume to hold; a hold must remain answerable
944        // without dispatching a question action.
945        q.actions.remove(HOLD);
946        for choice in approval_choices(state) {
947            if !q.choices.contains(&choice) {
948                q.choices.push(choice);
949            }
950        }
951        if q.merge_target.is_none() {
952            q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
953                pr: pr.url.clone(),
954                head: state.land_approval.as_ref().unwrap().head.clone(),
955                language: state.config.graph.language.clone(),
956            });
957        }
958    }
959    for choice in q.choices.clone() {
960        if (q.status.open()
961            && crate::merge_decision::stops(&choice)
962            && choice != HOLD
963            && choice != crate::merge_decision::DISMISS)
964            || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
965        {
966            q.actions.insert(
967                choice,
968                ask::ChoiceAction::Resume {
969                    run: state.id.clone(),
970                },
971            );
972        }
973    }
974    before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
975}
976
977/// Graph node recorded on the approval question.
978///
979/// The phone keys its high-stakes card off this rather than off the choice
980/// strings, so renaming a button cannot silently downgrade the card that
981/// guards the one irreversible action magi takes.
982pub const APPROVAL_NODE: &str = "land-approval";
983
984/// Unified diff lines carried in the panel before it is truncated.
985///
986/// Four hundred: the panel is read on a 390px phone, where a diff line often
987/// wraps to two rows, so this is already a few thousand rows of scrolling -
988/// past that nobody is reading, and the bytes still count against the panel's
989/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
990/// cut and which worktree holds the whole patch.
991pub const DIFF_MAX_LINES: usize = 400;
992
993/// What the owner's answer to the approval question means.
994#[derive(Debug, Clone, Copy, PartialEq, Eq)]
995pub enum Approval {
996    /// The owner said [`APPROVE`]. Merge.
997    Merge,
998    /// Repair the existing PR, then ask again; never approval to merge.
999    Repair,
1000    /// Anything else, including silence. Leave the pull request open.
1001    Hold,
1002}
1003
1004/// Read the owner's answer, where `None` is an unanswered question.
1005///
1006/// Silence is a hold. A timed-out question means the owner never saw it or
1007/// never decided, and defaulting an irreversible merge to "yes" would make this
1008/// gate worse than no gate at all: it would merge unattended while claiming to
1009/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
1010/// function does not recognise holds too.
1011pub fn approval(answer: Option<&str>) -> Approval {
1012    match answer {
1013        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1014        Some(a) if a.trim() == REPAIR => Approval::Repair,
1015        _ => Approval::Hold,
1016    }
1017}
1018
1019/// What [`approval_gate`] found on one check of the owner's merge decision.
1020#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1021enum ApprovalGate {
1022    /// The owner said [`APPROVE`]. Merge.
1023    Approved,
1024    /// The owner explicitly requested repair, without authorizing a merge.
1025    Repair,
1026    /// The owner said anything else, the question timed out, or it was
1027    /// closed with no decision recorded.
1028    Held,
1029    /// Filed and still waiting - the caller parks rather than blocking on it.
1030    Pending,
1031}
1032
1033/// Escape text for HTML, including both quote characters.
1034///
1035/// Every string in the panel is agent-influenced: a branch name, a file path, a
1036/// commit subject, a review comment. The sandboxed frame stops such text from
1037/// *running*, but it does not stop a `<` from ending the document early or a
1038/// `"` from ending an attribute and inventing a new one - the panel would then
1039/// render a lie, or not render at all. Both quotes are escaped because the same
1040/// function is used inside attributes, where remembering which quote style the
1041/// caller used is one mistake away from an injected attribute.
1042fn esc(s: &str) -> String {
1043    let mut out = String::with_capacity(s.len());
1044    for c in s.chars() {
1045        match c {
1046            '&' => out.push_str("&amp;"),
1047            '<' => out.push_str("&lt;"),
1048            '>' => out.push_str("&gt;"),
1049            '"' => out.push_str("&quot;"),
1050            '\'' => out.push_str("&#39;"),
1051            _ => out.push(c),
1052        }
1053    }
1054    out
1055}
1056
1057/// One row of the diffstat table.
1058#[derive(Debug, Clone, PartialEq, Eq)]
1059struct StatRow {
1060    path: String,
1061    /// `None` for a binary file, which `git` reports as `-`.
1062    added: Option<u64>,
1063    removed: Option<u64>,
1064}
1065
1066impl StatRow {
1067    /// Lines touched, for sorting. A binary file counts as zero rather than as
1068    /// unknown, which puts it at the bottom where it needs no attention.
1069    fn churn(&self) -> u64 {
1070        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1071    }
1072}
1073
1074/// Parse `git diff --numstat` into rows, biggest churn first.
1075///
1076/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
1077/// terminal width, so counting its characters would print fabricated numbers in
1078/// the one table an operator approves an irreversible action from.
1079fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1080    let mut rows: Vec<StatRow> = numstat
1081        .lines()
1082        .filter_map(|line| {
1083            let mut parts = line.splitn(3, '\t');
1084            let added = parts.next()?.trim();
1085            let removed = parts.next()?.trim();
1086            let path = parts.next()?.trim();
1087            if path.is_empty() {
1088                return None;
1089            }
1090            Some(StatRow {
1091                path: path.to_owned(),
1092                added: added.parse().ok(),
1093                removed: removed.parse().ok(),
1094            })
1095        })
1096        .collect();
1097    // Path breaks the tie so the same change always renders the same table; an
1098    // operator comparing two panels should not see rows shuffle.
1099    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1100    rows
1101}
1102
1103/// How one diff line is shown: a gutter character, a style, and the body to
1104/// print - which is the line minus its marker, so the marker appears exactly
1105/// once, in the gutter.
1106///
1107/// The gutter is why this exists at all. The operator may be colour blind, or
1108/// reading in sunlight with the screen dimmed, so an added line is never
1109/// distinguished by its background alone: `+` and `-` sit in a fixed column,
1110/// the same mark they already read in a terminal.
1111fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1112    if line.starts_with("+++") || line.starts_with("---") {
1113        (" ", "color:#57606a;font-weight:600", line)
1114    } else if let Some(body) = line.strip_prefix('+') {
1115        ("+", "background:#e6ffec;color:#0a3622", body)
1116    } else if let Some(body) = line.strip_prefix('-') {
1117        ("-", "background:#ffebe9;color:#5c1a17", body)
1118    } else if line.starts_with("@@") {
1119        ("~", "background:#eef2ff;color:#3730a3", line)
1120    } else if let Some(body) = line.strip_prefix(' ') {
1121        (" ", "", body)
1122    } else {
1123        (" ", "color:#57606a;font-weight:600", line)
1124    }
1125}
1126
1127/// The handful of words the approval panel says in its own voice.
1128///
1129/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
1130/// the operator asked why the merge question spoke English on a repository
1131/// configured for Japanese, and "because that string is a literal in Rust" is
1132/// not an answer. Only the languages magi can actually check are translated;
1133/// anything else falls back to English rather than shipping a guess, and that
1134/// fallback is deliberate.
1135struct Words {
1136    html_lang: &'static str,
1137    task: &'static str,
1138    what_changed: &'static str,
1139    review_verdict: &'static str,
1140    reviewer: &'static str,
1141    reviewer_no_answer: &'static str,
1142    checks: &'static str,
1143    nothing_failing: &'static str,
1144    files_changed: &'static str,
1145    commits: &'static str,
1146    no_commits: &'static str,
1147    comments: &'static str,
1148    no_comments: &'static str,
1149    diff: &'static str,
1150    truncated: &'static str,
1151    lands_as: &'static str,
1152}
1153
1154const EN: Words = Words {
1155    html_lang: "en",
1156    task: "Task",
1157    what_changed: "What changed",
1158    review_verdict: "Review verdict",
1159    reviewer: "Reviewer",
1160    reviewer_no_answer: "produced no answer",
1161    checks: "Checks",
1162    nothing_failing: "Nothing failing.",
1163    files_changed: "file(s) changed",
1164    commits: "Commits being squashed",
1165    no_commits: "No commit subjects could be read from the branch.",
1166    comments: "External PR review comments",
1167    no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1168    diff: "Diff",
1169    truncated: "Truncated",
1170    lands_as: "They land as one commit titled",
1171};
1172
1173const JA: Words = Words {
1174    html_lang: "ja",
1175    task: "タスク",
1176    what_changed: "変更内容",
1177    review_verdict: "レビューの結論",
1178    reviewer: "レビュアー",
1179    reviewer_no_answer: "回答なし",
1180    checks: "チェック",
1181    nothing_failing: "失敗しているものはありません。",
1182    files_changed: "ファイル変更",
1183    commits: "squash されるコミット",
1184    no_commits: "ブランチからコミット件名を読めませんでした。",
1185    comments: "外部 PR レビューコメント",
1186    no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1187    diff: "差分",
1188    truncated: "省略",
1189    lands_as: "これらは次の件名の1コミットとして入ります:",
1190};
1191
1192impl Words {
1193    /// The clause after the merge subject. Split out because word order moves:
1194    /// Japanese puts the subject before the verb, so a shared template with a
1195    /// hole in the middle would read as machine translation.
1196    fn lands_as_tail(&self) -> &'static str {
1197        if self.html_lang == "ja" {
1198            "。この件名も承認の対象です。"
1199        } else {
1200            ", which you are approving too."
1201        }
1202    }
1203
1204    /// The question's own one-line summary, which is what a phone shows first.
1205    fn approval_summary(&self, number: u64, subject: &str) -> String {
1206        if self.html_lang == "ja" {
1207            format!("プルリクエスト #{number} をマージ: {subject}")
1208        } else {
1209            format!("merge pull request #{number}: {subject}")
1210        }
1211    }
1212
1213    /// The body under the summary, above the panel.
1214    fn approval_detail(
1215        &self,
1216        url: &str,
1217        base: &str,
1218        subject: &str,
1219        contested: Option<&ContestedHandoff>,
1220    ) -> String {
1221        let body = if self.html_lang == "ja" {
1222            format!(
1223                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1224                 できる状態です。差分の要約・パッチ・squash されるコミットは\
1225                 下のパネルにあります。"
1226            )
1227        } else {
1228            format!(
1229                "{url} is green and ready to squash into `{base}` as `{subject}`. \
1230                 The panel holds the diffstat, the patch and the commits being squashed."
1231            )
1232        };
1233        match contested {
1234            Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1235            None => body,
1236        }
1237    }
1238
1239    /// Why this question exists although merge approvals are off: the open
1240    /// blocking findings and who rejected. Short enough for a phone.
1241    fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1242        const SHOWN: usize = 5;
1243        const TITLE_CHARS: usize = 100;
1244        let ja = self.html_lang == "ja";
1245        let mut out = if ja {
1246            format!(
1247                "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1248                 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1249            )
1250        } else {
1251            format!(
1252                "{url} is held for approval although merge approvals are off: the \
1253                 review ended with blocking findings still open and a reviewer \
1254                 voting reject.\n"
1255            )
1256        };
1257        for f in c.findings.iter().take(SHOWN) {
1258            let at = match (&f.file, f.line) {
1259                (Some(file), Some(line)) => format!("{file}:{line}"),
1260                (Some(file), None) => file.clone(),
1261                _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1262            };
1263            let title: String = f.title.chars().take(TITLE_CHARS).collect();
1264            let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1265        }
1266        if c.findings.len() > SHOWN {
1267            let more = c.findings.len() - SHOWN;
1268            let _ = writeln!(
1269                out,
1270                "{}",
1271                if ja {
1272                    format!("- ほか {more} 件")
1273                } else {
1274                    format!("- and {more} more")
1275                }
1276            );
1277        }
1278        let seats: Vec<String> = c
1279            .rejecters
1280            .iter()
1281            .map(|(seat, agent)| format!("#{seat} ({agent})"))
1282            .collect();
1283        let _ = write!(
1284            out,
1285            "{} {}",
1286            if ja {
1287                "却下したレビュアー:"
1288            } else {
1289                "Rejected by reviewer:"
1290            },
1291            seats.join(", ")
1292        );
1293        out
1294    }
1295
1296    /// The truncation note, written whole in each language for the same reason.
1297    fn truncated_note(
1298        &self,
1299        omitted: usize,
1300        total: usize,
1301        shown: usize,
1302        where_: &str,
1303        base: &str,
1304        head: &str,
1305    ) -> String {
1306        if self.html_lang == "ja" {
1307            format!(
1308                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1309                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1310                 プルリクエストにあります。"
1311            )
1312        } else {
1313            format!(
1314                "{omitted} of {total} diff lines omitted after the first {shown}. \
1315                 The whole patch is in <code>{where_}</code> \
1316                 (<code>git diff {base}...{head}</code>) and on the pull request."
1317            )
1318        }
1319    }
1320}
1321
1322/// Pick the panel's language. Codes and names both, because `[graph] language`
1323/// has always accepted either.
1324fn words(language: &str) -> &'static Words {
1325    if crate::lang::is_japanese(language) {
1326        &JA
1327    } else {
1328        &EN
1329    }
1330}
1331
1332/// The approval panel's html: what is about to land, and the evidence for it.
1333///
1334/// Pure, so the whole document is asserted in tests without `gh`, without a
1335/// network and without a repository. The caller gathers `diffstat`
1336/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
1337/// being squashed) and `subject` (what the squash will be called) from the
1338/// winner's worktree.
1339///
1340/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
1341/// content security policy blocks all three: anything of the sort here would be
1342/// dead markup that misleads the next reader into thinking it works.
1343pub fn approval_panel(
1344    state: &RunState,
1345    pr: &PrState,
1346    diffstat: &str,
1347    diff: &str,
1348    commits: &[String],
1349    subject: &str,
1350) -> String {
1351    let rows = parse_numstat(diffstat);
1352    let w = words(&state.config.graph.language);
1353    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1354
1355    let _ = writeln!(
1356        h,
1357        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1358         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1359        w.html_lang
1360    );
1361    let _ = writeln!(
1362        h,
1363        "<title>merge #{} — {}</title>\n</head>",
1364        pr.number,
1365        esc(subject)
1366    );
1367    h.push_str(
1368        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1369         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1370         word-break:break-word\">\n",
1371    );
1372
1373    // The decision, in the words the operator is approving.
1374    let _ = writeln!(
1375        h,
1376        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1377         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1378         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1379         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1380         <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1381        pr.number,
1382        esc(&state.base_branch),
1383        esc(subject),
1384        esc(&state.id),
1385        esc(&pr.url),
1386        esc(&pr.url),
1387    );
1388
1389    let _ = writeln!(
1390        h,
1391        "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1392        esc(&approval_evidence(state))
1393    );
1394
1395    // The task, verbatim: the operator's own words for what was asked, so the
1396    // panel does not make them reconstruct the request from a diffstat.
1397    let _ = writeln!(
1398        h,
1399        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1400         <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1401        w.task,
1402        esc(&state.instruction)
1403    );
1404
1405    // The winner's own account of what it did and why, when there is one.
1406    if let Some(summary) = state
1407        .winner()
1408        .map(|c| c.summary.as_str())
1409        .filter(|s| !s.is_empty())
1410    {
1411        let _ = writeln!(
1412            h,
1413            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1414             <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1415            w.what_changed,
1416            esc(summary)
1417        );
1418    }
1419
1420    // Only the last round's reviewer prose is current. Its votes and
1421    // remaining findings are shown explicitly in the evidence above.
1422    if let Some(round) = state.reviews.last() {
1423        let _ = writeln!(
1424            h,
1425            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1426            w.review_verdict
1427        );
1428        for r in &round.reviews {
1429            // A seat the review loop counted as answered has real prose in
1430            // `summary`; one it counted against `incomplete` (see
1431            // `graph::Runner::review_loop`) never produced any and left it
1432            // empty - which must not be read back as a blank verdict, since
1433            // an empty box here looks like "nothing to say" rather than
1434            // "never answered".
1435            let body = match &r.failed {
1436                Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1437                None => esc(&r.summary),
1438            };
1439            let _ = writeln!(
1440                h,
1441                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1442                 border-radius:6px\">\
1443                 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1444                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1445                w.reviewer,
1446                r.reviewer,
1447                esc(&r.agent),
1448                body,
1449            );
1450        }
1451    }
1452
1453    let _ = writeln!(
1454        h,
1455        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1456        w.checks,
1457        esc(pr.checks.as_str())
1458    );
1459    if pr.failing.is_empty() {
1460        let _ = writeln!(
1461            h,
1462            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1463            w.nothing_failing
1464        );
1465    } else {
1466        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1467        for f in &pr.failing {
1468            let _ = writeln!(h, "<li>{}</li>", esc(f));
1469        }
1470        h.push_str("</ul>\n");
1471    }
1472
1473    // Diffstat as a real table, so a phone reads what moved without scrolling
1474    // sideways through a terminal bar chart.
1475    let _ = writeln!(
1476        h,
1477        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1478        rows.len(),
1479        w.files_changed
1480    );
1481    h.push_str(
1482        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1483         <thead><tr>\
1484         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1485         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1486         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1487         </th></tr></thead>\n<tbody>\n",
1488    );
1489    let mut total_added = 0u64;
1490    let mut total_removed = 0u64;
1491    for r in &rows {
1492        total_added += r.added.unwrap_or(0);
1493        total_removed += r.removed.unwrap_or(0);
1494        let cell = |n: Option<u64>| match n {
1495            Some(n) => n.to_string(),
1496            None => "bin".to_owned(),
1497        };
1498        let _ = writeln!(
1499            h,
1500            "<tr>\
1501             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1502             font-family:ui-monospace,monospace\">{}</td>\
1503             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1504             color:#0a3622\">{}</td>\
1505             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1506             color:#5c1a17\">{}</td></tr>",
1507            esc(&r.path),
1508            cell(r.added),
1509            cell(r.removed),
1510        );
1511    }
1512    let _ = writeln!(
1513        h,
1514        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1515         <td style=\"padding:4px 2px\">total</td>\
1516         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1517         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1518         </tr></tfoot>\n</table>"
1519    );
1520
1521    // The commits being squashed, and the subject that replaces them.
1522    let _ = writeln!(
1523        h,
1524        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1525        w.commits
1526    );
1527    if commits.is_empty() {
1528        h.push_str(&format!(
1529            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1530            w.no_commits
1531        ));
1532    } else {
1533        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1534        for c in commits {
1535            let _ = writeln!(h, "<li>{}</li>", esc(c));
1536        }
1537        h.push_str("</ol>\n");
1538    }
1539    let _ = writeln!(
1540        h,
1541        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1542        w.lands_as,
1543        esc(subject),
1544        w.lands_as_tail()
1545    );
1546
1547    // The review comments that shaped this branch, and who asked for them.
1548    let _ = writeln!(
1549        h,
1550        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1551        w.comments
1552    );
1553    if pr.review_comments.is_empty() {
1554        h.push_str(&format!(
1555            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1556            w.no_comments
1557        ));
1558    } else {
1559        for c in &pr.review_comments {
1560            let anchor = match (&c.path, c.line) {
1561                (Some(p), Some(l)) => format!("{p}:{l}"),
1562                (Some(p), None) => p.clone(),
1563                _ => "pull request thread".to_owned(),
1564            };
1565            let _ = writeln!(
1566                h,
1567                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1568                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1569                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1570                esc(&c.author),
1571                esc(&anchor),
1572                esc(&tail(&c.body, 800)),
1573            );
1574        }
1575    }
1576
1577    // The patch itself.
1578    let total = diff.lines().count();
1579    let shown = total.min(DIFF_MAX_LINES);
1580    let _ = writeln!(
1581        h,
1582        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1583        w.diff
1584    );
1585    h.push_str(
1586        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1587         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1588    );
1589    for line in diff.lines().take(shown) {
1590        let (gutter, style, body) = diff_row(line);
1591        let _ = writeln!(
1592            h,
1593            "<div style=\"display:flex;{style}\">\
1594             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1595             border-right:1px solid #d0d7de\">{gutter}</span>\
1596             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1597            esc(body),
1598        );
1599    }
1600    h.push_str("</div>\n");
1601    if total > shown {
1602        let omitted = total - shown;
1603        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1604        let where_ = state.winner().map_or_else(
1605            || state.repo.display().to_string(),
1606            |w| w.worktree.display().to_string(),
1607        );
1608        let _ = writeln!(
1609            h,
1610            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1611             font-size:13px\">{}: {}</p>",
1612            w.truncated,
1613            w.truncated_note(
1614                omitted,
1615                total,
1616                shown,
1617                &esc(&where_),
1618                &esc(&state.base_branch),
1619                &esc(head),
1620            ),
1621        );
1622    }
1623
1624    h.push_str("</body>\n</html>\n");
1625    h
1626}
1627
1628/// Historical contested hand-off evidence for the question detail. Approval
1629/// itself is decided by `approval_reasons`, including reject-only cases.
1630fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1631    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
1632        state.contested_handoff.clone()
1633    } else {
1634        None
1635    }
1636}
1637
1638/// What a merge-approval question's deputy is told: the pull request, the run,
1639/// what each answer does, and the evidence saved when the question was filed.
1640///
1641/// Rebuilt for every deputy turn, it is still a snapshot of that moment, so it
1642/// says so and points at `magi show` / `gh pr view` for anything current.
1643/// `state` is `None` for a run that cannot be read; what is missing is named as
1644/// missing, and no past approval basis is rebuilt from today's state.
1645pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1646    let mut s = format!(
1647        "This is the merge approval for run {run} (`magi show {run}`). The question's \
1648         own text above names the pull request. Answering `{APPROVE}` squash-merges \
1649         it into the base branch; `{HOLD}` leaves the pull request open. Silence is a \
1650         hold: the owner not answering never merges. When the owner tells you to \
1651         merge, in whatever words, settle `{APPROVE}`; when they ask for what another \
1652         offered choice does, settle that one. If you cannot tell what they want, ask \
1653         with `magi ask --thread`.\n\n\
1654         This brief is a snapshot from when your turn started: check `magi show {run}` \
1655         and `gh pr view` (read-only) before telling the owner anything current. The \
1656         driver carries out the merge, repair, withdrawal or replacement: do not \
1657         change files, branches or the pull request yourself.",
1658        run = q.run
1659    );
1660    if let Some(target) = &q.merge_target {
1661        s.push_str(&format!("\n\nThis decision names PR {} at head `{}`. A settled `{APPROVE}` merges exactly that head; a newer head is never merged on this answer. Withdrawal explicitly closes the PR and ends the task. Replacement closes the old PR before dispatching a new attempt with the selected implementer; do not start a separate task for it yourself.", target.pr, target.head));
1662    }
1663    if q.choices.iter().any(|c| c == REPAIR) {
1664        s.push_str("\n\n`fix findings and re-review` requests one repair and bounded re-review on the existing PR. It never approves a merge. The driver performs the repair; do not edit or queue another task for it. Updated review and gate results require a fresh merge decision.");
1665    }
1666    if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1667    {
1668        s.push_str("\n\nSaved approval evidence (at question creation):\n");
1669        s.push_str(&q.detail);
1670    } else {
1671        s.push_str("\n\nApproval reasons and final review evidence were not recorded in this legacy question. Do not infer its original trigger from current settings; consult the saved panel and review history.");
1672    }
1673    let Some(state) = state else {
1674        s.push_str(
1675            "\n\nThe run's record could not be read, so the pull request, the panel \
1676             summary and any contested findings are not known to you beyond the \
1677             question's own text. Say so to the owner rather than guessing.",
1678        );
1679        return s;
1680    };
1681    if let Some(pr) = &state.pr {
1682        s.push_str(&format!(
1683            "\n\nPull request #{} {} (recorded state: {}, last seen).",
1684            pr.number, pr.url, pr.state
1685        ));
1686    }
1687    s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1688    if let Some(w) = state.winner() {
1689        s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1690    }
1691
1692    s
1693}
1694
1695// Releasing a task permits work to resume; it does not renew merge consent.
1696// Check the persisted operator decision both when reusing a card and at the
1697// final merge boundary, since a hold/release can arrive between those reads.
1698fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1699    if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1700        return Ok(false);
1701    }
1702    let Some(task_id) = state
1703        .origin
1704        .as_ref()
1705        .and_then(|origin| origin.task.as_ref())
1706    else {
1707        return Ok(false);
1708    };
1709    let task = crate::queue::Queue::open()
1710        .get(task_id)
1711        .context("cannot verify operator decisions for the merge approval")?;
1712    Ok(task
1713        .operator_decided_at
1714        .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1715}
1716
1717/// Ask the owner before merging, with the whole case attached as a panel.
1718///
1719/// The evidence is gathered from the winner's own worktree with the `git` CLI,
1720/// never from the network, so a phone on a slow link gets the diff magi is
1721/// looking at rather than a link it has to go and open.
1722///
1723/// Never blocks. `land` used to sit inside [`ask::ask_and_wait`]'s poll loop
1724/// for up to a day right here, which held the whole run's task claim - and
1725/// the daemon's one slot with it - for exactly as long as the owner took to
1726/// notice their phone. [`ApprovalGate::Pending`] is the answer that lets the
1727/// caller park the run and hand the slot back instead: the question is on
1728/// disk either way, so nothing about the wait itself changes, only who is
1729/// blocked on it.
1730///
1731/// Idempotent across resumes: called again for a run already waiting on its
1732/// own question, this finds that question by [`crate::ask::Questions::list`]
1733/// rather than filing a second one - asking twice would double the
1734/// notification for one decision, and leave the first question's panel an
1735/// orphan nobody's answer ever reaches.
1736async fn approval_gate(
1737    state: &mut RunState,
1738    pr: &PrState,
1739    subject: &str,
1740    contested: Option<&ContestedHandoff>,
1741    head: &str,
1742) -> Result<ApprovalGate> {
1743    let _chat_merge = crate::chat_merge::target_lock_wait(&crate::run::home(), &pr.url).await?;
1744    let store = ask::Questions::open();
1745    // An answer belongs to the commit its panel showed. A question recorded
1746    // for another head - or none recorded at all, as for a question filed
1747    // before heads were tracked - is never reused: a fix or rebase push made
1748    // a commit the owner has not seen, and their word does not carry over.
1749    let reusable = state.land_approval.as_ref().and_then(|a| {
1750        store
1751            .list()
1752            .into_iter()
1753            .find(|q| q.id == a.question)
1754            .filter(|q| {
1755                q.status != ask::QuestionStatus::Abandoned
1756                    && (a.head.eq_ignore_ascii_case(head)
1757                        || q.resolution()
1758                            .as_deref()
1759                            .is_some_and(crate::merge_decision::stops))
1760            })
1761    });
1762    let reusable = match reusable {
1763        Some(q) if merge_consent_superseded(state, &q)? => None,
1764        other => other,
1765    };
1766    if reusable.is_none() {
1767        for stale in store
1768            .list()
1769            .into_iter()
1770            .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1771        {
1772            let why =
1773                "the pull request head or operator decision changed; asked again for approval";
1774            if let Err(e) = store.update(&stale.id, |q| {
1775                q.abandon(why);
1776                Ok(())
1777            }) {
1778                tracing::warn!("could not retire the superseded approval question: {e:#}");
1779            }
1780        }
1781    }
1782
1783    let q = match reusable {
1784        Some(mut q) => {
1785            if upgrade_repair_choice(&mut q, state) {
1786                q = store
1787                    .update(&q.id, |current| {
1788                        upgrade_repair_choice(current, state);
1789                        Ok(())
1790                    })?
1791                    .0;
1792            }
1793            q
1794        }
1795        None => {
1796            let worktree = match state.winner() {
1797                Some(w) => w.worktree.clone(),
1798                None => state.repo.clone(),
1799            };
1800            // The diff is built from the commit being approved, not from the
1801            // branch name, which may already point somewhere else.
1802            let head = if head.is_empty() {
1803                state
1804                    .winner()
1805                    .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1806            } else {
1807                head.to_owned()
1808            };
1809            // The diff is against what the remote's base holds, never the
1810            // local branch of that name; unreadable means less evidence.
1811            let remote = &state.config.merge.remote;
1812            let tracking = format!("{remote}/{}", state.base_branch);
1813            let base = if git::rev_exists(&worktree, &tracking).await {
1814                tracking
1815            } else {
1816                String::new()
1817            };
1818            let range = format!("{base}...{head}");
1819            // A failed `git` must not decide the merge: the panel degrades to
1820            // less evidence and the owner still chooses. Merging because the
1821            // diff could not be read would be the worst of both.
1822            let numstat = if base.is_empty() {
1823                String::new()
1824            } else {
1825                git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1826                    .await
1827                    .map(|o| o.stdout)
1828                    .unwrap_or_default()
1829            };
1830            let diff = if base.is_empty() {
1831                String::new()
1832            } else {
1833                git::diff(&worktree, &base, &head).await.unwrap_or_default()
1834            };
1835            let commits: Vec<String> = if base.is_empty() {
1836                Vec::new()
1837            } else {
1838                git::git_raw(
1839                    &worktree,
1840                    &[
1841                        "log",
1842                        "--reverse",
1843                        "--format=%s",
1844                        &format!("{base}..{head}"),
1845                    ],
1846                )
1847                .await
1848                .map(|o| o.stdout)
1849                .unwrap_or_default()
1850                .lines()
1851                .filter(|l| !l.trim().is_empty())
1852                .map(str::to_owned)
1853                .collect()
1854            };
1855
1856            let w = words(&state.config.graph.language);
1857            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1858            let mut fresh = ask::Question::new(
1859                state.id.clone(),
1860                APPROVAL_NODE.to_owned(),
1861                "land".to_owned(),
1862                w.approval_summary(pr.number, subject),
1863                format!(
1864                    "{}\n\n{}",
1865                    approval_evidence(state),
1866                    w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1867                ),
1868                approval_choices(state),
1869            );
1870            fresh.merge_target = Some(crate::merge_decision::Target {
1871                pr: pr.url.clone(),
1872                head: head.clone(),
1873                language: state.config.graph.language.clone(),
1874            });
1875            if fresh.choices.iter().any(|c| c == REPAIR) {
1876                fresh.actions.insert(
1877                    REPAIR.to_owned(),
1878                    ask::ChoiceAction::Resume {
1879                        run: state.id.clone(),
1880                    },
1881                );
1882                fresh.detail.push_str("\n\n`fix findings and re-review` runs one repair and bounded review on this PR; it does not approve a merge. A fresh decision follows the updated review and gate.");
1883            }
1884            for choice in fresh.choices.clone() {
1885                if crate::merge_decision::stops(&choice)
1886                    && choice != HOLD
1887                    && choice != crate::merge_decision::DISMISS
1888                {
1889                    fresh.actions.insert(
1890                        choice,
1891                        ask::ChoiceAction::Resume {
1892                            run: state.id.clone(),
1893                        },
1894                    );
1895                }
1896            }
1897            store
1898                .put_panel(&mut fresh, &html, &[])
1899                .context("write the merge approval panel")?;
1900            store
1901                .put_executable(&mut fresh)
1902                .context("file the merge approval question")?;
1903            state.land_approval = Some(LandApproval {
1904                question: fresh.id.clone(),
1905                head: head.clone(),
1906            });
1907            state.event(
1908                "land",
1909                format!("asking for merge approval ({})", fresh.short()),
1910            );
1911            state.save()?;
1912            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1913                // A broken webhook is not a reason to lose the merge: the
1914                // question is already on disk and the web UI already shows
1915                // it, so the operator still has a way in.
1916                tracing::warn!(
1917                    "could not notify about merge approval question {}: {e:#} - \
1918                     the web UI is the only surface for it now",
1919                    fresh.short()
1920                );
1921            }
1922            fresh
1923        }
1924    };
1925
1926    Ok(match q.status {
1927        ask::QuestionStatus::Open => ApprovalGate::Pending,
1928        // Nobody answered before `state.config.graph.answer_timeout` passed,
1929        // or the question was closed with no decision recorded underneath
1930        // this run - either way there is nothing left to wait on.
1931        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1932        // The merge gate does not speak `--thread`: an owner who talked back
1933        // instead of choosing never reaches `Answered`, so this arm only
1934        // ever sees an actual decision.
1935        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1936            Approval::Merge => ApprovalGate::Approved,
1937            Approval::Repair if !state.reviews.is_empty() => {
1938                if !state.merge_repairs.iter().any(|r| r.question == q.id) {
1939                    state.merge_repairs.push(crate::run::MergeRepair {
1940                        question: q.id.clone(),
1941                        head: head.to_owned(),
1942                        pr: pr.url.clone(),
1943                        reviews: state.reviews.clone(),
1944                        gate: state.gate.clone(),
1945                        gate_head: state.gate_head.clone(),
1946                        gate_fixes: state.gate_fixes.clone(),
1947                        pre_gate: state.pre_gate.clone(),
1948                        phase: crate::run::MergeRepairPhase::Requested,
1949                        failure: None,
1950                    });
1951                    state.land_approval = None;
1952                    state.event(
1953                        "repair",
1954                        "owner requested fix findings and re-review; merge is not approved",
1955                    );
1956                    state.save()?;
1957                }
1958                ApprovalGate::Repair
1959            }
1960            Approval::Repair | Approval::Hold => ApprovalGate::Held,
1961        },
1962    })
1963}
1964
1965/// Fold a rollup's entries into one verdict plus the failing checks' labels.
1966/// No I/O. An empty rollup is `Unknown`, never green.
1967fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1968    let mut failing = Vec::new();
1969    let mut pending = false;
1970    let mut unknown = false;
1971    for check in rollup {
1972        match check.verdict() {
1973            Verdict::Pass => {}
1974            Verdict::Pending => pending = true,
1975            Verdict::Fail => failing.push(check.label()),
1976            Verdict::Unknown => unknown = true,
1977        }
1978    }
1979    let checks = if rollup.is_empty() {
1980        Checks::Unknown
1981    } else if pending {
1982        Checks::Pending
1983    } else if !failing.is_empty() {
1984        Checks::Red
1985    } else if unknown {
1986        Checks::Unknown
1987    } else {
1988        Checks::Green
1989    };
1990    (checks, failing)
1991}
1992
1993/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
1994/// output into a [`PrState`]. No I/O.
1995pub fn parse_pr(json: &str) -> Result<PrState> {
1996    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1997    let state = match raw.state.to_ascii_uppercase().as_str() {
1998        "OPEN" => PrLifecycle::Open,
1999        "MERGED" => PrLifecycle::Merged,
2000        "CLOSED" => PrLifecycle::Closed,
2001        other => bail!("unknown pull request state `{other}`"),
2002    };
2003
2004    let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2005
2006    let mut review_comments = Vec::new();
2007    for r in raw.reviews {
2008        push_if_outstanding(
2009            &mut review_comments,
2010            ReviewComment {
2011                author: r.author.login,
2012                path: None,
2013                line: None,
2014                body: r.body,
2015            },
2016        );
2017    }
2018    for c in raw.comments {
2019        push_if_outstanding(
2020            &mut review_comments,
2021            ReviewComment {
2022                author: c.author.login,
2023                path: None,
2024                line: None,
2025                body: c.body,
2026            },
2027        );
2028    }
2029
2030    Ok(PrState {
2031        url: raw.url,
2032        number: raw.number,
2033        state,
2034        checks,
2035        failing,
2036        review_comments,
2037        blocking: Blocking::of(&raw.merge_state_status),
2038    })
2039}
2040
2041/// One rollup entry as the release watcher reads it.
2042#[derive(Debug, Clone, PartialEq, Eq)]
2043pub(crate) struct CheckView {
2044    pub name: String,
2045    pub verdict: Verdict,
2046    /// Workflow run behind the check, when its url names one.
2047    pub run: Option<String>,
2048    pub url: Option<String>,
2049}
2050
2051/// A pull request's lifecycle, head commit and per-check verdicts, without
2052/// [`rollup_verdict`]'s aggregation (a pending check anywhere hides a failure
2053/// from it, which is exactly what the release watcher must not inherit).
2054#[derive(Debug, Clone, PartialEq, Eq)]
2055pub(crate) struct RollupView {
2056    pub url: String,
2057    pub number: u64,
2058    pub state: PrLifecycle,
2059    pub head: String,
2060    pub checks: Vec<CheckView>,
2061}
2062
2063/// Parse `gh pr view --json url,number,state,headRefOid,statusCheckRollup`
2064/// output. No I/O.
2065pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2066    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2067    let state = match raw.state.to_ascii_uppercase().as_str() {
2068        "OPEN" => PrLifecycle::Open,
2069        "MERGED" => PrLifecycle::Merged,
2070        "CLOSED" => PrLifecycle::Closed,
2071        other => bail!("unknown pull request state `{other}`"),
2072    };
2073    let checks = raw
2074        .status_check_rollup
2075        .iter()
2076        .map(|c| CheckView {
2077            name: c.label(),
2078            verdict: c.verdict(),
2079            run: c.url().and_then(run_of),
2080            url: c.url().map(str::to_owned),
2081        })
2082        .collect();
2083    Ok(RollupView {
2084        url: raw.url,
2085        number: raw.number,
2086        state,
2087        head: raw.head_ref_oid,
2088        checks,
2089    })
2090}
2091
2092/// Read just a pull request's lifecycle state - open, merged, or closed -
2093/// with none of the checks/reviews/comments [`land`] itself needs to decide
2094/// what to do next.
2095///
2096/// For a caller that only ever wants one fact and must not risk anything
2097/// else: `magi fold --merged` uses this to confirm a URL the operator hands
2098/// it is actually a merged pull request *before* touching a run's state, so a
2099/// typo or a still-open PR fails loudly instead of quietly recording a merge
2100/// that never happened.
2101pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2102    let view = gh(
2103        repo,
2104        &[
2105            "pr".to_owned(),
2106            "view".to_owned(),
2107            pr_url.to_owned(),
2108            "--json".to_owned(),
2109            "state".to_owned(),
2110        ],
2111    )
2112    .await?;
2113    if !view.0 {
2114        bail!("gh pr view {pr_url}: {}", view.1);
2115    }
2116    // `parse_pr` reads every other field of `GhPr` as its serde default
2117    // (empty string, empty vec, zero) when this narrower `--json` selection
2118    // does not carry them - harmless, since only `.state` is read back.
2119    Ok(parse_pr(&view.1)?.state)
2120}
2121
2122/// A pull request the operator merged outside of `land::land`'s own loop,
2123/// found by asking GitHub about the run's own winning branch rather than
2124/// requiring the operator to go and find the URL themselves.
2125#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2126pub struct ExternalMerge {
2127    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
2128    pub url: String,
2129    /// The pull request's number.
2130    pub number: u64,
2131}
2132
2133#[derive(Debug, Deserialize)]
2134#[serde(rename_all = "camelCase")]
2135struct GhMergedPr {
2136    url: String,
2137    number: u64,
2138    merged_at: String,
2139    base_ref_name: String,
2140}
2141
2142/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
2143/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
2144/// decide whether exactly one of the pull requests it lists could actually
2145/// be *this* run's.
2146///
2147/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
2148/// from the run's own short id, so a collision with some other, unrelated
2149/// task's merged pull request from a same-named branch is rare but not
2150/// impossible once branches are deleted and ids run out. Filtering on
2151/// `base_ref_name` (the branch this run actually targets) and `merged_at`
2152/// (which cannot predate the run itself) rules that case out. More than one
2153/// survivor is exactly as uninformative as zero — something this run cannot
2154/// tell apart from another — so only a unique survivor is returned.
2155fn pick_merged_pr(
2156    json: &str,
2157    base_branch: &str,
2158    created_at: Timestamp,
2159) -> Result<Option<ExternalMerge>> {
2160    let raw: Vec<GhMergedPr> =
2161        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2162    let mut matches: Vec<ExternalMerge> = Vec::new();
2163    for pr in raw {
2164        if pr.base_ref_name != base_branch {
2165            continue;
2166        }
2167        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2168            continue;
2169        };
2170        if merged_at < created_at {
2171            continue;
2172        }
2173        matches.push(ExternalMerge {
2174            url: pr.url,
2175            number: pr.number,
2176        });
2177    }
2178    if matches.len() == 1 {
2179        Ok(matches.pop())
2180    } else {
2181        Ok(None)
2182    }
2183}
2184
2185/// What `gh pr list --head <branch> --base <base> --state open` found.
2186#[derive(Debug, Clone, PartialEq, Eq)]
2187pub enum OpenPr {
2188    /// Nothing open: the caller creates one.
2189    None,
2190    /// Exactly one: the caller adopts it instead of creating a second.
2191    One {
2192        /// The pull request's URL.
2193        url: String,
2194        /// Its current title.
2195        title: String,
2196    },
2197    /// More than one: magi does not pick between them.
2198    Many(Vec<String>),
2199}
2200
2201#[derive(Debug, Deserialize)]
2202#[serde(rename_all = "camelCase")]
2203struct GhOpenPr {
2204    // `url` and `baseRefName` are required: a record missing either must be a
2205    // parse error, not a pull request that silently fails the base filter and
2206    // reads as "none open" (which would go on to create a duplicate).
2207    url: String,
2208    #[serde(default)]
2209    title: String,
2210    base_ref_name: String,
2211    // A fork's branch of the same name is not the task head; `gh pr list --head`
2212    // matches by branch name alone, so the filter has to be ours.
2213    #[serde(default)]
2214    is_cross_repository: bool,
2215}
2216
2217/// Pure half of [`find_open_pr`]: classify the raw `--json
2218/// number,url,title,baseRefName` output. Entries whose base is not `base` are
2219/// dropped even though the query already filtered on it, so a stub or an old
2220/// `gh` that ignores `--base` cannot get a pull request into the wrong branch
2221/// adopted.
2222pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2223    let raw: Vec<GhOpenPr> =
2224        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2225    let mut hits: Vec<GhOpenPr> = raw
2226        .into_iter()
2227        .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2228        .collect();
2229    Ok(match hits.len() {
2230        0 => OpenPr::None,
2231        1 => {
2232            let p = hits.remove(0);
2233            OpenPr::One {
2234                url: p.url,
2235                title: p.title,
2236            }
2237        }
2238        _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2239    })
2240}
2241
2242/// Open pull requests whose head is `branch` and whose base is `base`. A
2243/// failing `gh` is an error carrying its own output, never "none": guessing
2244/// there is how a duplicate gets created.
2245pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2246    let (ok, out) = gh(
2247        repo,
2248        &[
2249            "pr".to_owned(),
2250            "list".to_owned(),
2251            "--head".to_owned(),
2252            branch.to_owned(),
2253            "--base".to_owned(),
2254            base.to_owned(),
2255            "--state".to_owned(),
2256            "open".to_owned(),
2257            "--json".to_owned(),
2258            "number,url,title,baseRefName,isCrossRepository".to_owned(),
2259        ],
2260    )
2261    .await?;
2262    if !ok {
2263        bail!("gh pr list failed: {out}");
2264    }
2265    pick_open_pr(&out, base)
2266}
2267
2268#[derive(Debug, Deserialize)]
2269#[serde(rename_all = "camelCase")]
2270struct GhPrHead {
2271    head_ref_name: String,
2272    base_ref_name: String,
2273    state: String,
2274    // Required, like `GhOpenPr`'s fields: a record that cannot say whether the
2275    // head lives in a fork must be a parse error, never "same repository".
2276    is_cross_repository: bool,
2277    // Required too: it is what ties the pull request to the commits that were
2278    // actually proven to be on the base.
2279    head_ref_oid: String,
2280}
2281
2282/// Why [`closable`] said no, and whether asking again later could say yes.
2283#[derive(Debug, Clone, PartialEq, Eq)]
2284pub struct Refusal {
2285    /// A later attempt may succeed (the head moved, the view was unreadable);
2286    /// `false` means this pull request is simply not the run's to close.
2287    pub retry: bool,
2288    /// What was wrong.
2289    pub why: String,
2290}
2291
2292impl Refusal {
2293    fn final_(why: String) -> Self {
2294        Self { retry: false, why }
2295    }
2296}
2297
2298/// Pure half of [`close_superseded_pr`]: read `gh pr view --json
2299/// headRefName,baseRefName,state,isCrossRepository` and say whether closing
2300/// is safe, `Err` carrying the reason when it is not.
2301///
2302/// Closing is outward-facing, so every property is checked on what the forge
2303/// says now, not on what magi recorded: the head must be exactly `branch` in
2304/// this repository (a fork's branch of the same name is somebody else's), the
2305/// base must be `base`, and the pull request must still be open.
2306pub fn closable(
2307    json: &str,
2308    branch: &str,
2309    base: &str,
2310    verified: &[String],
2311) -> std::result::Result<(), Refusal> {
2312    let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2313        retry: true,
2314        why: format!("could not read the pull request ({e})"),
2315    })?;
2316    if pr.head_ref_name != branch {
2317        return Err(Refusal::final_(format!(
2318            "its head is `{}`, not this run's `{branch}`",
2319            pr.head_ref_name
2320        )));
2321    }
2322    if pr.is_cross_repository {
2323        return Err(Refusal::final_("its head lives in a fork".to_owned()));
2324    }
2325    if pr.base_ref_name != base {
2326        return Err(Refusal::final_(format!(
2327            "it targets `{}`, not `{base}`",
2328            pr.base_ref_name
2329        )));
2330    }
2331    if !pr.state.eq_ignore_ascii_case("open") {
2332        return Err(Refusal::final_(format!(
2333            "it is already {}",
2334            pr.state.to_ascii_lowercase()
2335        )));
2336    }
2337    // Last, so a pull request that is not this run's at all is reported as
2338    // such. A head that is this branch but not a commit checked against the
2339    // base (somebody pushed since) may well get checked next time: retry.
2340    if !verified.contains(&pr.head_ref_oid) {
2341        return Err(Refusal {
2342            retry: true,
2343            why: format!(
2344                "its head {} is not a commit this run checked against the base",
2345                crate::already::short_sha(&pr.head_ref_oid)
2346            ),
2347        });
2348    }
2349    Ok(())
2350}
2351
2352/// Does `remote` point at something a forge could host - a URL or an scp-style
2353/// `user@host:path` - rather than a filesystem path or nothing at all? Judged
2354/// from the URL alone, so it answers the same on every machine, whatever `gh`
2355/// happens to be installed or logged in to.
2356async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2357    let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2358        return false;
2359    };
2360    is_forge_url(url.trim())
2361}
2362
2363fn is_forge_url(url: &str) -> bool {
2364    url.contains("://") && !url.starts_with("file://")
2365        || url
2366            .split_once(':')
2367            .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2368}
2369
2370/// Does this `gh` failure mean there is no GitHub to ask, as opposed to a
2371/// request that failed?
2372fn forge_unavailable(message: &str) -> bool {
2373    message.contains("known GitHub host") || message.contains("spawn gh")
2374}
2375
2376/// The comment left on a pull request closed because its change is already on
2377/// the base.
2378pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2379    let how = match evidence.proof {
2380        crate::already::Proof::PatchId => format!(
2381            "carried by commit {} on `{base}` with the same patch",
2382            evidence.names()
2383        ),
2384        crate::already::Proof::Ancestry => {
2385            format!("already in the history of `{base}` as {}", evidence.names())
2386        }
2387        crate::already::Proof::Tree => format!(
2388            "already part of `{base}` (merging this branch changes nothing at {})",
2389            crate::already::short_sha(&evidence.tip)
2390        ),
2391    };
2392    format!(
2393        "Closing: everything this branch adds is {how}, so there is nothing left to \
2394         land. This pull request was closed automatically after that was verified; \
2395         reopen it if you disagree."
2396    )
2397}
2398
2399/// Close the open pull request for `branch`, if there is one and it is
2400/// provably this run's, with a comment naming what supersedes it. `Ok(Ok(url))` is
2401/// the URL closed; `Ok(Err(why))` is a final "nothing to close" (no pull request,
2402/// not this run's, no forge); `Err` is anything a later attempt could resolve (a
2403/// failed `gh` call, a head that moved since it was checked), which the caller
2404/// must not treat as settled.
2405///
2406/// The recorded `state.pr` is preferred, else the forge is asked for an open
2407/// pull request on `branch`; either way the candidate is re-read and passed
2408/// through [`closable`] before anything is written; `verified` lists the
2409/// commits proven to be on the base, and the pull request's head must be one.
2410pub async fn close_superseded_pr(
2411    state: &mut RunState,
2412    branch: &str,
2413    evidence: &crate::already::Evidence,
2414    verified: &[String],
2415) -> Result<std::result::Result<String, String>> {
2416    let repo = state.repo.clone();
2417    let base = state.base_branch.clone();
2418    let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2419        Some(p) => p.url.clone(),
2420        // No recorded pull request. A forge that cannot be asked at all (no
2421        // GitHub remote, no `gh`) says nothing about this run, so that is
2422        // "none found"; any other lookup failure is an error, because "could
2423        // not look" is not "nothing there" and the caller must retry.
2424        None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2425            return Ok(Err(
2426                "the remote is not a forge, so there is no pull request".to_owned(),
2427            ));
2428        }
2429        None => match find_open_pr(&repo, branch, &base).await {
2430            Err(e) if forge_unavailable(&format!("{e:#}")) => {
2431                return Ok(Err(format!("no forge to ask: {e:#}")));
2432            }
2433            Err(e) => return Err(e),
2434            Ok(OpenPr::One { url, .. }) => url,
2435            Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2436            Ok(OpenPr::Many(urls)) => {
2437                return Ok(Err(format!(
2438                    "{} open pull requests name it; not choosing between them",
2439                    urls.len()
2440                )));
2441            }
2442        },
2443    };
2444    let (ok, view) = gh(
2445        &repo,
2446        &[
2447            "pr".to_owned(),
2448            "view".to_owned(),
2449            url.clone(),
2450            "--json".to_owned(),
2451            "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2452        ],
2453    )
2454    .await?;
2455    if !ok {
2456        bail!("gh pr view {url} failed: {view}");
2457    }
2458    if let Err(refusal) = closable(&view, branch, &base, verified) {
2459        // A pull request whose head cannot be tied to what was proven is not
2460        // one to walk away from: the caller keeps the run resumable.
2461        if refusal.retry {
2462            bail!("left {url} open: {}", refusal.why);
2463        }
2464        return Ok(Err(format!("left {url} open: {}", refusal.why)));
2465    }
2466    let (ok, out) = gh(
2467        &repo,
2468        &[
2469            "pr".to_owned(),
2470            "close".to_owned(),
2471            url.clone(),
2472            "--comment".to_owned(),
2473            superseded_comment(&base, evidence),
2474        ],
2475    )
2476    .await?;
2477    if !ok {
2478        bail!("gh pr close {url} failed: {out}");
2479    }
2480    if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2481        p.state = "closed".to_owned();
2482    }
2483    Ok(Ok(url))
2484}
2485
2486/// `gh pr edit <url> --title <title>`, for an adopted pull request whose title
2487/// differs from the one this run computed. Only the title: the body may have
2488/// been edited by the owner and cannot be compared.
2489pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2490    let (title, _) = crate::github_text::prepare(state, title, "");
2491    edit_pr_title(repo, url, &title).await
2492}
2493
2494/// [`set_pr_title`] for a title the owner explicitly chose to post unmodified
2495/// (`github_text::POST_ORIGINAL` or `github_text::USE_REWRITE`): the posting
2496/// gate is skipped on purpose.
2497pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2498    edit_pr_title(repo, url, title).await
2499}
2500
2501/// Post both fields of the owner's chosen rewrite to an adopted pull request,
2502/// unmodified and without the posting gate.
2503pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2504    let (ok, _) = gh(
2505        repo,
2506        &[
2507            "pr".to_owned(),
2508            "edit".to_owned(),
2509            url.to_owned(),
2510            "--title".to_owned(),
2511            title.to_owned(),
2512            "--body".to_owned(),
2513            body.to_owned(),
2514        ],
2515    )
2516    .await
2517    .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2518    if !ok {
2519        // Command output may echo the chosen text; keep it out of run events.
2520        bail!("gh pr edit failed while posting the chosen rewrite");
2521    }
2522    Ok(())
2523}
2524
2525async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2526    let (ok, out) = gh(
2527        repo,
2528        &[
2529            "pr".to_owned(),
2530            "edit".to_owned(),
2531            url.to_owned(),
2532            "--title".to_owned(),
2533            title.to_owned(),
2534        ],
2535    )
2536    .await?;
2537    if !ok {
2538        bail!("gh pr edit failed: {out}");
2539    }
2540    Ok(())
2541}
2542
2543/// Ask GitHub whether this run's winning candidate branch was actually merged
2544/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2545/// --merged` exists to close, minus the operator having to find the URL by
2546/// hand.
2547///
2548/// `Ok(None)` covers every case where nothing can be said with confidence: no
2549/// winner decided yet (nothing to check a branch for), no merged pull request
2550/// found, or [`pick_merged_pr`] found more than one candidate and would not
2551/// guess between them. Never wired to a weaker, URL-less signal like
2552/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2553/// separately, precisely because it cannot drive an automatic correction on
2554/// its own (see that function's own doc).
2555pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2556    let Some(winner) = state.winner() else {
2557        return Ok(None);
2558    };
2559    let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2560    let out = gh(
2561        &state.repo,
2562        &[
2563            "pr".to_owned(),
2564            "list".to_owned(),
2565            "--head".to_owned(),
2566            branch.clone(),
2567            "--state".to_owned(),
2568            "merged".to_owned(),
2569            "--json".to_owned(),
2570            "url,number,mergedAt,baseRefName".to_owned(),
2571        ],
2572    )
2573    .await?;
2574    if !out.0 {
2575        bail!("gh pr list --head {branch}: {}", out.1);
2576    }
2577    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2578}
2579
2580/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2581/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2582///
2583/// Deliberately never consulted by [`find_external_merge`]: a base branch
2584/// that has moved since the run started can make an old, abandoned branch
2585/// look like an ancestor of the *current* base for reasons that have nothing
2586/// to do with a merge (a later commit that happens to supersede it, an
2587/// unrelated squash), and there is no pull request URL here to confirm
2588/// against or to land through anyway. Its only honest use is a weaker
2589/// notice — "this looks merged, go check" — never an automatic rewrite of
2590/// `status`/`merge`.
2591pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2592    let out = tokio::process::Command::new("git")
2593        .args(["merge-base", "--is-ancestor", branch, base_branch])
2594        .current_dir(repo)
2595        .quiet()
2596        .stdin(std::process::Stdio::null())
2597        .output()
2598        .await
2599        .context("spawn git merge-base --is-ancestor")?;
2600    Ok(out.status.success())
2601}
2602
2603/// Parse `host/owner/repo` out of a forge URL, with no network access.
2604///
2605/// The host is part of the slug, not discarded: `owner/repo` alone would
2606/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2607/// repository, which is exactly the mix-up the same-repo guard exists to
2608/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2609/// shape at all.
2610fn forge_slug(url: &str) -> Option<(String, &str)> {
2611    let rest = url.rsplit("://").next()?;
2612    let (host, path) = rest.split_once('/')?;
2613    if host.is_empty() {
2614        return None;
2615    }
2616    Some((host.to_ascii_lowercase(), path))
2617}
2618
2619/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2620/// access - the first half of the same-repo guard [`correct_manual_merge`]
2621/// applies before it writes anything.
2622///
2623/// Returns `None` for anything that does not look like
2624/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2625/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2626pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2627    let (host, path) = forge_slug(url)?;
2628    let mut segments = path.split('/');
2629    let owner = segments.next()?;
2630    let repo = segments.next()?;
2631    let kind = segments.next()?;
2632    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2633        return None;
2634    }
2635    Some(format!("{host}/{owner}/{repo}"))
2636}
2637
2638/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2639/// suffix), the shape `gh repo view --json url` returns - the other half of
2640/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2641fn slug_of_repo_url(url: &str) -> Option<String> {
2642    let (host, path) = forge_slug(url)?;
2643    let mut segments = path.split('/');
2644    let owner = segments.next()?;
2645    let repo = segments.next()?;
2646    if owner.is_empty() || repo.is_empty() {
2647        return None;
2648    }
2649    Some(format!("{host}/{owner}/{repo}"))
2650}
2651
2652/// Refuse to correct a run against a pull request from a different
2653/// repository than the one it is recorded against.
2654///
2655/// This is the guard the shun/8c75 incident argued for: an operator ran
2656/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2657/// in a different repository, omitted the run id, and the id defaulted to
2658/// this machine's most recently created run - an unrelated, still-in-progress
2659/// run in a completely different repository - which then had its `status`
2660/// rewritten to `merged` from a pull request it had nothing to do with.
2661/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2662/// CLI help), but a mistyped or stale id could still name a run in a
2663/// different repository than the one the URL belongs to, so this checks that
2664/// independently rather than trusting the id alone.
2665///
2666/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2667/// mismatch names both slugs rather than just refusing, so an operator whose
2668/// local checkout's `origin` is a fork of the repository the pull request was
2669/// opened against (a legitimate setup this cannot tell apart from a genuine
2670/// mix-up) can judge for themselves rather than being blocked with no way to
2671/// see why.
2672pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2673    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2674        return Ok(());
2675    }
2676    bail!(
2677        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2678         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2679         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2680         verify by hand before treating this as a false positive)"
2681    );
2682}
2683
2684/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2685/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2686///
2687/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2688/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2689/// repository the same way GitHub itself would recognize it, so the
2690/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2691/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2692/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2693/// a same-named one on a GitHub Enterprise host.
2694async fn repo_slug(repo: &Path) -> Result<String> {
2695    let out = gh(
2696        repo,
2697        &[
2698            "repo".to_owned(),
2699            "view".to_owned(),
2700            "--json".to_owned(),
2701            "url".to_owned(),
2702        ],
2703    )
2704    .await?;
2705    if !out.0 {
2706        bail!("gh repo view --json url: {}", out.1);
2707    }
2708    #[derive(Debug, Deserialize)]
2709    struct GhRepo {
2710        url: String,
2711    }
2712    let parsed: GhRepo = serde_json::from_str(&out.1)
2713        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2714    slug_of_repo_url(&parsed.url)
2715        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2716}
2717
2718/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2719/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2720/// would have written them had magi opened and merged this pull request
2721/// itself.
2722///
2723/// This is `magi fold --merged`'s whole implementation, and also what the
2724/// web `fold-merged` route calls once it has a URL in hand — an operator
2725/// recovery path for a merge magi could not finish on its own: a PR title too
2726/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2727/// closed by hand with a pull request magi never opened and so never
2728/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2729/// directly keeps this one authoritative: a merged pull request decides
2730/// `Step::Done { merged: true }` on the very first read, before any of
2731/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2732/// safe to call here even though this pull request was never magi's own.
2733///
2734/// [`ensure_same_repo`] is checked before anything else: a pull request from
2735/// a different repository than the one `state` is recorded against is
2736/// refused outright, regardless of its lifecycle. This is the guard for a
2737/// URL an *operator* hands in - the CLI or the web route - where a stale or
2738/// mistyped run id could otherwise get corrected from an unrelated
2739/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2740/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2741/// goes through [`correct_confirmed_external_merge`] instead, which skips
2742/// this check: its `url` was never operator-supplied, it comes from
2743/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2744/// it is already guaranteed to name a pull request in that same repository -
2745/// re-deriving and re-checking the repository here would only be a second
2746/// `gh repo view` call that can fail for reasons that have nothing to do with
2747/// correctness (a rate limit, a network blip), turning a self-heal that would
2748/// otherwise have succeeded into a run left `Blocked` for another pass.
2749///
2750/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2751/// URL fails loudly without writing anything, rather than handing an open
2752/// pull request to the full autonomous loop by accident.
2753///
2754/// Correcting `status` this way does not run `bump::after_merge`
2755/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2756/// which this path never goes through. A release version bump the change
2757/// might have earned is therefore not filed automatically and has to be
2758/// requested by hand - recorded as an event on the run so the gap is visible
2759/// to whoever reads it later, not just wherever this was called from. Follow-up
2760/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2761///
2762/// Returns the status before and after, so every caller (CLI, janitor, web
2763/// route) can build its own log line or response from the same pair rather
2764/// than each re-deriving it.
2765pub async fn correct_manual_merge(
2766    state: &mut RunState,
2767    url: &str,
2768) -> Result<(RunStatus, RunStatus)> {
2769    let Some(pr_slug) = slug_of_pr_url(url) else {
2770        bail!(
2771            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2772             this pull request belongs to"
2773        );
2774    };
2775    let run_slug = repo_slug(&state.repo).await?;
2776    ensure_same_repo(&run_slug, &pr_slug)?;
2777    correct_merge(state, url).await
2778}
2779
2780/// The janitor's own entry point into the same correction
2781/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2782/// same-repo guard - see that function's own doc for why skipping it here is
2783/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2784/// calls this with a `url` [`find_external_merge`] already found by querying
2785/// `state.repo`'s own remote, so the guard could never do anything here but
2786/// fail on its own transient errors.
2787///
2788/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2789/// because it lives in a different module.
2790pub(crate) async fn correct_confirmed_external_merge(
2791    state: &mut RunState,
2792    url: &str,
2793) -> Result<(RunStatus, RunStatus)> {
2794    correct_merge(state, url).await
2795}
2796
2797/// Same pull request, same repository: the url, or the number within one repo.
2798fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2799    let Some(pr) = a.pr.as_ref() else {
2800        return false;
2801    };
2802    if !url.is_empty()
2803        && pr
2804            .url
2805            .trim_end_matches('/')
2806            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2807    {
2808        return true;
2809    }
2810    number > 0
2811        && pr.number == number
2812        && match (a.repo.canonicalize(), repo.canonicalize()) {
2813            (Ok(x), Ok(y)) => x == y,
2814            _ => a.repo == repo,
2815        }
2816}
2817
2818/// Rewrite `pr.state` to a final state on every run record under `home` that
2819/// `decide` picks, and report how many were rewritten.
2820///
2821/// Lifecycle writes through to other runs are deliberately narrow (task-head
2822/// ownership is recorded separately): only a **terminal** run (never one a
2823/// driver may still be writing), only a proven-stopped driver with no daemon
2824/// claim (the shared liveness judge), only a record
2825/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2826/// The record is read as folding reads it (no schema check: every bump so far
2827/// only added fields, and the whole struct round-trips) and written through
2828/// [`RunState::save_under`], the path every record uses, so nothing but
2829/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2830/// A run that cannot be read or written is skipped with a warning.
2831fn rewrite_open_prs(
2832    home: &Path,
2833    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2834) -> usize {
2835    let now = Timestamp::now();
2836    let mut changed = 0;
2837    for id in crate::run::list_ids_in(&home.join("runs")) {
2838        let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2839            continue;
2840        };
2841        let path = home.join("runs").join(&id).join("run.json");
2842        let Ok(body) = std::fs::read_to_string(&path) else {
2843            continue;
2844        };
2845        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2846            continue;
2847        };
2848        if !state.status.done()
2849            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2850            || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2851                != crate::run::Liveness::Dead
2852        {
2853            continue;
2854        }
2855        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2856            continue;
2857        };
2858        if let Some(pr) = state.pr.as_mut() {
2859            pr.state = to.as_str().to_owned();
2860        }
2861        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2862        state.event(
2863            "land",
2864            format!("recorded {url} as {}: another run settled it", to.as_str()),
2865        );
2866        match state.save_under(home) {
2867            Ok(()) => changed += 1,
2868            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2869        }
2870    }
2871    changed
2872}
2873
2874/// A run reached a final state for its pull request: tell every other terminal
2875/// run in the same repository that names the same pull request (handed-over
2876/// predecessors, blocked attempts, anything), so their records stop saying
2877/// `open`. Best effort; `run`'s own record is the caller's.
2878pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2879    if to == PrLifecycle::Open {
2880        return;
2881    }
2882    let Some(home) = crate::run::try_home() else {
2883        return;
2884    };
2885    write_pr_state_through_in(&home, run, to);
2886}
2887
2888pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2889    let Some(pr) = run.pr.as_ref() else {
2890        return 0;
2891    };
2892    let (url, number) = (pr.url.clone(), pr.number);
2893    rewrite_open_prs(home, &mut |other| {
2894        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2895    })
2896}
2897
2898/// Terminal runs whose record still says their pull request is open, as
2899/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
2900pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2901    let now = Timestamp::now();
2902    let mut out = Vec::new();
2903    for id in crate::run::list_ids_in(&home.join("runs")) {
2904        let path = home.join("runs").join(&id).join("run.json");
2905        let Ok(body) = std::fs::read_to_string(&path) else {
2906            continue;
2907        };
2908        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2909            continue;
2910        };
2911        if let Some(pr) = state.pr.as_ref()
2912            && state.status.done()
2913            && pr.state == "open"
2914            && !pr.url.is_empty()
2915            && !crate::daemon::is_working_on(home, &id, now)
2916        {
2917            out.push((id, state.repo.clone(), pr.url.clone()));
2918        }
2919    }
2920    out
2921}
2922
2923/// Apply forge answers (`pr url -> state`) to every stale terminal record.
2924/// A url with no answer (the forge was unreadable) changes nothing.
2925pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2926    rewrite_open_prs(home, &mut |s| {
2927        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2928    })
2929}
2930
2931/// One-time (and idempotent) repair of records that froze an `open` pull
2932/// request: ask the forge about each distinct pull request that a terminal run
2933/// still calls open - at most `max_lookups` of them - and rewrite the merged
2934/// and closed ones. A genuinely open pull request is left alone, and a lookup
2935/// that fails is "unknown, change nothing". Returns `(records rewritten,
2936/// lookups that failed)`.
2937pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2938    let mut known = BTreeMap::new();
2939    let mut failed = 0;
2940    let mut seen = BTreeSet::new();
2941    for (_, repo, url) in stale_open_prs(home) {
2942        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2943            continue;
2944        }
2945        match lifecycle(&repo, &url).await {
2946            Ok(state) => {
2947                known.insert(url, state);
2948            }
2949            Err(e) => {
2950                tracing::warn!("repair pr state of {url}: {e:#}");
2951                failed += 1;
2952            }
2953        }
2954    }
2955    (apply_pr_states(home, &known), failed)
2956}
2957
2958async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2959    match lifecycle(&state.repo, url).await? {
2960        PrLifecycle::Merged => {}
2961        other => bail!(
2962            "{url} is {}, not merged; refusing to record {} as merged on a guess",
2963            other.as_str(),
2964            state.id
2965        ),
2966    }
2967    let before = state.status;
2968    if let Err(e) = land(state, url).await {
2969        // `land` sets `status` to `Landing` and saves before its first read
2970        // of the pull request — see its own doc — so a failure here (a
2971        // transient `gh` hiccup between the two forge reads this function
2972        // makes) can leave the run stuck on that in-between value with
2973        // nothing left driving it. Land it on the same terminal shape an
2974        // automated `land` failure lands on instead of leaving it stuck.
2975        state.status = RunStatus::Blocked;
2976        state.event("fold", format!("manual-merge correction failed: {e:#}"));
2977        state.save()?;
2978        return Err(e).context(format!("confirming the merge of {url}"));
2979    }
2980    state.event(
2981        "fold",
2982        "operator recorded this pull request as a manual merge; this run never \
2983         re-entered `land`, so `bump::after_merge` did not run for it - a release \
2984         bump this change might warrant has to be filed by hand",
2985    );
2986    // Unlike the bump, the findings the merge left open are filed on this
2987    // path too: nothing else would ever carry them forward.
2988    if state.status == RunStatus::Merged {
2989        crate::followup::after_merge(state, url).await;
2990    }
2991    state.save()?;
2992    Ok((before, state.status))
2993}
2994
2995/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
2996/// comments. No I/O.
2997///
2998/// `gh pr view` does not surface inline comments, and inline is exactly where
2999/// both review bots put their findings - a landing loop that read only the
3000/// top-level thread would never see the thing it is supposed to fix.
3001pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3002    let raw: Vec<GhInline> =
3003        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3004    let mut out = Vec::new();
3005    for c in raw {
3006        push_if_outstanding(
3007            &mut out,
3008            ReviewComment {
3009                author: c.user.login,
3010                path: c.path,
3011                line: c.line,
3012                body: c.body,
3013            },
3014        );
3015    }
3016    Ok(out)
3017}
3018
3019/// Keep a comment only when it asks for something.
3020///
3021/// An inline comment always does: it names a file and a line. A top-level
3022/// comment is dropped when it is empty, when it is magi's own, or when it is
3023/// [noise](is_noise).
3024fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3025    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3026        return;
3027    }
3028    if comment.path.is_none() && is_noise(&comment.body) {
3029        return;
3030    }
3031    out.push(comment);
3032}
3033
3034/// Is this comment body machinery rather than a finding?
3035///
3036/// Two tests, both structural, because guessing from prose is how a "looks
3037/// good to me" turns into a fix round:
3038///
3039/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
3040///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
3041///    footer.
3042/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
3043///    horizontal rules, and the bot's own status banner are removed, every
3044///    remaining line is a task-list item. That is exactly the shape of the
3045///    comment the Claude review job posts while it is still working.
3046///
3047/// Anything else is input, including bot prose. A bot that writes a paragraph
3048/// has said something, and the fix prompt tells the fixer it may decline a
3049/// comment with an argument - a wasted sentence in a prompt is cheaper than a
3050/// missed finding.
3051pub fn is_noise(body: &str) -> bool {
3052    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3053        return true;
3054    }
3055    let mut content = false;
3056    for line in strip_blocks(body).lines() {
3057        let line = unquote(line);
3058        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3059            continue;
3060        }
3061        content = true;
3062        break;
3063    }
3064    !content
3065}
3066
3067/// Remove HTML comments and collapsed `<details>` blocks.
3068fn strip_blocks(body: &str) -> String {
3069    let mut out = String::with_capacity(body.len());
3070    let mut rest = body;
3071    loop {
3072        let open = ["<!--", "<details>"]
3073            .iter()
3074            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3075            .min_by_key(|(i, _)| *i);
3076        let Some((at, tag)) = open else {
3077            out.push_str(rest);
3078            return out;
3079        };
3080        out.push_str(&rest[..at]);
3081        let after = &rest[at + tag.len()..];
3082        let close = if tag == "<!--" { "-->" } else { "</details>" };
3083        match after.find(close) {
3084            Some(end) => rest = &after[end + close.len()..],
3085            // Unterminated: the rest of the body is inside the block.
3086            None => return out,
3087        }
3088    }
3089}
3090
3091/// Strip blockquote markers, which both bots wrap their callouts in.
3092fn unquote(line: &str) -> &str {
3093    let mut s = line.trim();
3094    while let Some(rest) = s.strip_prefix('>') {
3095        s = rest.trim_start();
3096    }
3097    s.trim()
3098}
3099
3100/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
3101fn is_checklist(line: &str) -> bool {
3102    let rest = line
3103        .strip_prefix("- ")
3104        .or_else(|| line.strip_prefix("* "))
3105        .unwrap_or("");
3106    let rest = rest.trim_start();
3107    matches!(
3108        rest.get(..3),
3109        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3110    )
3111}
3112
3113/// A heading, a horizontal rule, or a callout tag - shape, never content.
3114fn is_decoration(line: &str) -> bool {
3115    line.starts_with('#')
3116        || line.starts_with("[!")
3117        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3118}
3119
3120/// A line that is nothing but emphasis and links.
3121///
3122/// Both review jobs open with a status banner
3123/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
3124/// to a line-based test and asks for nothing, so it is measured the same way a
3125/// heading is: strip the markup, and if no word survives, it was decoration.
3126fn is_banner(line: &str) -> bool {
3127    let plain = drop_spans(line, "**", "**");
3128    let plain = if plain.contains("](") {
3129        drop_spans(&plain, "[", ")")
3130    } else {
3131        plain
3132    };
3133    !plain.chars().any(char::is_alphanumeric)
3134}
3135
3136/// Remove every `open` .. `close` span, including the delimiters. An
3137/// unterminated span swallows the rest of the input, which is what a reader
3138/// sees too.
3139fn drop_spans(s: &str, open: &str, close: &str) -> String {
3140    let mut out = String::with_capacity(s.len());
3141    let mut rest = s;
3142    while let Some(at) = rest.find(open) {
3143        out.push_str(&rest[..at]);
3144        let after = &rest[at + open.len()..];
3145        match after.find(close) {
3146            Some(end) => rest = &after[end + close.len()..],
3147            None => return out,
3148        }
3149    }
3150    out.push_str(rest);
3151    out
3152}
3153
3154/// The lock that keeps at most one run per repository actually moving the
3155/// base branch at a time: a rebase push, or `gh pr merge`.
3156///
3157/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
3158/// running a fix round in the winner's own worktree, waiting on the owner's
3159/// approval - touches nothing a *different* run in the same repository could
3160/// collide with, and holding a lock across any of that would serialise one
3161/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
3162/// resume, which is precisely the "must not wait on another task" property
3163/// the daemon's slot-freeing exists to give a resume. Only the two moments
3164/// that actually write to the shared base branch need mutual exclusion, and
3165/// both are brief.
3166///
3167/// One entry per repository, each its own `tokio::sync::Mutex`, so two
3168/// different repositories' runs never wait on each other. The outer
3169/// `std::sync::Mutex` guards only the map itself, held long enough to find or
3170/// insert an entry and clone its `Arc`, never across an `.await`.
3171fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3172    static LOCKS: std::sync::LazyLock<
3173        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3174    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3175    LOCKS
3176        .lock()
3177        .unwrap_or_else(std::sync::PoisonError::into_inner)
3178        .entry(repo.to_path_buf())
3179        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3180        .clone()
3181}
3182
3183/// `owner/repo` out of a pull request url, falling back to the checkout's
3184/// directory name when the url is not the usual `host/owner/repo/pull/N`.
3185fn repo_label(repo: &Path, pr_url: &str) -> String {
3186    let parts: Vec<&str> = pr_url.split('/').collect();
3187    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3188        && at >= 2
3189        && !parts[at - 1].is_empty()
3190        && !parts[at - 2].is_empty()
3191    {
3192        return format!("{}/{}", parts[at - 2], parts[at - 1]);
3193    }
3194    repo.file_name()
3195        .map(|n| n.to_string_lossy().into_owned())
3196        .unwrap_or_default()
3197}
3198
3199/// The operator-facing sentence for a merge that went ahead with red checks,
3200/// or `None` when the checks were not red. Judged on `checks`, not on
3201/// `failing`, which can be non-empty on a green observation.
3202fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3203    (pr.checks == Checks::Red).then(|| {
3204        let details = crate::notices::format_list(&pr.failing)
3205            .map(|names| format!(": {names}"))
3206            .unwrap_or_default();
3207        format!(
3208            "Merged {repo_name} PR #{} with red checks{details} ({})",
3209            pr.number, pr.url
3210        )
3211    })
3212}
3213
3214/// After a merge that succeeded: record which checks were red and tell the
3215/// operator. The decision to merge is already made; this only makes it audible.
3216/// Best-effort - a broken notifier never fails the run.
3217async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3218    let repo_name = repo_label(&state.repo, &pr.url);
3219    let Some(summary) = red_merge_summary(&repo_name, pr) else {
3220        return;
3221    };
3222    if let Some(rec) = state.pr.as_mut() {
3223        rec.red_at_merge = pr.failing.clone();
3224    }
3225    state.event("land", summary.clone());
3226    // The notice pages through `[notify]` itself, once, unless a question
3227    // already carries the cause.
3228    crate::notices::raise_with(
3229        crate::notices::merged_red(&state.id, &summary),
3230        &state.config.notify,
3231    );
3232}
3233
3234/// Run the loop against a real pull request until it merges or the budget runs
3235/// out.
3236///
3237/// The caller decides whether landing happens at all: this is only reached when
3238/// `graph.land` is on. Returns the last observation, so the caller can report
3239/// what magi was looking at when it stopped.
3240pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3241    land_with(state, pr_url, &GhForge).await
3242}
3243
3244/// The forge calls whose timing the loop's decisions depend on, behind a seam
3245/// so a test can script what the pull request looks like from one observation
3246/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
3247trait Forge {
3248    async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3249        let (ok, out) = gh(
3250            repo,
3251            &[
3252                "pr".into(),
3253                "close".into(),
3254                pr_url.into(),
3255                "--comment".into(),
3256                comment.into(),
3257            ],
3258        )
3259        .await?;
3260        if !ok {
3261            bail!("PR closure failed: {out}");
3262        }
3263        Ok(())
3264    }
3265    async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
3266        None
3267    }
3268    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3269    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3270    async fn poll(&self);
3271    /// Rerun the failed jobs of one workflow run.
3272    async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3273    /// Unix seconds, behind the seam so a test owns the clock.
3274    fn now(&self) -> i64;
3275    /// The check contexts the base branch requires, for a stop reason only.
3276    /// `None` when they could not be read, which is not the same as none.
3277    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3278    #[allow(clippy::too_many_arguments)]
3279    async fn fix(
3280        &self,
3281        state: &mut RunState,
3282        pr: &PrState,
3283        round: usize,
3284        budget: usize,
3285        reason: &str,
3286        logs: &str,
3287    ) -> Result<Fixed>;
3288}
3289
3290struct GhForge;
3291
3292impl Forge for GhForge {
3293    async fn attribution(&self, state: &RunState, seen: &Seen) -> Option<CiEvidence> {
3294        collect_ci_evidence(state, seen).await
3295    }
3296    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3297        observe(repo, pr_url).await
3298    }
3299    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3300        gh(repo, argv).await
3301    }
3302    async fn poll(&self) {
3303        tokio::time::sleep(POLL).await;
3304    }
3305    async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3306        let args = [
3307            "run".to_owned(),
3308            "rerun".to_owned(),
3309            run.to_owned(),
3310            "--failed".to_owned(),
3311        ];
3312        let (ok, out) = gh(repo, &args).await?;
3313        if !ok {
3314            bail!("gh run rerun {run}: {out}");
3315        }
3316        Ok(())
3317    }
3318    fn now(&self) -> i64 {
3319        jiff::Timestamp::now().as_second()
3320    }
3321    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3322        required_contexts_of(repo, base).await
3323    }
3324    async fn fix(
3325        &self,
3326        state: &mut RunState,
3327        pr: &PrState,
3328        round: usize,
3329        budget: usize,
3330        reason: &str,
3331        logs: &str,
3332    ) -> Result<Fixed> {
3333        fix_round(state, pr, round, budget, reason, logs).await
3334    }
3335}
3336
3337/// Percent-encode a branch name for a URL path segment (`/` included).
3338fn encode_path_segment(s: &str) -> String {
3339    let mut out = String::new();
3340    for b in s.bytes() {
3341        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3342            out.push(b as char);
3343        } else {
3344            let _ = write!(out, "%{b:02X}");
3345        }
3346    }
3347    out
3348}
3349
3350/// Read the required contexts of `base` from classic branch protection and
3351/// from rulesets, once, for a stop reason. Organisation-level rulesets that
3352/// these two endpoints do not list are missed. Any unreadable source makes the
3353/// whole answer `None`: a partial set would read as a complete one.
3354async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3355    let enc = encode_path_segment(base);
3356    let mut all = BTreeSet::new();
3357    // Classic protection answers 404 for an unprotected branch, which is
3358    // "nothing required here", not a failure to read.
3359    let classic = gh(
3360        repo,
3361        &[
3362            "api".to_owned(),
3363            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3364        ],
3365    )
3366    .await
3367    .ok()?;
3368    if classic.0 {
3369        all.extend(parse_classic_required(&classic.1)?);
3370    } else if !classic.1.contains("404") {
3371        return None;
3372    }
3373    let rules = gh(
3374        repo,
3375        &[
3376            "api".to_owned(),
3377            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3378        ],
3379    )
3380    .await
3381    .ok()?;
3382    if !rules.0 {
3383        return None;
3384    }
3385    all.extend(parse_ruleset_required(&rules.1)?);
3386    Some(all)
3387}
3388
3389/// `required_status_checks` of classic protection: `contexts` plus the
3390/// `checks[].context` form.
3391fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3392    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3393    let mut out = BTreeSet::new();
3394    for c in v.get("contexts")?.as_array()? {
3395        out.insert(c.as_str()?.to_owned());
3396    }
3397    for c in v
3398        .get("checks")
3399        .and_then(|c| c.as_array())
3400        .into_iter()
3401        .flatten()
3402    {
3403        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3404            out.insert(name.to_owned());
3405        }
3406    }
3407    Some(out)
3408}
3409
3410/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
3411fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3412    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3413    let mut out = BTreeSet::new();
3414    for rule in v.as_array()? {
3415        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3416            continue;
3417        }
3418        let checks = rule
3419            .pointer("/parameters/required_status_checks")?
3420            .as_array()?;
3421        for c in checks {
3422            out.insert(c.get("context")?.as_str()?.to_owned());
3423        }
3424    }
3425    Some(out)
3426}
3427
3428/// Is the observation older than the commit a fix round pushed?
3429///
3430/// The forge takes a few seconds to move the pull request to a new head and
3431/// attach that head's check runs, and until it has, the rollup is the previous
3432/// head's - all green, which is exactly what a merge decision must not read.
3433/// An absent or different head counts as not yet: guessing "close enough"
3434/// would reopen the hole.
3435fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3436    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3437}
3438
3439/// The commit an observation may be decided on, or `None` while it cannot be
3440/// trusted to describe one.
3441///
3442/// `None` when a pushed commit is awaited and the pull request is not on it,
3443/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3444/// last commit's) is not the head's: that is the previous commit's checks. The
3445/// caller re-polls on `None`. A rollup that cannot be read (including one
3446/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3447/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3448fn bound_head<'a>(
3449    seen_head: &'a str,
3450    rollup_head: &str,
3451    awaiting: Option<&str>,
3452) -> Option<&'a str> {
3453    if seen_head.is_empty()
3454        || awaiting_new_head(awaiting, seen_head)
3455        || !rollup_head.eq_ignore_ascii_case(seen_head)
3456    {
3457        return None;
3458    }
3459    Some(seen_head)
3460}
3461
3462/// What a refused `gh pr merge` means.
3463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3464enum Refused {
3465    /// The branch policy is not satisfied *yet*: go back to waiting.
3466    Pending,
3467    /// Checks have settled and the merge state still says no, seen for the
3468    /// first time. The forge updates `mergeStateStatus` a moment after the last
3469    /// check finishes, so one more look is allowed before believing it.
3470    Recheck,
3471    /// Nothing is in flight and the policy still refuses: a person has to
3472    /// supply what it asks for (a review, say).
3473    Final,
3474}
3475
3476/// Judged from the pull request's state after the refusal, never from the
3477/// refusal's wording, which belongs to the forge and changes.
3478fn classify_refusal(
3479    after: Option<&Seen>,
3480    rechecked: bool,
3481    observed_head: &str,
3482    ci: CiExpectation,
3483) -> Refused {
3484    let Some(after) = after else {
3485        // Unreadable is not evidence of anything; the next loop reads again.
3486        return Refused::Pending;
3487    };
3488    if after.pr.state != PrLifecycle::Open {
3489        return Refused::Final;
3490    }
3491    // The branch moved after it was observed (the forge refuses a merge pinned
3492    // to the old commit): not a verdict on anything, look again.
3493    if !after.head.eq_ignore_ascii_case(observed_head) {
3494        return Refused::Pending;
3495    }
3496    // The same binding the loop applies: checks of another commit say nothing.
3497    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3498        return Refused::Pending;
3499    }
3500    let state = after.merge_state.to_ascii_uppercase();
3501    if (ci == CiExpectation::Expected
3502        && matches!(after.pr.checks, Checks::Pending | Checks::Unknown))
3503        || state.is_empty()
3504        || state == "UNKNOWN"
3505    {
3506        return Refused::Pending;
3507    }
3508    if rechecked {
3509        Refused::Final
3510    } else {
3511        Refused::Recheck
3512    }
3513}
3514
3515/// Take auto-merge back from the forge and forget that it was armed.
3516///
3517/// `Err` carries the forge's message: the caller must not push or move on, as
3518/// an armed merge left behind could still fire for a commit nobody approved.
3519async fn disarm<F: Forge>(
3520    forge: &F,
3521    state: &mut RunState,
3522    repo: &Path,
3523    number: u64,
3524) -> std::result::Result<(), String> {
3525    let argv = disable_automerge_argv(number);
3526    let out = {
3527        let merge_lock = repo_merge_lock(repo);
3528        let _merge_slot = merge_lock.lock().await;
3529        forge.merge(repo, &argv).await
3530    };
3531    match out {
3532        Ok((true, _)) => {
3533            state.land_armed_head = None;
3534            state.event("land", "auto-merge disabled");
3535            state.save().map_err(|e| format!("{e:#}"))?;
3536            Ok(())
3537        }
3538        Ok((false, msg)) => Err(msg),
3539        Err(e) => Err(format!("{e:#}")),
3540    }
3541}
3542
3543/// [`stop`], first taking back an armed auto-merge so a pull request magi
3544/// gave up on does not merge on its own later. A failure to disarm is added
3545/// to the reason rather than hiding it.
3546async fn stop_disarmed<F: Forge>(
3547    forge: &F,
3548    state: &mut RunState,
3549    repo: &Path,
3550    pr: &PrState,
3551    why: &str,
3552) -> Result<()> {
3553    if state.land_armed_head.is_none() {
3554        return stop(state, repo, pr, why).await;
3555    }
3556    match disarm(forge, state, repo, pr.number).await {
3557        Ok(()) => stop(state, repo, pr, why).await,
3558        Err(e) => {
3559            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3560            stop(state, repo, pr, &why).await
3561        }
3562    }
3563}
3564
3565/// Check the persisted card again around awaits that can outlive an owner tap.
3566fn owner_requested_stop(state: &RunState) -> Result<bool> {
3567    let Some(binding) = &state.land_approval else {
3568        return Ok(false);
3569    };
3570    let q = ask::Questions::open().get(&binding.question)?;
3571    Ok(q.run == state.id
3572        && q.node == APPROVAL_NODE
3573        && q.resolution()
3574            .as_deref()
3575            .is_some_and(crate::merge_decision::stops))
3576}
3577
3578/// Read stop choices before checks, repair or rebase. These are about stopping
3579/// ownership, so a changed head never cancels them as it cancels merge consent.
3580async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3581    use crate::merge_decision as md;
3582    let Some(binding) = &state.land_approval else {
3583        return Ok(false);
3584    };
3585    let q = ask::Questions::open().get(&binding.question)?;
3586    let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3587        return Ok(false);
3588    };
3589    if q.run != state.id || q.node != APPROVAL_NODE {
3590        bail!("stop decision names another run");
3591    }
3592    let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3593        pr: pr.url.clone(),
3594        head: binding.head.clone(),
3595        language: state.config.graph.language.clone(),
3596    });
3597    if target.pr != pr.url {
3598        bail!("stop decision names another PR");
3599    }
3600    if state.merge_decision.is_none() {
3601        state.merge_decision = Some(md::Decision {
3602            question: q.id.clone(),
3603            target,
3604            choice: choice.clone(),
3605            closed: false,
3606            replacement_run: None,
3607            replacement_pr: None,
3608        });
3609        state.save()?;
3610    }
3611    let repo = state.repo.clone();
3612    // Always disable, including arms left by an older driver.
3613    if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3614        stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3615        return Ok(true);
3616    }
3617    if choice.eq_ignore_ascii_case(HOLD) {
3618        stop(
3619            state,
3620            &repo,
3621            pr,
3622            "owner held the decision: PR left open; no further repair or merge work",
3623        )
3624        .await?;
3625        return Ok(true);
3626    }
3627    if md::selected_agent(&choice).is_some()
3628        || state
3629            .origin
3630            .as_ref()
3631            .and_then(|o| o.task.as_ref())
3632            .is_some()
3633    {
3634        let owners: Vec<_> = crate::queue::Queue::open()
3635            .list()
3636            .into_iter()
3637            .filter(|task| task.runs.last() == Some(&state.id))
3638            .collect();
3639        let owner = (owners.len() == 1).then(|| &owners[0]);
3640        if !owner.is_some_and(|task| {
3641            task.runs.last() == Some(&state.id)
3642                && (!task
3643                    .operator_decided_at
3644                    .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3645                    || task.resumption.as_ref().is_some_and(|release| {
3646                        release.question_id.as_deref() == Some(q.id.as_str())
3647                            && task.operator_decided_at == Some(release.at)
3648                    }))
3649        }) {
3650            stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3651            return Ok(true);
3652        }
3653    }
3654    if pr.state == PrLifecycle::Merged {
3655        stop(
3656            state,
3657            &repo,
3658            pr,
3659            "PR already merged; withdrawal/replacement cannot be applied",
3660        )
3661        .await?;
3662        return Ok(true);
3663    }
3664    let comment = format!(
3665        "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3666        q.id, choice, state.id
3667    );
3668    if pr.state == PrLifecycle::Open {
3669        if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3670            stop(
3671                state,
3672                &repo,
3673                pr,
3674                &format!(
3675                    "owner requested closure but it failed: {e}; task held, no replacement started"
3676                ),
3677            )
3678            .await?;
3679            return Ok(true);
3680        }
3681    }
3682    let confirmed = forge.view(&repo, &pr.url).await?;
3683    if confirmed.pr.state != PrLifecycle::Closed {
3684        stop(
3685            state,
3686            &repo,
3687            pr,
3688            "PR closure was not confirmed; task held, no replacement started",
3689        )
3690        .await?;
3691        return Ok(true);
3692    }
3693    *pr = confirmed.pr;
3694    state.merge_decision.as_mut().unwrap().closed = true;
3695    if let Some(record) = &mut state.pr {
3696        record.state = "closed".into();
3697    }
3698    state.status = RunStatus::Superseded;
3699    state.merge = Some(MergeOutcome {
3700        mode: MergeMode::Pr,
3701        ok: false,
3702        detail: format!("owner decision: {choice}; PR closure confirmed"),
3703        empty: false,
3704    });
3705    state.event(
3706        "land",
3707        format!(
3708            "{} applied: PR closure confirmed; old execution retired",
3709            choice
3710        ),
3711    );
3712    state.save()?;
3713    Ok(true)
3714}
3715
3716/// Keep an unstarted verification resumable without charging a task attempt.
3717fn check_landing_resources(
3718    state: &mut RunState,
3719    node: &str,
3720    outcomes: &[crate::run::CommandOutcome],
3721) -> Result<()> {
3722    if let Some(blocked) = outcomes.iter().find(|outcome| outcome.resource_blocked) {
3723        let detail = format!("{}: {}", blocked.command, blocked.output_tail);
3724        state
3725            .resource_block
3726            .get_or_insert_with(|| crate::run::ResourceBlock {
3727                cause: crate::run::ResourceBlockCause::Other,
3728                node: node.into(),
3729                kind: "shared build cache".into(),
3730                detail: detail.clone(),
3731                at: jiff::Timestamp::now(),
3732            });
3733        state.event("resource", &detail);
3734        state.save()?;
3735        bail!("landing verification could not start: {detail}");
3736    }
3737    Ok(())
3738}
3739
3740/// Gate the local winner and publish that exact SHA, retaining the saved lease.
3741/// A push completed before a crash is recoverable only when its SHA was gated.
3742async fn publish_landing_head<F: Forge>(
3743    state: &mut RunState,
3744    observed: &str,
3745    pr_url: &str,
3746    forge: &F,
3747) -> Result<String> {
3748    crate::pr_head::ensure_owner(state)?;
3749    let winner = state.winner().cloned().context("landing has no winner")?;
3750    let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3751    let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3752    anyhow::ensure!(
3753        observed == expected
3754            || (observed == head
3755                && state.gate_head.as_deref() == Some(&head)
3756                && state.gate_allows_progress()),
3757        "public head moved outside this run; refusing to overwrite it"
3758    );
3759    anyhow::ensure!(
3760        git::is_clean(&winner.worktree).await?,
3761        "landing worktree is dirty"
3762    );
3763    if state.gate_head.as_deref() != Some(&head) || !state.gate_ran || !state.gate_allows_progress()
3764    {
3765        state.gate.clear();
3766        state.gate_head = None;
3767        state.gate_ran = false;
3768        state.pre_gate.clear();
3769        state.pre_gate_commit = None;
3770        state.save()?;
3771        let inherited = crate::repo_lock::WaitPolicy::current();
3772        let wait = crate::repo_lock::WaitPolicy::new(
3773            Duration::from_secs(state.config.graph.timeout_resource_wait),
3774            move || inherited.cancelled(),
3775        );
3776        let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3777        loop {
3778            if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3779                state.gate = outcomes.clone();
3780                check_landing_resources(state, "gate", &outcomes)?;
3781            }
3782            if outcomes.iter().all(|o| o.ok()) {
3783                break;
3784            }
3785            match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3786                crate::graph::GateFix::Retry => {
3787                    outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3788                }
3789                crate::graph::GateFix::Stop => break,
3790                crate::graph::GateFix::Defer => {
3791                    // The repair checks e2e immediately, before the next gate.
3792                    // Preserve a cache wait here as well as below; otherwise
3793                    // the daemon charges an attempt for unstarted verification.
3794                    if let Some(e2e) = &state.gate_repair_e2e {
3795                        let outcomes = e2e.outcomes.clone();
3796                        check_landing_resources(state, "e2e", &outcomes)?;
3797                    }
3798                    bail!("landing gate repair deferred");
3799                }
3800            }
3801        }
3802        state.gate = outcomes;
3803        state.gate_ran = true;
3804        state.gate_head = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3805        state.save()?;
3806    }
3807    anyhow::ensure!(
3808        state.gate_allows_progress(),
3809        "landing gate failed; nothing published"
3810    );
3811    let checked = state
3812        .gate_head
3813        .clone()
3814        .context("landing gate has no checked SHA")?;
3815    anyhow::ensure!(
3816        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3817            && git::is_clean(&winner.worktree).await?,
3818        "landing tree changed after gate"
3819    );
3820    let inherited = crate::repo_lock::WaitPolicy::current();
3821    let wait = crate::repo_lock::WaitPolicy::new(
3822        Duration::from_secs(state.config.graph.timeout_resource_wait),
3823        move || inherited.cancelled(),
3824    );
3825    let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3826    if matches!(e2e, crate::graph::GateFix::Defer) {
3827        let outcomes = state
3828            .gate_repair_e2e
3829            .as_ref()
3830            .context("deferred landing e2e has no outcomes")?
3831            .outcomes
3832            .clone();
3833        check_landing_resources(state, "e2e", &outcomes)?;
3834    }
3835    anyhow::ensure!(
3836        matches!(e2e, crate::graph::GateFix::Retry),
3837        "post-repair e2e has not passed on the landing head; nothing published"
3838    );
3839    anyhow::ensure!(
3840        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3841            && git::is_clean(&winner.worktree).await?,
3842        "landing tree changed during post-repair e2e"
3843    );
3844    crate::pr_head::ensure_owner(state)?;
3845    if owner_requested_stop(state)? {
3846        bail!("owner requested stop before publication");
3847    }
3848    let current = forge.view(&state.repo, pr_url).await?;
3849    anyhow::ensure!(
3850        current.pr.state == PrLifecycle::Open,
3851        "PR closed or merged while gating; nothing published"
3852    );
3853    anyhow::ensure!(
3854        current.head == observed || current.head == checked,
3855        "PR head changed while gating; nothing published"
3856    );
3857    let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3858    let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3859    let refspec = format!("{checked}:refs/heads/{public}");
3860    let lock = repo_merge_lock(&state.repo);
3861    let _slot = lock.lock().await;
3862    let pushed = git::git_raw(
3863        &winner.worktree,
3864        &["push", &lease, &state.config.merge.remote, &refspec],
3865    )
3866    .await?;
3867    anyhow::ensure!(
3868        pushed.ok(),
3869        "publishing gated landing head failed: {}",
3870        pushed.stderr
3871    );
3872    state.pr_pushed_head = Some(checked.clone());
3873    state.land_sync_pending = false;
3874    state.save()?;
3875    Ok(checked)
3876}
3877
3878/// A scratch rebase moves the winner ref before its index and files. Recover
3879/// only a tree proven unchanged since the saved start; never discard edits.
3880async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
3881    let winner = state.winner().cloned().context("landing has no winner")?;
3882    let inherited = crate::repo_lock::WaitPolicy::current();
3883    let wait = crate::repo_lock::WaitPolicy::new(
3884        Duration::from_secs(state.config.graph.timeout_resource_wait),
3885        move || inherited.cancelled(),
3886    );
3887    let _coordination = crate::repo_lock::SeatLease::operation_with(
3888        &winner.worktree,
3889        state.config.coordination.as_ref(),
3890        &wait,
3891    )
3892    .await?;
3893    if !git::is_clean(&winner.worktree).await? {
3894        let from = state
3895            .land_rebase_from
3896            .as_deref()
3897            .context("landing worktree is dirty")?;
3898        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3899        anyhow::ensure!(head != from, "landing worktree is dirty");
3900        for args in [
3901            vec!["diff", "--cached", "--quiet", from],
3902            vec!["diff", "--quiet"],
3903        ] {
3904            anyhow::ensure!(
3905                git::git_raw(&winner.worktree, &args).await?.ok(),
3906                "landing worktree has edits beyond the interrupted rebase"
3907            );
3908        }
3909        // Preserve build artifacts and local files, including ignored ones.
3910        // read-tree can overwrite ignored paths, so reject collisions with
3911        // the new tree (including file/directory conflicts) before updating.
3912        let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
3913        let tracked = git::git(
3914            &winner.worktree,
3915            &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
3916        )
3917        .await?;
3918        for local in untracked.split('\0').filter(|p| !p.is_empty()) {
3919            anyhow::ensure!(
3920                !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
3921                    path == local
3922                        || path
3923                            .strip_prefix(local)
3924                            .is_some_and(|rest| rest.starts_with('/'))
3925                        || local
3926                            .strip_prefix(path)
3927                            .is_some_and(|rest| rest.starts_with('/'))
3928                }),
3929                "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
3930            );
3931        }
3932        git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
3933    }
3934    crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
3935    state.land_rebase_from = None;
3936    state.save()?;
3937    Ok(())
3938}
3939
3940async fn rebase_landing_worktree(
3941    state: &mut RunState,
3942    onto: &str,
3943) -> Result<crate::rebase::Rebased> {
3944    restore_landing_worktree(state).await?;
3945    let winner = state.winner().cloned().context("landing has no winner")?;
3946    state.land_sync_pending = true;
3947    state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3948    state.save()?;
3949    let scratch = state.dir().join("rebase");
3950    let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
3951    if result == crate::rebase::Rebased::Applied {
3952        restore_landing_worktree(state).await?;
3953    }
3954    Ok(result)
3955}
3956
3957/// Reconcile a stopped landing before changing its branch. This also handles
3958/// old records whose graph status or merge detail no longer identifies land.
3959async fn resume_landing_head<F: Forge>(
3960    state: &mut RunState,
3961    observed: &str,
3962    pr_url: &str,
3963    forge: &F,
3964) -> Result<String> {
3965    crate::pr_head::ensure_owner(state)?;
3966    let winner = state.winner().cloned().context("landing has no winner")?;
3967    let local = git::rev_parse(&winner.worktree, "HEAD").await?;
3968    let expected = state
3969        .pr_pushed_head
3970        .as_deref()
3971        .or(state.gate_head.as_deref())
3972        .context("missing public head lease")?;
3973    anyhow::ensure!(
3974        observed == expected
3975            || (observed == local
3976                && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
3977                    || git::is_ancestor(&winner.worktree, expected, &local).await)),
3978        "public head moved outside this run; refusing to sync"
3979    );
3980    // An operator may have extended and published the winner while approval
3981    // was pending. Matching local/public history can be adopted as the lease,
3982    // but publish_landing_head must gate its new SHA before proceeding. A
3983    // remote-only or divergent update still cannot be overwritten.
3984    // Save reconciliation before rebasing again; a prior push may have reached
3985    // the remote immediately before the previous driver's last save.
3986    state.pr_pushed_head = Some(observed.to_owned());
3987    state.save()?;
3988    restore_landing_worktree(state).await?;
3989    let remote = state.config.merge.remote.clone();
3990    let base = state.base_branch.clone();
3991    let fetched = git::fetch(&state.repo, &remote, &base).await?;
3992    anyhow::ensure!(
3993        fetched.ok(),
3994        "cannot fetch landing base: {}",
3995        fetched.stderr
3996    );
3997    let onto = format!("{remote}/{base}");
3998    let tip = git::rev_parse(&state.repo, &onto).await?;
3999    if !git::is_ancestor(&state.repo, &tip, &local).await {
4000        match rebase_landing_worktree(state, &onto).await? {
4001            crate::rebase::Rebased::Applied => {}
4002            crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4003        }
4004    }
4005    publish_landing_head(state, observed, pr_url, forge).await
4006}
4007
4008async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4009    crate::pr_head::ensure_owner(state)?;
4010    state.land_unrelated = None;
4011    let reopening_hold = crate::merge_decision::resumable_hold(state);
4012    let repo = state.repo.clone();
4013    let budget = state.config.graph.land_rounds;
4014    let mut round = 0usize;
4015    // Counted apart from `round`: a rebase is not a fix, and a base that
4016    // moved is not the change's fault.
4017    let mut rebases = 0usize;
4018    let ci = state.config.merge.ci.expectation();
4019    if ci == CiExpectation::Absent {
4020        state.event(
4021            "land",
4022            "CI not expected ([merge] ci = \"none\"); check status ignored",
4023        );
4024    }
4025    let mut waited = Duration::ZERO;
4026    // Comment bodies the fixer has already been shown. A comment is
4027    // outstanding until it has been handed over once; after that it is a
4028    // recorded decision, not an open question, and re-feeding it would loop the
4029    // budget away on a comment the fixer already declined with an argument.
4030    let mut shown: BTreeSet<String> = BTreeSet::new();
4031    // Await the last successfully published head, including across resumes.
4032    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4033    // Whether a settled-checks refusal has already been given its one re-look.
4034    let mut rechecked = false;
4035
4036    // Marks the run resumable through exactly this function, not through a
4037    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
4038    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
4039    // status specifically to know a resumed run belongs back in `land`
4040    // rather than at a second `gh pr create`. Set on every entry - fresh or
4041    // resumed - because a resume that parked here again must keep reading
4042    // `Landing`, not whatever a first pass through `merge` left behind.
4043    state.status = RunStatus::Landing;
4044    state.event("land", format!("watching {pr_url}"));
4045    state.save()?;
4046
4047    // An armed merge recorded by an earlier pass may or may not have reached
4048    // the forge (the record is written before the call). It is taken back on
4049    // the first observation, where a pull request is known to stop with.
4050    let mut resumed_armed = state.land_armed_head.is_some();
4051
4052    loop {
4053        let seen = forge.view(&repo, pr_url).await?;
4054        let mut pr = seen.pr.clone();
4055        pr.review_comments.retain(|c| !shown.contains(&c.body));
4056        state.pr = Some(crate::run::PrRecord {
4057            url: pr.url.clone(),
4058            number: pr.number,
4059            state: pr.state.as_str().to_owned(),
4060            checks: pr.checks.as_str().to_owned(),
4061            round,
4062            rounds: budget,
4063            red_at_merge: Vec::new(),
4064        });
4065        state.save()?;
4066
4067        if pr.state == PrLifecycle::Open {
4068            state.observe_branch_owner_holds(&seen.head).await;
4069            state.save()?;
4070        }
4071        crate::pr_head::ensure_owner(state)?;
4072        if reopening_hold {
4073            anyhow::ensure!(
4074                pr.state == PrLifecycle::Open,
4075                "held PR is no longer open; cannot request a fresh decision"
4076            );
4077            // An explicit resume authorizes a fresh question only. Preserve the
4078            // answered card, and never enter repair/rebase/merge on this pass.
4079            disarm(forge, state, &repo, pr.number)
4080                .await
4081                .map_err(anyhow::Error::msg)?;
4082            let previous = state.merge_decision.take();
4083            let binding = state.land_approval.take();
4084            state.config.graph.land_approval = true;
4085            let subject = merge_subject(
4086                crate::graph::landing_title(state, &seen.title),
4087                &crate::graph::landing_subject_source(state),
4088            );
4089            let contested = contested_to_ask(state);
4090            if let Err(error) =
4091                approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4092            {
4093                state.merge_decision = previous;
4094                state.land_approval = binding;
4095                return Err(error);
4096            }
4097            state.end_pass_parked(crate::run::ParkCause::Question);
4098            state.parked = true;
4099            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()));
4100            state.save()?;
4101            return Ok(pr);
4102        }
4103        if owner_stop(state, &mut pr, forge).await? {
4104            return Ok(pr);
4105        }
4106
4107        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4108            // An approval already given for the same head is reused, so
4109            // nothing is asked twice. A failed disable
4110            // stops the run with the record kept: pushing on could change the
4111            // head under an arm that is still live.
4112            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4113                let why = format!(
4114                    "a previous pass may have armed auto-merge and it could not be disabled \
4115                     on resume: {e}"
4116                );
4117                stop(state, &repo, &pr, &why).await?;
4118                return Ok(pr);
4119            }
4120        }
4121
4122        // The head moved away from the one auto-merge was armed on, by
4123        // someone other than this loop. The arm is pinned and would refuse to
4124        // merge the new commit, but the approval was for the old one: take it
4125        // back and go through the normal path again.
4126        if pr.state == PrLifecycle::Open
4127            && !seen.head.is_empty()
4128            && state
4129                .land_armed_head
4130                .as_deref()
4131                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4132        {
4133            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4134                let why = format!(
4135                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
4136                );
4137                stop(state, &repo, &pr, &why).await?;
4138                return Ok(pr);
4139            }
4140        }
4141
4142        if state.land_sync_pending && pr.state == PrLifecycle::Open {
4143            // Disarming can outlive an owner answer. Consume it before our
4144            // own rebase invalidates the head shown on the decision card.
4145            if owner_stop(state, &mut pr, forge).await? {
4146                return Ok(pr);
4147            }
4148            let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4149                binding.head.eq_ignore_ascii_case(&seen.head)
4150                    && ask::Questions::open()
4151                        .get(&binding.question)
4152                        .is_ok_and(|q| {
4153                            q.run == state.id
4154                                && q.node == APPROVAL_NODE
4155                                && q.status == ask::QuestionStatus::Answered
4156                                && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4157                        })
4158            });
4159            if repair_answer {
4160                let subject = merge_subject(
4161                    crate::graph::landing_title(state, &seen.title),
4162                    &crate::graph::landing_subject_source(state),
4163                );
4164                let contested = contested_to_ask(state);
4165                if matches!(
4166                    approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4167                    ApprovalGate::Repair
4168                ) {
4169                    if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4170                        state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4171                        state.save()?;
4172                    }
4173                    return Ok(pr);
4174                }
4175            }
4176            let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4177            // A stop detected before publication must apply its durable
4178            // decision, even when verification returned an error meanwhile.
4179            if owner_stop(state, &mut pr, forge).await? {
4180                return Ok(pr);
4181            }
4182            let head = synced?;
4183            awaiting_head = Some(head);
4184            waited = Duration::ZERO;
4185            rechecked = false;
4186            // Read CI and approvals afresh, never from the pre-push observation.
4187            continue;
4188        }
4189
4190        if pr.state == PrLifecycle::Open {
4191            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4192                if waited >= WAIT_CEILING {
4193                    let want = awaiting_head.as_deref().unwrap_or_default();
4194                    let why = format!(
4195                        "the pull request's checks were still not about one readable head after \
4196                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
4197                         someone may have pushed over it",
4198                        WAIT_CEILING.as_secs() / 60,
4199                        if want.is_empty() { "any" } else { want },
4200                        if seen.head.is_empty() {
4201                            "nothing readable"
4202                        } else {
4203                            &seen.head
4204                        },
4205                        if seen.rollup_head.is_empty() {
4206                            "nothing readable"
4207                        } else {
4208                            &seen.rollup_head
4209                        },
4210                    );
4211                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
4212                    return Ok(pr);
4213                }
4214                waited += POLL;
4215                forge.poll().await;
4216                continue;
4217            }
4218            // Reset once, when the awaited head first shows up; resetting on
4219            // every re-observation would let a standing refusal wait forever.
4220            if awaiting_head.take().is_some() {
4221                // The checks now being read belong to the new head; give them
4222                // the same grace a fresh pull request gets.
4223                waited = Duration::ZERO;
4224            }
4225        }
4226
4227        // The merge-state grace is head-bound and durable. Reruns reset the
4228        // checks clock, but must not extend this deadline across resumes.
4229        let decision_wait = if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid {
4230            let first = state
4231                .land_unsaid
4232                .get_or_insert_with(|| (seen.head.clone(), forge.now()));
4233            if !first.0.eq_ignore_ascii_case(&seen.head) {
4234                *first = (seen.head.clone(), forge.now());
4235            }
4236            let elapsed = Duration::from_secs(forge.now().saturating_sub(first.1).max(0) as u64);
4237            state.save()?;
4238            elapsed
4239        } else {
4240            waited
4241        };
4242        let proposed = decide_with(&pr, round, budget, decision_wait, ci);
4243        if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid && proposed == Step::Wait {
4244            if waited >= WAIT_CEILING {
4245                stop_disarmed(
4246                    forge,
4247                    state,
4248                    &repo,
4249                    &pr,
4250                    "merge state remained unreadable; refusing to merge on a guess",
4251                )
4252                .await?;
4253                return Ok(pr);
4254            }
4255            waited += POLL;
4256            forge.poll().await;
4257            continue;
4258        }
4259
4260        // A failed workflow run is rerun once, judged by its own run rather
4261        // than by the rollup's aggregate: another run still pending, or a fix
4262        // budget already spent, must not keep a flaky failure from its one
4263        // rerun. It spends no round and no attempt.
4264        if ci == CiExpectation::Expected
4265            && pr.state == PrLifecycle::Open
4266            && state.config.graph.rerun_flaky_checks
4267        {
4268            let include_optional = pr.checks == Checks::Red
4269                && matches!(proposed, Step::Fix { .. } | Step::GiveUp { .. });
4270            let failing = rerunnable_with(&seen.contexts, include_optional);
4271            let now = forge.now();
4272            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4273                RerunStep::Rerun(runs) => {
4274                    let rec = state.land_reruns.get_or_insert_with(Default::default);
4275                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
4276                        *rec = LandReruns {
4277                            head: seen.head.clone(),
4278                            runs: BTreeMap::new(),
4279                        };
4280                    }
4281                    for run in &runs {
4282                        rec.runs.insert(run.clone(), now);
4283                    }
4284                    // Durable before the call: a restart must not
4285                    // rerun the same run twice. A save that fails
4286                    // means no rerun.
4287                    state.save()?;
4288                    let mut any = false;
4289                    for run in &runs {
4290                        let names = failing
4291                            .iter()
4292                            .filter(|(_, r)| r.as_deref() == Some(run))
4293                            .map(|(l, _)| l.as_str())
4294                            .collect::<Vec<_>>()
4295                            .join(", ");
4296                        match forge.rerun(&repo, run).await {
4297                            Ok(()) => {
4298                                any = true;
4299                                state.event(
4300                                    "land",
4301                                    format!("reran failed check(s) {names} (run {run})"),
4302                                );
4303                            }
4304                            // Stays recorded: never a retry loop.
4305                            Err(e) => state.event(
4306                                "land",
4307                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4308                            ),
4309                        }
4310                    }
4311                    state.save()?;
4312                    if any {
4313                        waited = Duration::ZERO;
4314                        forge.poll().await;
4315                        continue;
4316                    }
4317                }
4318                RerunStep::Grace => {
4319                    if waited >= WAIT_CEILING {
4320                        let why = format!(
4321                            "checks were still failing after a rerun and {} minutes",
4322                            WAIT_CEILING.as_secs() / 60
4323                        );
4324                        stop(state, &repo, &pr, &why).await?;
4325                        return Ok(pr);
4326                    }
4327                    waited += POLL;
4328                    forge.poll().await;
4329                    continue;
4330                }
4331                RerunStep::Fix => {}
4332            }
4333        }
4334
4335        let step = proposed;
4336        // Armed on exactly this head: the forge is doing the waiting. A
4337        // decision to merge (or keep waiting) is only watched, never re-armed
4338        // and never re-approved; anything else - a red check, a comment, a
4339        // conflict - falls through to its own arm, which disarms first.
4340        let armed_here = state
4341            .land_armed_head
4342            .as_deref()
4343            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4344        if armed_here && matches!(step, Step::Merge | Step::Wait) {
4345            if waited >= WAIT_CEILING {
4346                let required = if seen.base.is_empty() {
4347                    None
4348                } else {
4349                    forge.required_contexts(&repo, &seen.base).await
4350                };
4351                let why = format!(
4352                    "auto-merge was armed on {} but the pull request did not merge within {} \
4353                     minutes ({})",
4354                    seen.head,
4355                    WAIT_CEILING.as_secs() / 60,
4356                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4357                );
4358                stop_disarmed(forge, state, &repo, &pr, &why).await?;
4359                return Ok(pr);
4360            }
4361            waited += POLL;
4362            forge.poll().await;
4363            continue;
4364        }
4365        if armed_here && !matches!(step, Step::Done { .. }) {
4366            // Not merging or waiting any more: whatever is next may change the
4367            // head or give up, and neither may leave the arm standing.
4368            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4369                let why = format!("auto-merge could not be disabled: {e}");
4370                stop(state, &repo, &pr, &why).await?;
4371                return Ok(pr);
4372            }
4373        }
4374        if pr.checks == Checks::Red
4375            && matches!(step, Step::Fix { .. } | Step::GiveUp { .. })
4376            && pr.review_comments.is_empty()
4377            && let Some(evidence) = forge.attribution(state, &seen).await
4378            && evidence.unrelated(&seen.head, &pr.failing)
4379        {
4380            if owner_stop(state, &mut pr, forge).await? {
4381                return Ok(pr);
4382            }
4383            // Attribution does not authorize action against an unseen push.
4384            let fresh = forge.view(&repo, pr_url).await?;
4385            if fresh.head != seen.head
4386                || fresh.rollup_head != seen.rollup_head
4387                || fresh.pr.state != PrLifecycle::Open
4388                || fresh.pr.checks != Checks::Red
4389                || fresh.pr.failing != pr.failing
4390                || fresh
4391                    .pr
4392                    .review_comments
4393                    .iter()
4394                    .any(|c| !shown.contains(&c.body))
4395            {
4396                continue;
4397            }
4398            hold_unrelated(state, &pr, evidence).await?;
4399            return Ok(pr);
4400        }
4401        match step {
4402            Step::Wait => {
4403                if waited >= WAIT_CEILING {
4404                    let why = format!(
4405                        "checks were still running after {} minutes",
4406                        WAIT_CEILING.as_secs() / 60
4407                    );
4408                    stop(state, &repo, &pr, &why).await?;
4409                    return Ok(pr);
4410                }
4411                waited += POLL;
4412                forge.poll().await;
4413            }
4414            Step::Done { merged } => {
4415                if merged {
4416                    state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4417                }
4418                // Auto-merge is pinned to the approved head, but the forge's
4419                // own handling of a later push is not something magi can
4420                // see: if the pull request merged on another commit, say so
4421                // loudly rather than record a clean landing.
4422                if let Some(armed) = state.land_armed_head.take() {
4423                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4424                        let msg = format!(
4425                            "{} merged on {} but the owner approved {armed}; review what landed",
4426                            pr.url, seen.head
4427                        );
4428                        tracing::warn!("{msg}");
4429                        state.event("land", msg);
4430                        // Only an arm left by an older build can get here.
4431                        // The wording is fixed so a repeat does not relight
4432                        // the notice.
4433                        crate::notices::raise_with(
4434                            crate::notices::Notice::warn(
4435                                &format!("merged-unapproved-head:{}", state.id),
4436                                "A pull request merged on a commit the owner did not approve; \
4437                                 review what landed",
4438                            )
4439                            .link(crate::notices::Link::Run {
4440                                id: state.id.clone(),
4441                            }),
4442                            &state.config.notify,
4443                        );
4444                    }
4445                }
4446                state.status = if merged {
4447                    RunStatus::Merged
4448                } else {
4449                    RunStatus::Ready
4450                };
4451                let detail = if merged {
4452                    format!("{} was merged", pr.url)
4453                } else {
4454                    format!("{} was closed without merging", pr.url)
4455                };
4456                state.merge = Some(MergeOutcome {
4457                    mode: MergeMode::Pr,
4458                    ok: merged,
4459                    detail: detail.clone(),
4460                    empty: false,
4461                });
4462                state.event("land", detail);
4463                state.save()?;
4464                write_pr_state_through(state, pr.state);
4465                return Ok(pr);
4466            }
4467            Step::Merge => {
4468                state.check_delivery_evidence(&seen.head)?;
4469                let subject = merge_subject(
4470                    crate::graph::landing_title(state, &seen.title),
4471                    &crate::graph::landing_subject_source(state),
4472                );
4473                // The owner sees the panel before the one irreversible step,
4474                // and an unanswered question is a hold: silence never merges.
4475                //
4476                // The dedicated policy includes existing consent and post-repair
4477                // decisions; repair choice availability never grants merge authority.
4478                let contested = contested_to_ask(state);
4479                if !approval_reasons(state).is_empty() {
4480                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4481                        .await?
4482                    {
4483                        ApprovalGate::Approved => {}
4484                        ApprovalGate::Repair => {
4485                            // The driver consumes the saved request under the existing
4486                            // run/task claim. No merge, no second run or task.
4487                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4488                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4489                                state.save()?;
4490                            }
4491                            return Ok(pr);
4492                        }
4493                        ApprovalGate::Held => {
4494                            // Another entry point may have saved a stop after
4495                            // the first read above. Apply its durable action.
4496                            if owner_stop(state, &mut pr, forge).await? {
4497                                return Ok(pr);
4498                            }
4499                            stop(
4500                                state,
4501                                &repo,
4502                                &pr,
4503                                "the owner did not approve the merge (held or unanswered)",
4504                            )
4505                            .await?;
4506                            return Ok(pr);
4507                        }
4508                        // Filed (or still standing from an earlier visit) and
4509                        // not yet answered. Park here rather than wait: the
4510                        // question survives on disk, the daemon hands this
4511                        // run's slot to something else, and a later resume
4512                        // re-enters `land`, finds the same question, and
4513                        // either merges or stops depending on what it says
4514                        // by then.
4515                        ApprovalGate::Pending => {
4516                            state.end_pass_parked(crate::run::ParkCause::Question);
4517                            state.parked = true;
4518                            state.event(
4519                                "land",
4520                                "parked awaiting merge approval - resumes once answered",
4521                            );
4522                            state.save()?;
4523                            return Ok(pr);
4524                        }
4525                    }
4526                }
4527                // Open and past the gate above, so `seen.head` is the commit
4528                // the checks were bound to and the owner approved.
4529                //
4530                // Merge directly, bound to that commit. Auto-merge is not
4531                // armed any more: the forge checks `--match-head-commit` only
4532                // when the request is made, so an arm outlives the head it
4533                // was made for, and a push of another commit whose
4534                // requirements are met first would merge without approval.
4535                // A direct merge is checked by the forge at the moment it
4536                // happens, so only the approved head can land.
4537                let observed_head = seen.head.clone();
4538                // Read the pull request again just before: the state seen
4539                // above can be a moment old.
4540                {
4541                    let fresh = forge.view(&repo, pr_url).await.ok();
4542                    if !direct_merge_is_safe(
4543                        fresh.as_ref(),
4544                        &observed_head,
4545                        &shown,
4546                        round,
4547                        budget,
4548                        waited,
4549                        ci,
4550                    ) {
4551                        if waited >= WAIT_CEILING {
4552                            let why = "the pull request did not settle on the approved head \
4553                                       before it could be merged";
4554                            stop(state, &repo, &pr, why).await?;
4555                            return Ok(pr);
4556                        }
4557                        state.event(
4558                            "land",
4559                            "the pull request changed before merging; looking again",
4560                        );
4561                        waited += POLL;
4562                        forge.poll().await;
4563                        continue;
4564                    }
4565                }
4566                let argv = merge_argv_at(pr.number, &subject, &observed_head);
4567                let out = {
4568                    let merge_lock = repo_merge_lock(&repo);
4569                    let _merge_slot = merge_lock.lock().await;
4570                    crate::pr_head::ensure_owner(state)?;
4571                    if let Some(task_id) = state
4572                        .origin
4573                        .as_ref()
4574                        .and_then(|origin| origin.task.as_ref())
4575                    {
4576                        let task = crate::queue::Queue::open()
4577                            .get(task_id)
4578                            .context("cannot verify the task owner immediately before merge")?;
4579                        if task.runs.last() != Some(&state.id)
4580                            || task.operator_held()
4581                            || task.status == crate::queue::TaskStatus::Done
4582                        {
4583                            bail!(
4584                                "task ownership or an operator hold superseded this merge approval"
4585                            );
4586                        }
4587                    }
4588                    if let Some(binding) = &state.land_approval {
4589                        let current = ask::Questions::open().get(&binding.question)?;
4590                        if merge_consent_superseded(state, &current)?
4591                            || current.run != state.id
4592                            || !binding.head.eq_ignore_ascii_case(&observed_head)
4593                            || approval(current.resolution().as_deref()) != Approval::Merge
4594                            || current.merge_target.as_ref().is_some_and(|t| {
4595                                t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4596                            })
4597                        {
4598                            bail!(
4599                                "merge decision is stale or no longer authorizes the reviewed head"
4600                            );
4601                        }
4602                    }
4603                    forge.merge(&repo, &argv).await?
4604                };
4605                if out.0 {
4606                    // Exit 0 is not proof of a merge: with a merge queue `gh`
4607                    // enqueues (or reports the pull request already queued)
4608                    // and succeeds while it is still open. Ask the forge; an
4609                    // unreadable answer is not a confirmation either, so it
4610                    // is looked at again rather than recorded as merged.
4611                    let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4612                        if c.pr.state == PrLifecycle::Merged {
4613                            state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4614                            true
4615                        } else {
4616                            false
4617                        }
4618                    });
4619                    if !confirmed {
4620                        if waited >= WAIT_CEILING {
4621                            let why = "the merge request succeeded but the pull request \
4622                                       could not be confirmed merged after waiting";
4623                            stop(state, &repo, &pr, why).await?;
4624                            return Ok(pr);
4625                        }
4626                        state.event(
4627                            "land",
4628                            "merge accepted but the pull request is not confirmed merged yet; waiting",
4629                        );
4630                        state.save()?;
4631                        waited += POLL;
4632                        forge.poll().await;
4633                        continue;
4634                    }
4635                    pr.state = PrLifecycle::Merged;
4636                    state.status = RunStatus::Merged;
4637                    state.merge = Some(MergeOutcome {
4638                        mode: MergeMode::Pr,
4639                        ok: true,
4640                        detail: format!("gh {}", argv.join(" ")),
4641                        empty: false,
4642                    });
4643                    // The last `state.pr` snapshot is whatever the poll before
4644                    // this merge observed - still `open` - and nothing below
4645                    // refreshes it from GitHub again, so the UI's round rail
4646                    // would otherwise keep animating a merged run forever.
4647                    if let Some(pr_record) = state.pr.as_mut() {
4648                        pr_record.state = pr.state.as_str().to_owned();
4649                    }
4650                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4651                    announce_red_merge(state, &pr).await;
4652                    state.save()?;
4653                    write_pr_state_through(state, pr.state);
4654                    return Ok(pr);
4655                }
4656                let after_seen = forge.view(&repo, pr_url).await.ok();
4657                let after = after_seen.as_ref().map(|s| s.pr.state);
4658                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4659                    state.pr_merged_head = after_seen
4660                        .as_ref()
4661                        .map(|s| s.head.clone())
4662                        .filter(|h| !h.is_empty());
4663                    pr.state = PrLifecycle::Merged;
4664                    state.status = RunStatus::Merged;
4665                    state.merge = Some(outcome);
4666                    if let Some(pr_record) = state.pr.as_mut() {
4667                        pr_record.state = pr.state.as_str().to_owned();
4668                    }
4669                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4670                    announce_red_merge(state, &pr).await;
4671                    state.save()?;
4672                    write_pr_state_through(state, pr.state);
4673                    return Ok(pr);
4674                }
4675                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head, ci);
4676                match verdict {
4677                    Refused::Final => {
4678                        let merge_state = after_seen
4679                            .as_ref()
4680                            .map(|s| s.merge_state.as_str())
4681                            .filter(|m| !m.is_empty())
4682                            .unwrap_or("unknown");
4683                        // Which refusal this was decides what a person has to
4684                        // do about it, so the two are worded apart; both carry
4685                        // the forge's own message.
4686                        let why = format!(
4687                            "the merge was refused: {} (merge state: {merge_state})",
4688                            out.1
4689                        );
4690                        stop(state, &repo, &pr, &why).await?;
4691                        return Ok(pr);
4692                    }
4693                    verdict => {
4694                        // Back to the top, which re-decides and passes the
4695                        // approval gate again, a poll later. `waited` is not
4696                        // reset here: only a pushed fix restarts it, or a
4697                        // standing refusal would wait forever.
4698                        if waited >= WAIT_CEILING {
4699                            let why = format!(
4700                                "the merge was still refused after {} minutes: {}",
4701                                WAIT_CEILING.as_secs() / 60,
4702                                out.1
4703                            );
4704                            stop(state, &repo, &pr, &why).await?;
4705                            return Ok(pr);
4706                        }
4707                        if verdict == Refused::Recheck {
4708                            rechecked = true;
4709                        }
4710                        state.event(
4711                            "land",
4712                            "merge refused while the branch policy is not satisfied yet; waiting",
4713                        );
4714                        state.save()?;
4715                        waited += POLL;
4716                        forge.poll().await;
4717                    }
4718                }
4719            }
4720            Step::Rebase => {
4721                // Bounded by the same budget as a fix, because a rebase that
4722                // keeps being needed means the base moves faster than this
4723                // run can land and a person should decide what to do. It
4724                // spends none of that budget: the change is not what is
4725                // wrong.
4726                if rebases >= budget {
4727                    let why = format!(
4728                        "the base moved under this branch {budget} time(s) and it still does \
4729                         not merge; rebasing again would only race it"
4730                    );
4731                    stop(state, &repo, &pr, &why).await?;
4732                    return Ok(pr);
4733                }
4734                rebases += 1;
4735                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4736                    stop(
4737                        state,
4738                        &repo,
4739                        &pr,
4740                        "the pull request conflicts and this run has no winning branch to rebase",
4741                    )
4742                    .await?;
4743                    return Ok(pr);
4744                };
4745                let base = state.base_branch.clone();
4746                state.event(
4747                    "land",
4748                    format!("{} no longer merges; rebasing onto {base}", pr.url),
4749                );
4750                state.save()?;
4751
4752                // Onto the base as the *remote* has it: the local ref may be
4753                // behind, and rebasing onto a stale base produces a branch
4754                // that conflicts all over again.
4755                let remote = state.config.merge.remote.clone();
4756                git::fetch(&repo, &remote, &base).await.ok();
4757                if owner_stop(state, &mut pr, forge).await? {
4758                    return Ok(pr);
4759                }
4760                let onto = format!("{remote}/{base}");
4761                let rebased = match rebase_landing_worktree(state, &onto).await {
4762                    Ok(crate::rebase::Rebased::Applied) => Ok(None),
4763                    Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4764                    Err(e) => Err(e),
4765                };
4766                if owner_stop(state, &mut pr, forge).await? {
4767                    return Ok(pr);
4768                }
4769                match rebased {
4770                    Ok(None) => {
4771                        let published =
4772                            publish_landing_head(state, &seen.head, pr_url, forge).await;
4773                        if owner_stop(state, &mut pr, forge).await? {
4774                            return Ok(pr);
4775                        }
4776                        let head = published?;
4777                        crate::graph::refresh_reviewed_commits(state, &branch).await;
4778                        state.pr_pushed_head = Some(head.clone());
4779                        awaiting_head = Some(head);
4780                        rechecked = false;
4781                        state.event("land", format!("rebased {branch} onto {base}"));
4782                        state.save()?;
4783                        // The forge has to re-run its checks against the
4784                        // rebased head before anything else can be decided.
4785                        waited = Duration::ZERO;
4786                        tokio::time::sleep(POLL).await;
4787                    }
4788                    // The fixer's rounds are spent (or it could not finish):
4789                    // that is a decision for a person.
4790                    Ok(Some(conflict)) => {
4791                        let why = format!(
4792                            "{} conflicts with {base} and the rebase did not apply: {}",
4793                            pr.url,
4794                            conflict.chars().take(600).collect::<String>()
4795                        );
4796                        stop(state, &repo, &pr, &why).await?;
4797                        return Ok(pr);
4798                    }
4799                    Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4800                    Err(e) => {
4801                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
4802                        stop(state, &repo, &pr, &why).await?;
4803                        return Ok(pr);
4804                    }
4805                }
4806            }
4807            Step::GiveUp { reason } => {
4808                stop(state, &repo, &pr, &reason).await?;
4809                return Ok(pr);
4810            }
4811            Step::Fix { reason } => {
4812                round += 1;
4813                waited = Duration::ZERO;
4814                for c in &pr.review_comments {
4815                    shown.insert(c.body.clone());
4816                }
4817                state.event("land", format!("round {round}: {reason}"));
4818                state.save()?;
4819
4820                let logs = failing_logs(&repo, &seen.failing_urls).await;
4821                let was_red = pr.checks == Checks::Red;
4822                if owner_stop(state, &mut pr, forge).await? {
4823                    return Ok(pr);
4824                }
4825                let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4826                if owner_stop(state, &mut pr, forge).await? {
4827                    return Ok(pr);
4828                }
4829                match fixed {
4830                    Fixed::Committed { head } => {
4831                        // Whatever the next look shows may still be the
4832                        // previous head; see `awaiting_new_head`.
4833                        state.pr_pushed_head = Some(head.clone());
4834                        awaiting_head = Some(head);
4835                        rechecked = false;
4836                        waited = Duration::ZERO;
4837                        forge.poll().await;
4838                    }
4839                    Fixed::Declined if was_red => {
4840                        // Review comments may have required this round even
4841                        // when CI was unrelated. Once the fixer has read them,
4842                        // use the same structural attribution before charging
4843                        // a no-commit stop to the task.
4844                        if let Some(evidence) = forge.attribution(state, &seen).await
4845                            && evidence.unrelated(&seen.head, &pr.failing)
4846                        {
4847                            let fresh = forge.view(&repo, pr_url).await?;
4848                            if fresh.head != seen.head
4849                                || fresh.rollup_head != seen.rollup_head
4850                                || fresh.pr.state != PrLifecycle::Open
4851                                || fresh.pr.checks != Checks::Red
4852                                || fresh.pr.failing != pr.failing
4853                                || fresh
4854                                    .pr
4855                                    .review_comments
4856                                    .iter()
4857                                    .any(|c| !shown.contains(&c.body))
4858                            {
4859                                continue;
4860                            }
4861                            if owner_stop(state, &mut pr, forge).await? {
4862                                return Ok(pr);
4863                            }
4864                            hold_unrelated(state, &pr, evidence).await?;
4865                            return Ok(pr);
4866                        }
4867                        let why = format!(
4868                            "the fixer produced no commit while {} check(s) were failing \
4869                             ({}); stopping instead of looping on an unchanged tree",
4870                            pr.failing.len(),
4871                            pr.failing.join(", ")
4872                        );
4873                        stop(state, &repo, &pr, &why).await?;
4874                        return Ok(pr);
4875                    }
4876                    // Comment-driven round with no commit: the fixer read the
4877                    // comments and changed nothing, which is a decision it is
4878                    // allowed to make. The comments are recorded as shown, so
4879                    // the next observation sees a clean pull request.
4880                    Fixed::Declined => state.event(
4881                        "land",
4882                        format!("round {round}: fixer declined the comments, nothing committed"),
4883                    ),
4884                    Fixed::Failed(why) => {
4885                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4886                        return Ok(pr);
4887                    }
4888                }
4889                state.save()?;
4890            }
4891        }
4892    }
4893}
4894
4895/// Owner decision for a CI failure independently reproduced on the base.
4896pub const CI_HOLD_NODE: &str = "land-ci-unrelated";
4897
4898/// Durable machine-hold evidence, bound to one observed PR head.
4899#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4900pub struct CiHold {
4901    /// Head that exhibited the independently reproduced failures.
4902    pub head: String,
4903    /// Typed owner question controlling resumption.
4904    pub question: String,
4905    /// Complete structured facts used to attribute the failures.
4906    pub evidence: CiEvidence,
4907}
4908
4909/// Only structured forge annotations are compared, never log prose.
4910#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
4911struct CiFailure {
4912    path: String,
4913    start_line: u64,
4914    end_line: u64,
4915    title: String,
4916    message: String,
4917}
4918
4919#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4920struct CiCheckEvidence {
4921    label: String,
4922    head_check: u64,
4923    base_check: u64,
4924    app: u64,
4925    run: String,
4926    base_run: String,
4927    head_failures: Vec<CiFailure>,
4928    base_failures: Vec<CiFailure>,
4929    rerun_exhausted: bool,
4930}
4931
4932/// Pinned Git diff and forge failure annotations used for CI attribution.
4933#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4934pub struct CiEvidence {
4935    head: String,
4936    base: String,
4937    changed: Vec<String>,
4938    checks: Vec<CiCheckEvidence>,
4939}
4940
4941impl CiEvidence {
4942    /// Every red check must have the same located failure on the base, outside
4943    /// the diff. Changes to CI/build configuration invalidate this inference.
4944    fn unrelated(&self, head: &str, failing: &[String]) -> bool {
4945        if self.head != head
4946            || self.base.is_empty()
4947            || failing.is_empty()
4948            || self.changed.iter().any(|p| {
4949                p.starts_with(".github/")
4950                    || p.starts_with(".config/")
4951                    || p.starts_with(".cargo/")
4952                    || matches!(
4953                        p.rsplit('/').next().unwrap_or(p),
4954                        "Cargo.toml"
4955                            | "Cargo.lock"
4956                            | "Makefile.toml"
4957                            | "build.rs"
4958                            | "rust-toolchain.toml"
4959                    )
4960            })
4961        {
4962            return false;
4963        }
4964        failing.iter().all(|label| {
4965            let matches: Vec<_> = self.checks.iter().filter(|c| &c.label == label).collect();
4966            matches.len() == 1
4967                && matches[0].rerun_exhausted
4968                && !matches[0].head_failures.is_empty()
4969                && matches[0].head_failures.iter().all(|f| {
4970                    // Workflow-level exit-code annotations do not identify a
4971                    // failing test or source file, and prove nothing here.
4972                    !f.path.is_empty()
4973                        && !f.path.starts_with('.')
4974                        && !f.path.contains("..")
4975                        && !f.path.starts_with('/')
4976                        && !self.changed.contains(&f.path)
4977                        && matches[0].base_failures.contains(f)
4978                })
4979        })
4980    }
4981}
4982
4983async fn ci_json(repo: &Path, endpoint: String) -> Option<serde_json::Value> {
4984    let (ok, body) = gh(repo, &["api".into(), endpoint]).await.ok()?;
4985    if !ok {
4986        return None;
4987    }
4988    serde_json::from_str(&body).ok()
4989}
4990
4991async fn ci_annotations(repo: &Path, check: &serde_json::Value) -> Option<Vec<CiFailure>> {
4992    let count = check.pointer("/output/annotations_count")?.as_u64()?;
4993    if count == 0 || count >= 100 {
4994        return None;
4995    }
4996    let id = check.get("id")?.as_u64()?;
4997    let value = ci_json(
4998        repo,
4999        format!("repos/{{owner}}/{{repo}}/check-runs/{id}/annotations?per_page=100"),
5000    )
5001    .await?;
5002    let annotations = value.as_array()?;
5003    if annotations.len() != count as usize {
5004        return None;
5005    }
5006    annotations
5007        .iter()
5008        .filter(|a| a["annotation_level"] == "failure")
5009        .map(|a| {
5010            Some(CiFailure {
5011                path: a["path"].as_str()?.into(),
5012                start_line: a["start_line"].as_u64()?,
5013                end_line: a["end_line"].as_u64()?,
5014                title: a["title"].as_str()?.into(),
5015                message: a["message"].as_str()?.into(),
5016            })
5017        })
5018        .collect()
5019}
5020
5021async fn ci_runs(repo: &Path, sha: &str) -> Option<Vec<serde_json::Value>> {
5022    let value = ci_json(
5023        repo,
5024        format!("repos/{{owner}}/{{repo}}/commits/{sha}/check-runs?per_page=100&filter=latest"),
5025    )
5026    .await?;
5027    let runs = value["check_runs"].as_array()?;
5028    if value["total_count"].as_u64()? != runs.len() as u64 || runs.len() >= 100 {
5029        return None;
5030    }
5031    Some(runs.clone())
5032}
5033
5034async fn collect_ci_evidence(state: &RunState, seen: &Seen) -> Option<CiEvidence> {
5035    let repo = &state.repo;
5036    let base_ref = format!("refs/remotes/{}/{}", state.config.merge.remote, seen.base);
5037    let base = git::git_raw(repo, &["rev-parse", "--verify", &base_ref])
5038        .await
5039        .ok()?;
5040    if !base.ok() {
5041        return None;
5042    }
5043    let range = format!("{}...{}", base.stdout, seen.head);
5044    // No rename detection: both old and new paths must count as changed.
5045    let diff = git::git_raw(
5046        repo,
5047        &["diff", "--no-renames", "--name-only", "-z", &range, "--"],
5048    )
5049    .await
5050    .ok()?;
5051    if !diff.ok() {
5052        return None;
5053    }
5054    let head_runs = ci_runs(repo, &seen.head).await?;
5055    let base_runs = ci_runs(repo, &base.stdout).await?;
5056    let mut checks = Vec::new();
5057    for label in &seen.pr.failing {
5058        let matching = |runs: &[serde_json::Value]| {
5059            runs.iter()
5060                .filter(|r| r["name"].as_str() == Some(label))
5061                .cloned()
5062                .collect::<Vec<_>>()
5063        };
5064        let heads = matching(&head_runs);
5065        let bases = matching(&base_runs);
5066        if heads.len() != 1 || bases.len() != 1 {
5067            return None;
5068        }
5069        let h = &heads[0];
5070        let b = &bases[0];
5071        if h["conclusion"] != "failure"
5072            || b["conclusion"] != "failure"
5073            || h["status"] != "completed"
5074            || b["status"] != "completed"
5075            || h["head_sha"].as_str()? != seen.head
5076            || b["head_sha"].as_str()? != base.stdout
5077            || h.pointer("/app/id")?.as_u64()? != b.pointer("/app/id")?.as_u64()?
5078        {
5079            return None;
5080        }
5081        let run = run_of(h["details_url"].as_str()?)?;
5082        let contexts: Vec<_> = seen.contexts.iter().filter(|c| &c.label == label).collect();
5083        if contexts.len() != 1
5084            || contexts[0].run.as_ref() != Some(&run)
5085            || contexts[0].verdict != Verdict::Fail
5086        {
5087            return None;
5088        }
5089        // A persisted request alone is not a completed rerun: require the
5090        // workflow's structured attempt number as well.
5091        let workflow =
5092            ci_json(repo, format!("repos/{{owner}}/{{repo}}/actions/runs/{run}")).await?;
5093        let base_run = run_of(b["details_url"].as_str()?)?;
5094        let base_workflow = ci_json(
5095            repo,
5096            format!("repos/{{owner}}/{{repo}}/actions/runs/{base_run}"),
5097        )
5098        .await?;
5099        if base_workflow["head_sha"].as_str()? != base.stdout
5100            || base_workflow["head_branch"].as_str()? != seen.base
5101            || base_workflow["status"] != "completed"
5102            || base_workflow["conclusion"] != "failure"
5103            || base_workflow["workflow_id"].as_u64()? != workflow["workflow_id"].as_u64()?
5104        {
5105            return None;
5106        }
5107        let exhausted = state
5108            .land_reruns
5109            .as_ref()
5110            .is_some_and(|r| r.head == seen.head && r.runs.contains_key(&run))
5111            && workflow["head_sha"].as_str()? == seen.head
5112            && workflow["run_attempt"].as_u64()? > 1
5113            && workflow["status"] == "completed";
5114        checks.push(CiCheckEvidence {
5115            label: label.clone(),
5116            head_check: h["id"].as_u64()?,
5117            base_check: b["id"].as_u64()?,
5118            app: h.pointer("/app/id")?.as_u64()?,
5119            run,
5120            base_run,
5121            head_failures: ci_annotations(repo, h).await?,
5122            base_failures: ci_annotations(repo, b).await?,
5123            rerun_exhausted: exhausted,
5124        });
5125    }
5126    Some(CiEvidence {
5127        head: seen.head.clone(),
5128        base: base.stdout,
5129        changed: diff
5130            .stdout
5131            .split('\0')
5132            .filter(|p| !p.is_empty())
5133            .map(str::to_owned)
5134            .collect(),
5135        checks,
5136    })
5137}
5138
5139async fn hold_unrelated(state: &mut RunState, pr: &PrState, evidence: CiEvidence) -> Result<()> {
5140    let detail = serde_json::to_string(&evidence)?;
5141    let resume = "daemon: CI を確認して同じ run を再開する".to_owned();
5142    let hold = "operator: 保留して後で判断する".to_owned();
5143    let mut q = ask::Question::new(
5144        state.id.clone(),
5145        CI_HOLD_NODE.into(),
5146        "land".into(),
5147        "base でも再現する CI の失敗を検出したため、試行を消費せず保留しました".into(),
5148        format!(
5149            "必須チェックや承認は省略しません。再開時にも同じ workflow run は自動再実行しません。\n構造化された判定根拠:\n{detail}"
5150        ),
5151        vec![resume.clone(), hold.clone()],
5152    );
5153    q.actions.insert(
5154        resume,
5155        ask::ChoiceAction::Resume {
5156            run: state.id.clone(),
5157        },
5158    );
5159    q.actions.insert(hold, ask::ChoiceAction::Hold);
5160    ask::Questions::open().put_executable(&mut q)?;
5161    state.land_unrelated = Some(CiHold {
5162        head: evidence.head.clone(),
5163        question: q.id.clone(),
5164        evidence,
5165    });
5166    state.event("land", format!("[ci-unrelated] base reproduces every located failure after rerun; owner question {}: {detail}", q.id));
5167    state.end_pass_parked(crate::run::ParkCause::Question);
5168    state.parked = true;
5169    state.save()?;
5170    if let Err(e) = ask::notify(&state.config.notify, &q).await {
5171        tracing::warn!("could not notify about CI hold: {e:#}");
5172    }
5173    // A durable question and evidence are sufficient even if commenting fails.
5174    let body = stop_comment(
5175        &state.id,
5176        "CI failures independently reproduced on the base; awaiting an owner decision without charging the task attempt",
5177    );
5178    let (_, body) = crate::github_text::prepare(state, "", &body);
5179    gh(
5180        &state.repo,
5181        &[
5182            "pr".into(),
5183            "comment".into(),
5184            pr.number.to_string(),
5185            "--body".into(),
5186            body,
5187        ],
5188    )
5189    .await
5190    .ok();
5191    Ok(())
5192}
5193
5194/// One observation, plus the two things [`PrState`] deliberately does not carry:
5195/// the title (needed for the squash subject) and where the failing checks'
5196/// logs live.
5197#[derive(Clone)]
5198struct Seen {
5199    pr: PrState,
5200    title: String,
5201    failing_urls: Vec<(String, String)>,
5202    /// `headRefOid`: the commit this observation, checks included, is about.
5203    head: String,
5204    /// The commit the checks belong to, read from the same GraphQL node as
5205    /// the checks themselves. Empty when unreadable.
5206    rollup_head: String,
5207    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
5208    /// BEHIND and DRAFT together, and a stop reason has to say which.
5209    merge_state: String,
5210    /// Every check of the rollup, for naming what an armed merge waits on.
5211    contexts: Vec<CheckInfo>,
5212    /// `baseRefName`, to look up which contexts the base requires.
5213    base: String,
5214}
5215
5216/// One check of the rollup as far as a stop reason needs it.
5217#[derive(Clone)]
5218struct CheckInfo {
5219    label: String,
5220    verdict: Verdict,
5221    required: Option<bool>,
5222    /// The workflow run the check belongs to; `None` for an external status.
5223    run: Option<String>,
5224}
5225
5226/// What to do with a red head before a fix round is spent on it.
5227#[derive(Debug, Clone, PartialEq, Eq)]
5228enum RerunStep {
5229    /// Rerun these workflow runs (each not yet rerun for this head).
5230    Rerun(Vec<String>),
5231    /// Reran already and the forge may still report the old failure: wait.
5232    Grace,
5233    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
5234    Fix,
5235}
5236
5237/// Failed checks a rerun may help: a failure of a check that blocks (`required`
5238/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
5239/// pending checks in another run do not delay this run's rerun.
5240#[cfg(test)]
5241fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
5242    rerunnable_with(contexts, false)
5243}
5244
5245fn rerunnable_with(
5246    contexts: &[CheckInfo],
5247    include_optional: bool,
5248) -> Vec<(String, Option<String>)> {
5249    contexts
5250        .iter()
5251        .filter(|c| c.verdict == Verdict::Fail && (include_optional || c.required != Some(false)))
5252        // `gh run rerun --failed` refuses a run with a job still going.
5253        .filter(|c| {
5254            c.run.as_ref().is_none_or(|r| {
5255                !contexts.iter().any(|o| {
5256                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
5257                        && o.run.as_ref() == Some(r)
5258                })
5259            })
5260        })
5261        .map(|c| (c.label.clone(), c.run.clone()))
5262        .collect()
5263}
5264
5265/// Pure: rerun once per run id, wait out the grace, then hand over.
5266fn rerun_step(
5267    failing: &[(String, Option<String>)],
5268    record: Option<&LandReruns>,
5269    head: &str,
5270    now: i64,
5271) -> RerunStep {
5272    // A failure with no workflow run (an external status) cannot be rerun, and
5273    // the fixer's push re-runs everything anyway.
5274    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
5275        return RerunStep::Fix;
5276    }
5277    // Reruns belong to the head they were made on.
5278    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
5279    let mut fresh: Vec<String> = Vec::new();
5280    let mut recent = false;
5281    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
5282        match record.and_then(|r| r.runs.get(run)) {
5283            None => {
5284                if !fresh.iter().any(|f| f == run) {
5285                    fresh.push(run.to_owned());
5286                }
5287            }
5288            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
5289            Some(_) => {}
5290        }
5291    }
5292    if !fresh.is_empty() {
5293        RerunStep::Rerun(fresh)
5294    } else if recent {
5295        RerunStep::Grace
5296    } else {
5297        RerunStep::Fix
5298    }
5299}
5300
5301/// Read the pull request: `gh pr view` for the top-level thread and merge
5302/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
5303/// inline review comments `gh pr view` does not report.
5304async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
5305    let view = gh(
5306        repo,
5307        &[
5308            "pr".to_owned(),
5309            "view".to_owned(),
5310            pr_url.to_owned(),
5311            "--json".to_owned(),
5312            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
5313                .to_owned(),
5314        ],
5315    )
5316    .await?;
5317    if !view.0 {
5318        bail!("gh pr view {pr_url}: {}", view.1);
5319    }
5320    let number = parse_pr(&view.1)?.number;
5321    let node = last_commit_node(repo, number).await;
5322    let mut seen = seen_from(&view.1, node.as_deref())?;
5323
5324    let inline = gh(
5325        repo,
5326        &[
5327            "api".to_owned(),
5328            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
5329        ],
5330    )
5331    .await?;
5332    if inline.0 {
5333        match parse_inline_comments(&inline.1) {
5334            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5335            // An unreadable inline thread must not end a landing: the rollup
5336            // and the top-level thread are still real signal.
5337            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5338        }
5339    } else {
5340        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5341    }
5342    Ok(seen)
5343}
5344
5345/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
5346/// response. No I/O.
5347///
5348/// The commit oid and the checks are read from the *same* node, so the rollup
5349/// is bound to the commit it belongs to by construction; two reads that agree
5350/// before and after another response prove nothing about what that response
5351/// held. The view's own rollup is never used. A node that is missing,
5352/// unreadable, carries GraphQL errors, or whose checks run past the page
5353/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
5354/// as "look again" and never as a reason to merge.
5355fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5356    let mut pr = parse_pr(view_json)?;
5357    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5358
5359    let mut rollup_head = String::new();
5360    let mut failing_urls = Vec::new();
5361    let mut contexts = Vec::new();
5362    let mut checks = Checks::Unknown;
5363    let mut failing = Vec::new();
5364    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5365        (checks, failing) = rollup_verdict(&rollup);
5366        failing_urls = rollup
5367            .iter()
5368            .filter(|c| c.verdict() == Verdict::Fail)
5369            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5370            .collect();
5371        contexts = rollup
5372            .iter()
5373            .map(|c| CheckInfo {
5374                label: c.label(),
5375                verdict: c.verdict(),
5376                required: c.is_required,
5377                run: c.url().and_then(run_of),
5378            })
5379            .collect();
5380        rollup_head = oid;
5381    }
5382    pr.checks = checks;
5383    pr.failing = failing;
5384
5385    Ok(Seen {
5386        pr,
5387        title: raw.title,
5388        failing_urls,
5389        head: raw.head_ref_oid,
5390        rollup_head,
5391        merge_state: raw.merge_state_status,
5392        contexts,
5393        base: raw.base_ref_name,
5394    })
5395}
5396
5397/// The last commit's oid and its checks from one GraphQL response, `None`
5398/// when anything about it cannot be trusted.
5399fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5400    let v: serde_json::Value = serde_json::from_str(json).ok()?;
5401    if v.get("errors").is_some_and(|e| !e.is_null()) {
5402        return None;
5403    }
5404    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5405    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5406    let contexts = commit.pointer("/statusCheckRollup/contexts");
5407    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5408        // No rollup at all: the commit has no checks.
5409        return Some((oid.to_owned(), Vec::new()));
5410    };
5411    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5412        return None;
5413    }
5414    let nodes = contexts.get("nodes")?.as_array()?;
5415    let rollup = nodes
5416        .iter()
5417        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5418        .collect::<Result<Vec<_>, _>>()
5419        .ok()?;
5420    Some((oid.to_owned(), rollup))
5421}
5422
5423/// The pull request's last commit and its checks, in one response. `None`
5424/// when the forge could not be asked.
5425async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5426    let out = gh(
5427        repo,
5428        &[
5429            "api".to_owned(),
5430            "graphql".to_owned(),
5431            "-F".to_owned(),
5432            "owner={owner}".to_owned(),
5433            "-F".to_owned(),
5434            "repo={repo}".to_owned(),
5435            "-F".to_owned(),
5436            format!("number={number}"),
5437            "-f".to_owned(),
5438            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5439             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5440             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5441             ... on CheckRun{name status conclusion detailsUrl \
5442             isRequired(pullRequestNumber:$number)} \
5443             ... on StatusContext{context state targetUrl \
5444             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5445                .to_owned(),
5446        ],
5447    )
5448    .await
5449    .ok()?;
5450    out.0.then_some(out.1)
5451}
5452
5453/// What a fix round did.
5454#[doc(hidden)]
5455#[derive(Debug, PartialEq)]
5456pub enum Fixed {
5457    /// The fixer committed something, and this is the head that was pushed.
5458    Committed {
5459        /// The commit now at the tip of the pushed branch.
5460        head: String,
5461    },
5462    /// The fixer ran and chose to change nothing.
5463    Declined,
5464    /// The fixer could not run, or said nothing usable.
5465    Failed(String),
5466}
5467
5468/// Hand the failures and the comments to the fixer, then commit and push.
5469///
5470/// The fixer works in the winner's own worktree so its commits land on the
5471/// branch the pull request is built from, and it runs with `allow_write` for
5472/// the same reason.
5473#[doc(hidden)]
5474pub async fn fix_round(
5475    state: &mut RunState,
5476    pr: &PrState,
5477    round: usize,
5478    budget: usize,
5479    reason: &str,
5480    logs: &str,
5481) -> Result<Fixed> {
5482    let winner = state
5483        .winner()
5484        .cloned()
5485        .context("landing needs a winning candidate; none is recorded on this run")?;
5486    let roles = state
5487        .config
5488        .resolve_roles()
5489        .context("resolve the roster for the fix round")?;
5490    // Same chain as the review loop (see `crate::fixer`): the configured
5491    // fixers in order, otherwise the winner's own author continuing its own
5492    // conversation - the competition is over, so its context is pure benefit.
5493    let attempts = crate::fixer::attempts(state, &roles, &winner);
5494    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5495
5496    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5497    let mut artifacts = agent::artifacts_dir(&state.dir());
5498    if let Some(request) = state
5499        .merge_repairs
5500        .last()
5501        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5502    {
5503        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5504    }
5505    let prompt = if state.config.cache_dir().is_some() {
5506        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5507    } else {
5508        prompt
5509    };
5510    // Read before the fixer runs: it normally commits for itself, so HEAD has
5511    // already moved by the time it returns and a later read would see no
5512    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
5513    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5514    // Each id at most once, forward only: the next one is asked only when the
5515    // call advances, and only the last attempt's result is judged below, so an
5516    // exhausted chain ends as a single failed fixer does.
5517    let mut last = None;
5518    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5519        if owner_requested_stop(state)? {
5520            return Ok(Fixed::Declined);
5521        }
5522        let mut seat = seat_of(state, &seat_key, &spec.id);
5523        let stem = if i == 0 {
5524            format!("land-{round}")
5525        } else {
5526            format!("land-{round}-{}", spec.id)
5527        };
5528        let out = agent::invoke(
5529            &spec,
5530            &mut seat,
5531            &Invocation {
5532                coordination: state.config.coordination.as_ref(),
5533                editing_network: state.config.graph.editing_network,
5534                fetched_base: state.fetched_base.as_ref(),
5535                cwd: &winner.worktree,
5536                prompt: &prompt,
5537                timeout: Duration::from_secs(state.config.graph.timeout_fix),
5538                allow_write: true,
5539                unsandboxed: false,
5540                sessions: state.config.graph.sessions,
5541                artifacts: &artifacts,
5542                stem: &stem,
5543                run: &state.id,
5544                node: "land",
5545                cache_dir: state.config.cache_dir().as_deref(),
5546                attachments: &[],
5547                writable: &[],
5548            },
5549        )
5550        .await;
5551        state.seats.insert(seat.key.clone(), seat);
5552        if let Some(next) = ids.get(i + 1)
5553            && agent::chain_advances(&out)
5554        {
5555            let (class, why) = crate::fixer::failure_of(&out);
5556            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5557            continue;
5558        }
5559        last = Some(out);
5560        break;
5561    }
5562    let out = last.expect("the fixer chain always has an entry");
5563
5564    match out {
5565        Ok(o) if o.quota_exhausted() => {
5566            return Ok(Fixed::Failed(
5567                "rate limited (quota); the fixer could not run".to_owned(),
5568            ));
5569        }
5570        Ok(o) if !o.usable() => {
5571            return Ok(Fixed::Failed(format!(
5572                "the fixer produced nothing usable ({})",
5573                crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5574            )));
5575        }
5576        Ok(_) => {}
5577        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5578        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5579    }
5580
5581    if owner_requested_stop(state)? {
5582        return Ok(Fixed::Declined);
5583    }
5584
5585    // An agent that edited files but never committed would otherwise push
5586    // nothing and look like a refusal.
5587    let wait = crate::repo_lock::WaitPolicy::new(
5588        Duration::from_secs(state.config.graph.timeout_resource_wait),
5589        || false,
5590    );
5591    let coordination = crate::repo_lock::SeatLease::operation_with(
5592        &winner.worktree,
5593        state.config.coordination.as_ref(),
5594        &wait,
5595    )
5596    .await?;
5597    match git::rescue_commit(
5598        &winner.worktree,
5599        &format!("magi: land round {round} fixes (uncommitted work)"),
5600    )
5601    .await
5602    {
5603        Ok(r) => state.note_withheld("land", &r.withheld),
5604        Err(e)
5605            if state
5606                .merge_repairs
5607                .last()
5608                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5609        {
5610            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5611        }
5612        Err(_) => {}
5613    }
5614    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5615    drop(coordination);
5616    if after == before {
5617        return Ok(Fixed::Declined);
5618    }
5619
5620    if owner_requested_stop(state)? {
5621        return Ok(Fixed::Declined);
5622    }
5623    crate::pr_head::ensure_owner(state)?;
5624    let remote = state.config.merge.remote.clone();
5625    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5626    let push = if state.pr_head.is_some() {
5627        let expected = state
5628            .pr_pushed_head
5629            .as_deref()
5630            .context("missing public head lease")?;
5631        git::push_as(
5632            &winner.worktree,
5633            &remote,
5634            &winner.branch,
5635            &public_head,
5636            expected,
5637        )
5638        .await?
5639    } else {
5640        git::push(&winner.worktree, &remote, &winner.branch).await?
5641    };
5642    if !push.ok() {
5643        return Ok(Fixed::Failed(format!(
5644            "pushing {} to {remote} failed: {}",
5645            public_head, push.stderr
5646        )));
5647    }
5648    state.event(
5649        "land",
5650        format!("round {round}: pushed a fix to {public_head}"),
5651    );
5652    state.pr_pushed_head = Some(after.clone());
5653    state.save()?;
5654    Ok(Fixed::Committed { head: after })
5655}
5656
5657/// Fetch or create a seat, keeping its conversation across nodes.
5658pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5659    crate::fixer::seat_for(state, key, agent)
5660}
5661
5662/// Recheck the live PR and local worktree before spending the requested fix.
5663/// A moved/closed PR is held, never repaired using obsolete evidence.
5664pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5665    let seen = observe(&state.repo, url).await?;
5666    let winner = state.winner().context("no winning branch to repair")?;
5667    if seen.pr.state != PrLifecycle::Open || seen.head != head {
5668        anyhow::bail!(
5669            "PR is closed or its head changed; review the current head before requesting repair again"
5670        );
5671    }
5672    if git::rev_parse(&winner.worktree, "HEAD").await? != head
5673        || !git::is_clean(&winner.worktree).await?
5674    {
5675        anyhow::bail!(
5676            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5677        );
5678    }
5679    // Always disable any forge-side reservation, including one not recorded
5680    // by this run. A repair request is never merge authorization.
5681    let view = gh(
5682        &state.repo,
5683        &[
5684            "pr".into(),
5685            "view".into(),
5686            url.into(),
5687            "--json".into(),
5688            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5689        ],
5690    )
5691    .await?;
5692    if !view.0 {
5693        bail!("cannot check auto-merge: {}", view.1);
5694    }
5695    let live: serde_json::Value = serde_json::from_str(&view.1)?;
5696    let public = state.pr_branch().unwrap_or(&winner.branch);
5697    if live["state"] != "OPEN"
5698        || live["headRefOid"] != head
5699        || live["headRefName"] != public
5700        || live["baseRefName"] != state.base_branch
5701        || live["isCrossRepository"] != false
5702    {
5703        bail!("PR changed during repair preflight; review its current head explicitly");
5704    }
5705    let auto_merge = live
5706        .get("autoMergeRequest")
5707        .context("cannot read the PR auto-merge reservation; repair held")?;
5708    if !auto_merge.is_null() {
5709        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5710        if !out.0 {
5711            bail!("cannot disable auto-merge; repair held: {}", out.1);
5712        }
5713    }
5714    Ok(seen.pr)
5715}
5716
5717/// What the fixer is told.
5718fn fix_prompt(
5719    state: &RunState,
5720    pr: &PrState,
5721    round: usize,
5722    budget: usize,
5723    reason: &str,
5724    logs: &str,
5725) -> String {
5726    let mut s = format!(
5727        "Your patch is open as a pull request and it is not landing. Land round \
5728         {round} of {budget}.\n\n\
5729         Pull request: {}\n\n\
5730         What is holding it: {reason}\n\n\
5731         # The task\n\n{}\n",
5732        pr.url, state.instruction
5733    );
5734
5735    if pr.failing.is_empty() {
5736        s.push_str("\n# Failing checks\n\n(none)\n");
5737    } else {
5738        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5739        if logs.trim().is_empty() {
5740            s.push_str(
5741                "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5742            );
5743        } else {
5744            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5745        }
5746    }
5747
5748    if pr.review_comments.is_empty() {
5749        s.push_str("\n# Review comments\n\n(none)\n");
5750    } else {
5751        s.push_str("\n# Review comments\n");
5752        for c in &pr.review_comments {
5753            let where_ = match (&c.path, c.line) {
5754                (Some(p), Some(l)) => format!(" ({p}:{l})"),
5755                (Some(p), None) => format!(" ({p})"),
5756                _ => String::new(),
5757            };
5758            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5759        }
5760    }
5761
5762    s.push_str(
5763        "\n# Rules\n\n\
5764         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5765            failing test; do not silence a lint with an allow attribute; do not \
5766            stretch a timeout to hide a race. If the check is right, the code is \
5767            wrong.\n\
5768         2. Change nothing the checks and the comments did not raise. A \
5769            drive-by refactor turns a one-line fix into a pull request that \
5770            needs reviewing again.\n\
5771         3. If a comment is wrong, say so with a checkable argument and change \
5772            nothing for it. A declined comment with a reason is a correct \
5773            outcome; a change made to appease a reviewer is not.\n\
5774         4. Commit in this worktree. magi pushes to the pull request's branch \
5775            for you; do not push, merge, or close anything yourself.\n\
5776         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5777         # Output\n\n\
5778         Say what you changed and why, and what you declined and why.",
5779    );
5780
5781    let language = &state.config.graph.language;
5782    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5783        let _ = write!(s, "\n\nWrite all prose in {language}.");
5784    }
5785    // After the language line, so the exception is the last word on it.
5786    s.push_str(&crate::prompt::github_rule(
5787        language,
5788        &state.config.graph.github_language,
5789    ));
5790    if let Some(overlay) = state.config.prompts.overlay("fix") {
5791        let _ = write!(s, "\n\n{overlay}");
5792    }
5793    s.push('\n');
5794    s.push_str(crate::prompt::child_run_ownership());
5795    s
5796}
5797
5798/// Failing log tails, the way the operator collects them by hand:
5799/// `gh run view --log-failed`.
5800async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5801    let mut out = String::new();
5802    for (name, url) in failing.iter().take(MAX_LOGS) {
5803        let args = match (job_of(url), run_of(url)) {
5804            (Some(job), _) => vec![
5805                "run".to_owned(),
5806                "view".to_owned(),
5807                "--log-failed".to_owned(),
5808                "--job".to_owned(),
5809                job,
5810            ],
5811            (None, Some(run)) => vec![
5812                "run".to_owned(),
5813                "view".to_owned(),
5814                run,
5815                "--log-failed".to_owned(),
5816            ],
5817            // Not a GitHub Actions check - an external status has no log here.
5818            (None, None) => continue,
5819        };
5820        let (ok, body) = match gh(repo, &args).await {
5821            Ok(v) => v,
5822            Err(e) => (false, format!("{e:#}")),
5823        };
5824        if !ok && body.trim().is_empty() {
5825            continue;
5826        }
5827        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5828    }
5829    out
5830}
5831
5832/// Job id out of a check's `detailsUrl`
5833/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
5834fn job_of(details_url: &str) -> Option<String> {
5835    let after = details_url.split("/job/").nth(1)?;
5836    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5837    (!id.is_empty()).then_some(id)
5838}
5839
5840/// Workflow run id out of a check's `detailsUrl`.
5841pub(crate) fn run_of(details_url: &str) -> Option<String> {
5842    let after = details_url.split("/actions/runs/").nth(1)?;
5843    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5844    (!id.is_empty()).then_some(id)
5845}
5846
5847/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
5848/// it lands on GitHub, not in front of the operator. Pure so a test can hold
5849/// it to that.
5850fn stop_comment(run_id: &str, why: &str) -> String {
5851    format!(
5852        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5853         The branch is untouched and the run is `{run_id}`. Nothing was merged."
5854    )
5855}
5856
5857/// Leave the pull request open, say why on it, and mark the run blocked.
5858///
5859/// The comment is what makes an unattended stop actionable: the operator wakes
5860/// up to a pull request that explains itself rather than to a silent queue.
5861async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5862    let body = stop_comment(&state.id, why);
5863    let (_, mut body) = crate::github_text::prepare(state, "", &body);
5864    if !body.contains(MARKER) {
5865        body = format!("{MARKER}\n{body}");
5866    }
5867    let posted = gh(
5868        repo,
5869        &[
5870            "pr".to_owned(),
5871            "comment".to_owned(),
5872            pr.number.to_string(),
5873            "--body".to_owned(),
5874            body,
5875        ],
5876    )
5877    .await;
5878    match posted {
5879        Ok((true, _)) => {}
5880        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5881        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5882    }
5883    state.status = RunStatus::Blocked;
5884    state.merge = Some(MergeOutcome {
5885        mode: MergeMode::Pr,
5886        ok: false,
5887        detail: why.to_owned(),
5888        empty: false,
5889    });
5890    state.event("land", format!("stopped: {why}"));
5891    state.save()?;
5892    Ok(())
5893}
5894
5895/// Run `gh` in `repo`, returning success and the combined output.
5896///
5897/// Combined because `gh` reports a refused merge on stderr and the pull request
5898/// json on stdout, and both are evidence.
5899///
5900/// `GH_REPO` is stripped from the child's environment: every call site here
5901/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
5902/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
5903/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
5904/// `GH_REPO` the operator happens to have exported for an unrelated script
5905/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
5906/// below) to a different repository than the one actually on disk - which
5907/// for the same-repo guard in [`correct_manual_merge`] would mean the check
5908/// could be made to agree with whatever repository a forged `--merged` URL
5909/// claims, defeating it entirely.
5910pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5911    let out = tokio::process::Command::new("gh")
5912        .args(args)
5913        .current_dir(cwd)
5914        .env_remove("GH_REPO")
5915        .quiet()
5916        .stdin(std::process::Stdio::null())
5917        .output()
5918        .await
5919        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5920    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5921    let err = String::from_utf8_lossy(&out.stderr);
5922    if body.trim().is_empty() {
5923        body = err.into_owned();
5924    } else if !err.trim().is_empty() {
5925        body.push_str(&err);
5926    }
5927    Ok((out.status.success(), body.trim().to_owned()))
5928}
5929
5930/// Verdict of one entry in the status rollup.
5931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5932pub(crate) enum Verdict {
5933    Pass,
5934    Fail,
5935    Pending,
5936    Unknown,
5937}
5938
5939impl Verdict {
5940    /// Short operator-facing check result.
5941    pub(crate) fn label(self) -> &'static str {
5942        match self {
5943            Self::Pass => "passed",
5944            Self::Fail => "failed",
5945            Self::Pending => "pending",
5946            Self::Unknown => "unknown",
5947        }
5948    }
5949}
5950
5951#[derive(Debug, Deserialize)]
5952#[serde(rename_all = "camelCase")]
5953struct GhPr {
5954    #[serde(default)]
5955    url: String,
5956    #[serde(default)]
5957    number: u64,
5958    #[serde(default)]
5959    state: String,
5960    #[serde(default)]
5961    title: String,
5962    #[serde(default)]
5963    status_check_rollup: Vec<GhCheck>,
5964    /// GitHub's own verdict on whether the pull request can be merged.
5965    ///
5966    /// Worth asking for because it is the only place the *required* check set
5967    /// is applied: the rollup lists every check equally, so a repository that
5968    /// deliberately does not require `coverage` still looks red here. See
5969    /// [`Blocking`].
5970    #[serde(default)]
5971    merge_state_status: String,
5972    /// The commit the pull request currently points at. Compared with the
5973    /// commit a fix round pushed, it is how the loop knows the forge has moved
5974    /// on and the rollup belongs to the new head.
5975    #[serde(default)]
5976    head_ref_oid: String,
5977    #[serde(default)]
5978    base_ref_name: String,
5979    #[serde(default)]
5980    reviews: Vec<GhReview>,
5981    #[serde(default)]
5982    comments: Vec<GhComment>,
5983}
5984
5985/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
5986/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
5987/// status API, which is how CodeRabbit reports - has `context`/`state` and no
5988/// conclusion at all.
5989#[derive(Debug, Deserialize)]
5990#[serde(rename_all = "camelCase")]
5991struct GhCheck {
5992    #[serde(default)]
5993    name: Option<String>,
5994    #[serde(default)]
5995    context: Option<String>,
5996    #[serde(default)]
5997    status: Option<String>,
5998    #[serde(default)]
5999    conclusion: Option<String>,
6000    #[serde(default)]
6001    state: Option<String>,
6002    #[serde(default)]
6003    details_url: Option<String>,
6004    #[serde(default)]
6005    target_url: Option<String>,
6006    /// Whether the base branch requires this check. Only the GraphQL node
6007    /// carries it; `None` is "not read", never "not required".
6008    #[serde(default)]
6009    is_required: Option<bool>,
6010}
6011
6012impl GhCheck {
6013    /// Name to show a human and hand to the fixer.
6014    fn label(&self) -> String {
6015        self.name
6016            .clone()
6017            .or_else(|| self.context.clone())
6018            .unwrap_or_else(|| "(unnamed check)".to_owned())
6019    }
6020
6021    /// Where this check's logs live, when it has any.
6022    fn url(&self) -> Option<&str> {
6023        self.details_url
6024            .as_deref()
6025            .or(self.target_url.as_deref())
6026            .filter(|u| !u.is_empty())
6027    }
6028
6029    /// Did it pass?
6030    ///
6031    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
6032    /// skips release and bot pull requests by design, and a skip that blocked
6033    /// landing would block exactly the pull requests that need no review.
6034    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
6035    /// merging over one is merging over a check that never ran.
6036    fn verdict(&self) -> Verdict {
6037        if let Some(status) = self.status.as_deref() {
6038            if !status.eq_ignore_ascii_case("COMPLETED") {
6039                return Verdict::Pending;
6040            }
6041        }
6042        let outcome = self
6043            .conclusion
6044            .as_deref()
6045            .or(self.state.as_deref())
6046            .unwrap_or("");
6047        match outcome.to_ascii_uppercase().as_str() {
6048            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
6049            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
6050            | "ACTION_REQUIRED" => Verdict::Fail,
6051            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
6052                Verdict::Pending
6053            }
6054            _ => Verdict::Unknown,
6055        }
6056    }
6057}
6058
6059#[derive(Debug, Deserialize)]
6060struct GhAuthor {
6061    #[serde(default)]
6062    login: String,
6063}
6064
6065#[derive(Debug, Deserialize)]
6066struct GhReview {
6067    #[serde(default)]
6068    author: GhAuthor,
6069    #[serde(default)]
6070    body: String,
6071}
6072
6073#[derive(Debug, Deserialize)]
6074struct GhComment {
6075    #[serde(default)]
6076    author: GhAuthor,
6077    #[serde(default)]
6078    body: String,
6079}
6080
6081#[derive(Debug, Deserialize)]
6082struct GhUser {
6083    #[serde(default)]
6084    login: String,
6085}
6086
6087#[derive(Debug, Deserialize)]
6088struct GhInline {
6089    #[serde(default)]
6090    user: GhUser,
6091    #[serde(default)]
6092    path: Option<String>,
6093    #[serde(default)]
6094    line: Option<u64>,
6095    #[serde(default)]
6096    body: String,
6097}
6098
6099impl Default for GhAuthor {
6100    fn default() -> Self {
6101        Self {
6102            login: "(unknown)".to_owned(),
6103        }
6104    }
6105}
6106
6107impl Default for GhUser {
6108    fn default() -> Self {
6109        Self {
6110            login: "(unknown)".to_owned(),
6111        }
6112    }
6113}
6114
6115#[cfg(test)]
6116pub(crate) mod tests {
6117    use super::*;
6118    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
6119
6120    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
6121        format!(
6122            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
6123        )
6124    }
6125
6126    #[test]
6127    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
6128        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
6129        assert_eq!(
6130            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
6131            Ok(())
6132        );
6133        for (json, why) in [
6134            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
6135            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
6136            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
6137            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
6138            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
6139            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
6140        ] {
6141            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
6142                .unwrap_err()
6143                .why;
6144            assert!(err.contains(why), "{json}: {err}");
6145        }
6146        // A head that moved on past what was verified is left alone.
6147        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
6148        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
6149        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
6150        assert!(
6151            !closable(
6152                &head_json("x", "main", "OPEN", false),
6153                "magi/27b2/A",
6154                "main",
6155                &[]
6156            )
6157            .unwrap_err()
6158            .retry
6159        );
6160        assert!(is_forge_url("https://github.com/o/r.git"));
6161        assert!(is_forge_url("git@github.com:o/r.git"));
6162        assert!(!is_forge_url("/tmp/origin.git"));
6163        assert!(!is_forge_url("C:\\work\\origin.git"));
6164        assert!(!is_forge_url("file:///tmp/origin.git"));
6165        assert!(forge_unavailable(
6166            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
6167        ));
6168        assert!(!forge_unavailable(
6169            "gh pr list failed: error connecting to api.github.com"
6170        ));
6171        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
6172        // A record that does not say whether it is a fork is not trusted.
6173        assert!(
6174            closable(
6175                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
6176                "b",
6177                "main",
6178                &["aaa".to_owned()]
6179            )
6180            .is_err()
6181        );
6182    }
6183
6184    #[test]
6185    fn the_close_comment_names_the_commit_on_the_base() {
6186        let e = crate::already::Evidence {
6187            proof: crate::already::Proof::PatchId,
6188            tip: "1234567890".to_owned(),
6189            commits: vec!["0e368de0000".to_owned()],
6190        };
6191        let c = superseded_comment("main", &e);
6192        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
6193    }
6194
6195    /// 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.
6196    const GREEN_OPEN: &str = r####"{
6197  "url": "https://github.com/yukimemi/magi/pull/10",
6198  "number": 10,
6199  "state": "OPEN",
6200  "mergeStateStatus": "CLEAN",
6201  "statusCheckRollup": [
6202    {
6203      "__typename": "CheckRun",
6204      "conclusion": "SKIPPED",
6205      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
6206      "name": "review",
6207      "status": "COMPLETED",
6208      "workflowName": "claude-review"
6209    },
6210    {
6211      "__typename": "CheckRun",
6212      "conclusion": "SUCCESS",
6213      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
6214      "name": "check (ubuntu-latest)",
6215      "status": "COMPLETED",
6216      "workflowName": "CI"
6217    },
6218    {
6219      "__typename": "CheckRun",
6220      "conclusion": "SUCCESS",
6221      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
6222      "name": "rustfmt",
6223      "status": "COMPLETED",
6224      "workflowName": "CI"
6225    },
6226    {
6227      "__typename": "StatusContext",
6228      "context": "CodeRabbit",
6229      "state": "SUCCESS",
6230      "targetUrl": ""
6231    }
6232  ],
6233  "reviews": [],
6234  "comments": [
6235    {
6236      "author": {
6237        "login": "coderabbitai"
6238      },
6239      "authorAssociation": "NONE",
6240      "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"
6241    }
6242  ]
6243}"####;
6244
6245    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
6246    const RED_OPEN: &str = r####"{
6247  "url": "https://github.com/yukimemi/magi/pull/9",
6248  "number": 9,
6249  "state": "OPEN",
6250  "mergeStateStatus": "UNSTABLE",
6251  "statusCheckRollup": [
6252    {
6253      "__typename": "CheckRun",
6254      "conclusion": "SUCCESS",
6255      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6256      "name": "check (ubuntu-latest)",
6257      "status": "COMPLETED",
6258      "workflowName": "CI"
6259    },
6260    {
6261      "__typename": "CheckRun",
6262      "conclusion": "SUCCESS",
6263      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6264      "name": "rustfmt",
6265      "status": "COMPLETED",
6266      "workflowName": "CI"
6267    },
6268    {
6269      "__typename": "CheckRun",
6270      "conclusion": "FAILURE",
6271      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6272      "name": "editorconfig",
6273      "status": "COMPLETED",
6274      "workflowName": "CI"
6275    },
6276    {
6277      "__typename": "StatusContext",
6278      "context": "CodeRabbit",
6279      "state": "SUCCESS",
6280      "targetUrl": ""
6281    }
6282  ],
6283  "reviews": [],
6284  "comments": [
6285    {
6286      "author": {
6287        "login": "coderabbitai"
6288      },
6289      "authorAssociation": "NONE",
6290      "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"
6291    }
6292  ]
6293}"####;
6294
6295    /// 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.
6296    const PENDING_OPEN: &str = r####"{
6297  "url": "https://github.com/yukimemi/magi/pull/9",
6298  "number": 9,
6299  "state": "OPEN",
6300  "statusCheckRollup": [
6301    {
6302      "__typename": "CheckRun",
6303      "conclusion": "SUCCESS",
6304      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6305      "name": "check (ubuntu-latest)",
6306      "status": "COMPLETED",
6307      "workflowName": "CI"
6308    },
6309    {
6310      "__typename": "CheckRun",
6311      "conclusion": "SUCCESS",
6312      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6313      "name": "rustfmt",
6314      "status": "COMPLETED",
6315      "workflowName": "CI"
6316    },
6317    {
6318      "__typename": "CheckRun",
6319      "conclusion": null,
6320      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6321      "name": "editorconfig",
6322      "status": "IN_PROGRESS",
6323      "workflowName": "CI"
6324    },
6325    {
6326      "__typename": "StatusContext",
6327      "context": "CodeRabbit",
6328      "state": "SUCCESS",
6329      "targetUrl": ""
6330    }
6331  ],
6332  "reviews": [],
6333  "comments": []
6334}"####;
6335
6336    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
6337    const MERGED: &str = r####"{
6338  "url": "https://github.com/yukimemi/magi/pull/16",
6339  "number": 16,
6340  "state": "MERGED",
6341  "statusCheckRollup": [
6342    {
6343      "__typename": "CheckRun",
6344      "conclusion": "SUCCESS",
6345      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6346      "name": "check (ubuntu-latest)",
6347      "status": "COMPLETED",
6348      "workflowName": "CI"
6349    },
6350    {
6351      "__typename": "CheckRun",
6352      "conclusion": "SUCCESS",
6353      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6354      "name": "review",
6355      "status": "COMPLETED",
6356      "workflowName": "claude-review"
6357    }
6358  ],
6359  "reviews": [],
6360  "comments": []
6361}"####;
6362
6363    /// 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.
6364    const REVIEWED_OPEN: &str = r####"{
6365  "url": "https://github.com/yukimemi/magi/pull/12",
6366  "number": 12,
6367  "state": "OPEN",
6368  "statusCheckRollup": [
6369    {
6370      "__typename": "CheckRun",
6371      "conclusion": "SUCCESS",
6372      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6373      "name": "check (ubuntu-latest)",
6374      "status": "COMPLETED",
6375      "workflowName": "CI"
6376    },
6377    {
6378      "__typename": "CheckRun",
6379      "conclusion": "SUCCESS",
6380      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6381      "name": "review",
6382      "status": "COMPLETED",
6383      "workflowName": "claude-review"
6384    }
6385  ],
6386  "reviews": [
6387    {
6388      "author": {
6389        "login": "claude"
6390      },
6391      "state": "COMMENTED",
6392      "body": ""
6393    }
6394  ],
6395  "comments": [
6396    {
6397      "author": {
6398        "login": "coderabbitai"
6399      },
6400      "authorAssociation": "NONE",
6401      "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"
6402    },
6403    {
6404      "author": {
6405        "login": "claude"
6406      },
6407      "authorAssociation": "NONE",
6408      "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"
6409    }
6410  ]
6411}"####;
6412
6413    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
6414    const INLINE: &str = r####"[
6415  {
6416    "user": {
6417      "login": "claude[bot]"
6418    },
6419    "path": "src/graph.rs",
6420    "line": 231,
6421    "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"
6422  }
6423]"####;
6424
6425    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
6426    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6427<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6428
6429> [!IMPORTANT]
6430> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6431> 
6432> This repository does not receive automatic reviews because it has fewer than 10 stars.
6433> 
6434> <details>
6435> <summary>⚙️ Run configuration</summary>
6436> 
6437> **Configuration used**: defaults
6438> 
6439> **Review profile**: CHILL
6440> 
6441> **Plan**: Team
6442> 
6443> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6444> 
6445> </details>
6446
6447<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6448
6449<!-- tips_start -->
6450
6451---
6452
6453Thanks 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.
6454
6455<details>
6456<summary>❤️ Share</summary>
6457
6458- [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"####;
6459
6460    /// 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.
6461    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6462
6463---
6464### Reviewing PR #16
6465
6466- [x] Read AGENTS.md conventions
6467- [x] Review `src/daemon.rs` changes
6468- [x] Review `src/main.rs` changes (new `doctor` reporting)
6469- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6470- [x] Check test coverage for new behavior
6471- [x] Run verification commands (blocked — see note)
6472- [x] Post findings"####;
6473
6474    /// The same job's real comment on pull request #12 once it had something to say.
6475    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6476
6477---
6478### Review: `magi review <branch>` — cheap-half-only graph
6479
6480Read 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.
6481
6482**Correctness**
6483
6484- 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"####;
6485
6486    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6487        PrState {
6488            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6489            number: 16,
6490            state: PrLifecycle::Open,
6491            checks,
6492            // These tests are about red-means-fix, so a red here is one the
6493            // forge gates on. Without saying so they would assert the new
6494            // "merge past a check nobody requires" path by accident.
6495            blocking: if matches!(checks, Checks::Red) {
6496                Blocking::Yes
6497            } else {
6498                Blocking::No
6499            },
6500            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6501            review_comments: (0..comments)
6502                .map(|i| ReviewComment {
6503                    author: "coderabbitai".to_owned(),
6504                    path: Some("src/graph.rs".to_owned()),
6505                    line: Some(231),
6506                    body: format!("finding {i}"),
6507                })
6508                .collect(),
6509        }
6510    }
6511
6512    #[test]
6513    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6514        use CiExpectation::{Absent, Expected};
6515        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6516            let p = pr(checks, &["x"], 0);
6517            for waited in [Duration::ZERO, CHECKS_GRACE] {
6518                assert_eq!(
6519                    decide_with(&p, 0, 4, waited, Expected),
6520                    decide(&p, 0, 4, waited)
6521                );
6522            }
6523        }
6524        // Nothing will ever report: no wait, no give-up, no fix round.
6525        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6526            let p = pr(checks, &["x"], 0);
6527            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6528            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6529        }
6530        // Third-party statuses never change the review decision, even at budget.
6531        for comments in [0, 1] {
6532            for round in [0, 4] {
6533                let absent = decide_with(
6534                    &pr(Checks::Unknown, &[], comments),
6535                    round,
6536                    4,
6537                    CHECKS_GRACE,
6538                    Absent,
6539                );
6540                for checks in [Checks::Green, Checks::Red, Checks::Pending] {
6541                    assert_eq!(
6542                        decide_with(
6543                            &pr(checks, &["third-party"], comments),
6544                            round,
6545                            4,
6546                            Duration::ZERO,
6547                            Absent
6548                        ),
6549                        absent
6550                    );
6551                }
6552                if comments != 0 {
6553                    assert!(matches!(absent, Step::Fix { .. } | Step::GiveUp { .. }));
6554                    assert_eq!(matches!(absent, Step::GiveUp { .. }), round == 4);
6555                }
6556            }
6557        }
6558        let empty = pr(Checks::Unknown, &[], 0);
6559        for waited in [Duration::ZERO, CHECKS_GRACE + POLL] {
6560            assert_eq!(decide_with(&empty, 0, 4, waited, Absent), Step::Merge);
6561        }
6562        assert_eq!(
6563            decide_with(&empty, 0, 4, Duration::ZERO, Expected),
6564            Step::Wait
6565        );
6566        assert_eq!(
6567            decide_with(&empty, 0, 4, CHECKS_GRACE, Expected),
6568            Step::GiveUp { reason: "no check status is readable on the pull request after 3 minute(s); refusing to merge on a guess".to_owned() }
6569        );
6570        // A conflict is not a check: it still wants the rebase.
6571        let mut p = pr(Checks::Unknown, &[], 0);
6572        p.blocking = Blocking::Conflict;
6573        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6574        // And a pull request that left our hands is still done.
6575        p.state = PrLifecycle::Merged;
6576        assert_eq!(
6577            decide_with(&p, 0, 4, Duration::ZERO, Absent),
6578            Step::Done { merged: true }
6579        );
6580    }
6581
6582    #[test]
6583    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6584        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6585        assert_eq!(state.number, 10);
6586        assert_eq!(state.state, PrLifecycle::Open);
6587        assert_eq!(state.checks, Checks::Green);
6588        assert!(state.failing.is_empty());
6589        assert!(
6590            state.review_comments.is_empty(),
6591            "the only comment is CodeRabbit's trigger notice: {:?}",
6592            state.review_comments
6593        );
6594        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6595    }
6596
6597    #[test]
6598    fn a_failing_check_parses_as_red_and_is_named() {
6599        let state = parse_pr(RED_OPEN).expect("red fixture parses");
6600        assert_eq!(state.checks, Checks::Red);
6601        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6602        // The captured payload says `UNSTABLE` - mergeable, with a check
6603        // nobody requires red - which is exactly the shape that had to be
6604        // merged by hand. Asserted separately, in
6605        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
6606        // test is about is that a red check is *named*, so the reason a fixer
6607        // is handed says which one; so it asks the blocking question here.
6608        let mut blocking = state.clone();
6609        blocking.blocking = Blocking::Yes;
6610        match decide(&blocking, 0, 4, Duration::ZERO) {
6611            Step::Fix { reason } => {
6612                assert!(reason.contains("editorconfig"), "reason: {reason}");
6613                assert!(reason.contains("failing"), "reason: {reason}");
6614            }
6615            other => panic!("expected a fix round, got {other:?}"),
6616        }
6617    }
6618
6619    #[test]
6620    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6621        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6622        assert_eq!(state.checks, Checks::Pending);
6623        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6624    }
6625
6626    #[test]
6627    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6628        let state = parse_pr(MERGED).expect("merged fixture parses");
6629        assert_eq!(state.state, PrLifecycle::Merged);
6630        assert_eq!(
6631            decide(&state, 0, 4, Duration::ZERO),
6632            Step::Done { merged: true }
6633        );
6634    }
6635
6636    #[test]
6637    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6638        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6639        assert_eq!(state.checks, Checks::Green);
6640        let authors: Vec<&str> = state
6641            .review_comments
6642            .iter()
6643            .map(|c| c.author.as_str())
6644            .collect();
6645        assert_eq!(
6646            authors,
6647            vec!["claude"],
6648            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6649        );
6650        match decide(&state, 0, 4, Duration::ZERO) {
6651            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6652            other => panic!("expected a fix round, got {other:?}"),
6653        }
6654    }
6655
6656    #[test]
6657    fn inline_review_comments_keep_their_file_and_line() {
6658        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6659        assert_eq!(comments.len(), 1);
6660        assert_eq!(comments[0].author, "claude[bot]");
6661        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6662        assert_eq!(comments[0].line, Some(231));
6663        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6664    }
6665
6666    #[test]
6667    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6668        assert!(
6669            is_noise(CODERABBIT_TRIGGER),
6670            "CodeRabbit's trigger notice declares itself not a review"
6671        );
6672        assert!(
6673            is_noise(CLAUDE_CHECKLIST),
6674            "a progress checklist asks for nothing"
6675        );
6676        assert!(
6677            !is_noise(CLAUDE_FINDING),
6678            "a review that names a bug is input, not noise"
6679        );
6680
6681        let mut clean = pr(Checks::Green, &[], 0);
6682        clean.review_comments.push(ReviewComment {
6683            author: "coderabbitai".to_owned(),
6684            path: None,
6685            line: None,
6686            body: CODERABBIT_TRIGGER.to_owned(),
6687        });
6688        clean.review_comments.retain(|c| !is_noise(&c.body));
6689        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6690
6691        let mut found = pr(Checks::Green, &[], 0);
6692        found.review_comments.push(ReviewComment {
6693            author: "claude".to_owned(),
6694            path: None,
6695            line: None,
6696            body: CLAUDE_FINDING.to_owned(),
6697        });
6698        found.review_comments.retain(|c| !is_noise(&c.body));
6699        assert!(matches!(
6700            decide(&found, 0, 4, Duration::ZERO),
6701            Step::Fix { .. }
6702        ));
6703    }
6704
6705    #[test]
6706    fn the_policy_table_holds_for_every_combination_that_matters() {
6707        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6708            (
6709                "pending checks are waited for, even on the last round",
6710                pr(Checks::Pending, &[], 0),
6711                4,
6712                4,
6713                Duration::ZERO,
6714                Step::Wait,
6715            ),
6716            (
6717                "red checks are fixed",
6718                pr(Checks::Red, &["editorconfig"], 0),
6719                0,
6720                4,
6721                Duration::ZERO,
6722                Step::Fix {
6723                    reason: "1 check(s) failing: editorconfig".to_owned(),
6724                },
6725            ),
6726            (
6727                "green with comments is fixed, not merged",
6728                pr(Checks::Green, &[], 2),
6729                1,
6730                4,
6731                Duration::ZERO,
6732                Step::Fix {
6733                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6734                        .to_owned(),
6735                },
6736            ),
6737            (
6738                "green and clean merges",
6739                pr(Checks::Green, &[], 0),
6740                3,
6741                4,
6742                Duration::ZERO,
6743                Step::Merge,
6744            ),
6745            (
6746                "an unreadable rollup is waited on while the grace lasts",
6747                pr(Checks::Unknown, &[], 0),
6748                0,
6749                4,
6750                Duration::ZERO,
6751                Step::Wait,
6752            ),
6753            (
6754                "an unreadable rollup is never merged once the grace is spent",
6755                pr(Checks::Unknown, &[], 0),
6756                0,
6757                4,
6758                CHECKS_GRACE,
6759                Step::GiveUp {
6760                    reason: "no check status is readable on the pull request after 3 minute(s); \
6761                             refusing to merge on a guess"
6762                        .to_owned(),
6763                },
6764            ),
6765        ];
6766        for (what, state, round, budget, waited, want) in cases {
6767            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6768        }
6769    }
6770
6771    #[test]
6772    fn the_forge_verdict_survives_the_round_trip_from_gh() {
6773        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
6774        // requested but never parsed is the kind of thing that looks wired up
6775        // and answers `Unsaid` forever.
6776        let green = parse_pr(GREEN_OPEN).expect("parse");
6777        assert_eq!(green.blocking, Blocking::No);
6778        let red = parse_pr(RED_OPEN).expect("parse");
6779        assert_eq!(
6780            red.blocking,
6781            Blocking::No,
6782            "`UNSTABLE` is mergeable: the red check is one nobody requires"
6783        );
6784        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6785        // A payload from an older `gh` has no such field at all.
6786        let quiet =
6787            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6788        assert_eq!(quiet.blocking, Blocking::Unsaid);
6789    }
6790
6791    #[test]
6792    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6793        // Pull request 37's only red check was `editorconfig`, failing
6794        // because the action could not fetch its own binary after
6795        // editorconfig-checker v4 renamed its release assets. The repository
6796        // does not require it. magi answered by asking a fixer to repair a
6797        // change that was fine, and the pull request had to be merged by hand.
6798        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6799        nonblocking.blocking = Blocking::No;
6800        assert_eq!(
6801            decide(&nonblocking, 0, 4, Duration::ZERO),
6802            Step::Merge,
6803            "the forge says nothing is in the way, so nothing is"
6804        );
6805
6806        // The same red, gated on: that is a fix round, as before.
6807        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6808        blocking.blocking = Blocking::Yes;
6809        assert!(matches!(
6810            decide(&blocking, 0, 4, Duration::ZERO),
6811            Step::Fix { .. }
6812        ));
6813
6814        // A review comment still outranks green-enough: a non-required red
6815        // must not become a way to merge past an unanswered reviewer.
6816        let mut commented = pr(Checks::Red, &["coverage"], 1);
6817        commented.blocking = Blocking::No;
6818        assert!(matches!(
6819            decide(&commented, 0, 4, Duration::ZERO),
6820            Step::Fix { .. }
6821        ));
6822
6823        // And silence from the forge is not consent.
6824        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6825        unsaid.blocking = Blocking::Unsaid;
6826        assert_eq!(decide(&unsaid, 0, 4, Duration::ZERO), Step::Wait);
6827        assert!(matches!(
6828            decide(&unsaid, 0, 4, CHECKS_GRACE),
6829            Step::Fix { .. }
6830        ));
6831    }
6832
6833    #[test]
6834    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6835        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6836        red.blocking = Blocking::No;
6837        assert_eq!(
6838            decide(&red, 0, 4, Duration::ZERO),
6839            Step::Merge,
6840            "announcing must not change the decision"
6841        );
6842        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6843        assert!(said.contains("yukimemi/magi"), "{said}");
6844        assert!(said.contains("#16"), "{said}");
6845        assert!(
6846            said.contains("https://github.com/yukimemi/magi/pull/16"),
6847            "{said}"
6848        );
6849        assert!(
6850            said.contains("test (windows-latest)") && said.contains("coverage"),
6851            "{said}"
6852        );
6853
6854        // `failing` can be left over on a green observation; only `checks` counts.
6855        let green = pr(Checks::Green, &["stale"], 0);
6856        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6857    }
6858
6859    #[test]
6860    fn red_merge_notices_omit_empty_check_details() {
6861        for checks in [vec![], vec!["", "  "]] {
6862            let red = pr(Checks::Red, &checks, 0);
6863            let summary = red_merge_summary("owner/repo", &red).unwrap();
6864            let notice = crate::notices::merged_red("run-1", &summary);
6865            assert_eq!(
6866                notice.message,
6867                format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6868            );
6869            assert_eq!(notice.severity, crate::notices::Severity::Warn);
6870        }
6871        let red = pr(Checks::Red, &["", " test ", " "], 0);
6872        let summary = red_merge_summary("owner/repo", &red).unwrap();
6873        assert!(summary.contains("with red checks: test ("), "{summary}");
6874    }
6875
6876    #[test]
6877    fn the_repo_label_comes_from_the_pull_request_url() {
6878        let p = Path::new("/tmp/checkout");
6879        assert_eq!(
6880            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6881            "yukimemi/magi"
6882        );
6883        assert_eq!(repo_label(p, "not a url"), "checkout");
6884    }
6885
6886    #[test]
6887    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6888        // Pull requests 35 and 37 were both rebased by hand: a competition
6889        // that runs for two hours against a repository merging pull requests
6890        // all day conflicts on the way in, and that is arithmetic rather
6891        // than a defect in the change.
6892        let mut conflicted = pr(Checks::Green, &[], 0);
6893        conflicted.blocking = Blocking::Conflict;
6894        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6895
6896        // Decided before the checks, and even with the rounds spent: every
6897        // check on a branch that cannot land is an answer about a state that
6898        // cannot land, and a conflict is not the change's fault.
6899        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6900        red.blocking = Blocking::Conflict;
6901        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6902
6903        // The lifecycle still wins over everything, conflict included.
6904        let mut merged = pr(Checks::Red, &[], 0);
6905        merged.blocking = Blocking::Conflict;
6906        merged.state = PrLifecycle::Merged;
6907        assert_eq!(
6908            decide(&merged, 0, 4, Duration::ZERO),
6909            Step::Done { merged: true }
6910        );
6911    }
6912
6913    #[test]
6914    fn the_forge_verdict_is_read_off_merge_state_status() {
6915        // The spellings that mean "mergeable". `UNSTABLE` is the one that
6916        // matters: mergeable, with a non-required check red or still running.
6917        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6918            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6919            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6920        }
6921        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6922        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6923        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6924        // An older `gh`, or a token without the scope, says nothing - and
6925        // refusing to guess is the rule everywhere else in this module.
6926        for quiet in ["", "UNKNOWN"] {
6927            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6928            assert!(Blocking::of(quiet).stops_a_merge());
6929        }
6930    }
6931
6932    #[test]
6933    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6934        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6935        // The exact stderr from run ec12, in a jj-colocated repository.
6936        let jj = "could not determine current branch: failed to run git: not on any branch";
6937
6938        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6939            .expect("the forge says merged, so it merged");
6940        assert!(landed.ok);
6941        assert!(
6942            landed.detail.contains("but the pull request is merged"),
6943            "the record must not read as a clean success: {}",
6944            landed.detail
6945        );
6946        assert!(
6947            landed.detail.contains("not on any branch"),
6948            "and it must keep what the command actually said: {}",
6949            landed.detail
6950        );
6951
6952        // A pull request still open means the merge really failed.
6953        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6954        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6955        // And an unreadable answer is not evidence of success.
6956        assert!(merged_after_all(&argv, jj, None).is_none());
6957    }
6958
6959    #[test]
6960    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6961        let mut state = pr(Checks::Red, &["editorconfig"], 3);
6962        state.state = PrLifecycle::Closed;
6963        assert_eq!(
6964            decide(&state, 0, 4, Duration::ZERO),
6965            Step::Done { merged: false },
6966            "a human closing the pull request ends the loop, whatever CI says"
6967        );
6968    }
6969
6970    #[test]
6971    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6972        let red = decide(
6973            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6974            4,
6975            4,
6976            Duration::ZERO,
6977        );
6978        match red {
6979            Step::GiveUp { reason } => {
6980                assert!(reason.contains("editorconfig"), "reason: {reason}");
6981                assert!(reason.contains("test (macos)"), "reason: {reason}");
6982                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6983            }
6984            other => panic!("expected a give-up, got {other:?}"),
6985        }
6986
6987        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6988        match commented {
6989            Step::GiveUp { reason } => {
6990                assert!(reason.contains("unresolved"), "reason: {reason}");
6991                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6992            }
6993            other => panic!("expected a give-up, got {other:?}"),
6994        }
6995    }
6996
6997    #[test]
6998    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6999        let candidate_commit = "magi: candidate A (uncommitted work)";
7000        let subject = merge_subject(candidate_commit, "add retries to the uploader");
7001        let argv = merge_argv(16, &subject);
7002
7003        assert!(argv.contains(&"--squash".to_owned()));
7004        assert!(argv.contains(&"--delete-branch".to_owned()));
7005        assert!(argv.contains(&"--subject".to_owned()));
7006        assert_eq!(
7007            argv.last().map(String::as_str),
7008            Some("add retries to the uploader"),
7009            "the subject must not be the candidate commit message"
7010        );
7011        assert_ne!(subject, candidate_commit);
7012    }
7013
7014    #[test]
7015    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
7016        assert_eq!(
7017            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
7018            "feat: a queue, an unattended loop, and a phone UI"
7019        );
7020        assert_eq!(
7021            merge_subject("", "# port the retry logic\n\ndetails"),
7022            "port the retry logic",
7023            "an empty title falls back to the task's first line, heading marks stripped"
7024        );
7025    }
7026
7027    #[test]
7028    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
7029        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
7030        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
7031        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
7032        assert_eq!(job_of("https://coderabbit.ai/status"), None);
7033        assert_eq!(run_of(""), None);
7034    }
7035
7036    #[test]
7037    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
7038        let mut out = Vec::new();
7039        push_if_outstanding(
7040            &mut out,
7041            ReviewComment {
7042                author: "yukimemi".to_owned(),
7043                path: None,
7044                line: None,
7045                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
7046            },
7047        );
7048        assert!(out.is_empty());
7049    }
7050
7051    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
7052    /// falls back to the repository - which keeps these tests free of a
7053    /// worktree, a `git` invocation and a network.
7054    fn run_state() -> RunState {
7055        let mut state = RunState::new(
7056            std::path::PathBuf::from("/repo/magi"),
7057            "main".to_owned(),
7058            "abcdef1234".to_owned(),
7059            "add retries to the uploader".to_owned(),
7060            crate::config::Config::default(),
7061        );
7062        // Tests run in parallel against one persistent home and the ids
7063        // `RunState::new` draws from the clock can repeat, so two tests would
7064        // share a run's questions. The home also outlives the process, so the
7065        // counter alone would reuse an earlier run's ids.
7066        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7067        let nanos = std::time::SystemTime::now()
7068            .duration_since(std::time::UNIX_EPOCH)
7069            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
7070        state.id = format!(
7071            "20261004-{nanos:06}-{:04x}",
7072            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
7073        );
7074        state
7075    }
7076
7077    fn green_pr() -> PrState {
7078        PrState {
7079            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
7080            number: 42,
7081            state: PrLifecycle::Open,
7082            checks: Checks::Green,
7083            // The forge sees nothing in the way unless a test says otherwise.
7084            blocking: Blocking::No,
7085            failing: Vec::new(),
7086            review_comments: vec![ReviewComment {
7087                author: "coderabbitai".to_owned(),
7088                path: Some("src/land.rs".to_owned()),
7089                line: Some(212),
7090                body: "this branch never checks the exit code".to_owned(),
7091            }],
7092        }
7093    }
7094
7095    #[test]
7096    fn land_fixer_defers_review_and_verification_without_a_cache() {
7097        let state = run_state();
7098        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7099        assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
7100        assert!(p.contains("Commit in this worktree"), "{p}");
7101    }
7102
7103    #[test]
7104    fn github_facing_land_text_is_english_whatever_the_language() {
7105        let mut state = run_state();
7106        state.config.graph.language = "ja".to_owned();
7107        let comment = stop_comment(&state.id, "checks are still red");
7108        assert!(crate::github_text::check("", &comment).is_empty());
7109        assert!(comment.is_ascii(), "{comment}");
7110        assert!(comment.starts_with(MARKER));
7111
7112        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7113        let ja_at = p.find("Write all prose in ja").unwrap();
7114        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
7115        assert!(ja_at < rule_at, "{p}");
7116        assert!(p.contains("stays in Japanese"), "{p}");
7117
7118        state.config.graph.language = "en".to_owned();
7119        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7120        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
7121        assert!(!p.contains("does not apply"), "{p}");
7122    }
7123
7124    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
7125
7126    fn panel() -> String {
7127        approval_panel(
7128            &run_state(),
7129            &green_pr(),
7130            NUMSTAT,
7131            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
7132            &[
7133                "land: ask before merging".to_owned(),
7134                "land: colour the diff".to_owned(),
7135            ],
7136            "feat: merge approval from the phone",
7137        )
7138    }
7139
7140    #[test]
7141    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
7142        let html = panel();
7143        for needle in [
7144            "42",
7145            "main",
7146            "src/land.rs",
7147            "src/web.rs",
7148            "assets/logo.png",
7149            "feat: merge approval from the phone",
7150            "land: ask before merging",
7151            "land: colour the diff",
7152            "coderabbitai",
7153            "this branch never checks the exit code",
7154            "green",
7155        ] {
7156            assert!(html.contains(needle), "the panel must state `{needle}`");
7157        }
7158    }
7159
7160    async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
7161        crate::run::pin_test_home();
7162        let tmp = tempfile::tempdir().unwrap();
7163        let repo = tmp.path().join("repo");
7164        let remote = tmp.path().join("remote.git");
7165        std::fs::create_dir(&repo).unwrap();
7166        git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
7167            .await
7168            .unwrap();
7169        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
7170        git::git(&repo, &["config", "user.name", "Test"])
7171            .await
7172            .unwrap();
7173        git::git(&repo, &["config", "user.email", "test@example.test"])
7174            .await
7175            .unwrap();
7176        std::fs::write(repo.join("base"), "base").unwrap();
7177        git::git(&repo, &["add", "."]).await.unwrap();
7178        git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
7179        git::git(
7180            &repo,
7181            &["remote", "add", "origin", remote.to_str().unwrap()],
7182        )
7183        .await
7184        .unwrap();
7185        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
7186        git::git(&repo, &["checkout", "-b", "winner"])
7187            .await
7188            .unwrap();
7189        std::fs::write(repo.join("change"), "change").unwrap();
7190        git::git(&repo, &["add", "."]).await.unwrap();
7191        git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
7192        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
7193        git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
7194            .await
7195            .unwrap();
7196        let mut state = run_state();
7197        state.repo = repo.clone();
7198        state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
7199        state.config.graph.land = true;
7200        state.config.graph.land_approval = true;
7201        state.pr_head = Some("task-head".into());
7202        state.pr_pushed_head = Some(head.clone());
7203        state.candidates = vec![winning_candidate("change")];
7204        state.candidates[0].worktree = repo;
7205        state.candidates[0].branch = "winner".into();
7206        state.tally = Some(uncontested_tally());
7207        state.gate_ran = true;
7208        state.gate_head = Some(head.clone());
7209        state.land_sync_pending = true;
7210        state.save().unwrap();
7211        (tmp, state, head)
7212    }
7213
7214    async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
7215        let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
7216        resume_landing_head(state, head, &green_pr().url, &forge).await
7217    }
7218
7219    async fn advance_landing_base(state: &RunState) {
7220        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7221        std::fs::write(state.repo.join("advance"), "advance").unwrap();
7222        git::git(&state.repo, &["add", "."]).await.unwrap();
7223        git::git(&state.repo, &["commit", "-m", "advance base"])
7224            .await
7225            .unwrap();
7226        git::git(&state.repo, &["push", "origin", "main"])
7227            .await
7228            .unwrap();
7229        git::git(&state.repo, &["checkout", "winner"])
7230            .await
7231            .unwrap();
7232    }
7233
7234    #[tokio::test]
7235    async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
7236        let (_tmp, mut state, old) = landing_git_fixture().await;
7237        advance_landing_base(&state).await;
7238        state.config.verify.gate = vec!["test -f advance".into()];
7239        let head = resume_observed_head(&mut state, &old).await.unwrap();
7240        assert_ne!(head, old);
7241        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7242        assert!(state.gate_allows_progress());
7243        assert_eq!(
7244            git::remote_head(&state.repo, "origin", "task-head")
7245                .await
7246                .unwrap(),
7247            Some(head.clone())
7248        );
7249        assert_eq!(state.pr_pushed_head, Some(head));
7250        assert!(!state.land_sync_pending);
7251        assert!(state.config.graph.land_approval);
7252    }
7253
7254    fn landing_gate_fixer(state: &mut RunState, script: &str) {
7255        state.config.disk.min_free_bytes = 0;
7256        state.config.graph.gate_fix_rounds = 1;
7257        state.config.agents = vec![crate::config::AgentSpec {
7258            id: "mock".into(),
7259            kind: crate::config::AgentKind::Command,
7260            model: None,
7261            command: vec!["sh".into(), "-c".into(), script.into()],
7262            extra_args: Vec::new(),
7263            env: Default::default(),
7264            prompt_delivery: None,
7265            max_turn_secs: None,
7266        }];
7267        state.candidates[0].agent = "mock".into();
7268    }
7269
7270    #[tokio::test]
7271    async fn landing_resume_revalidates_an_operator_published_winner_extension() {
7272        let (_tmp, mut state, old) = landing_git_fixture().await;
7273        std::fs::write(state.repo.join("change"), "operator update").unwrap();
7274        git::commit_all(&state.repo, "extend winner").await.unwrap();
7275        let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7276        git::git(
7277            &state.repo,
7278            &["push", "origin", "HEAD:refs/heads/task-head"],
7279        )
7280        .await
7281        .unwrap();
7282        state.config.verify.gate = vec!["exit 1".into()];
7283        state.config.graph.gate_fix_rounds = 0;
7284        assert!(resume_observed_head(&mut state, &updated).await.is_err());
7285        assert_ne!(old, updated);
7286        assert_eq!(state.gate_head.as_ref(), Some(&updated));
7287        assert!(!state.gate_allows_progress());
7288        assert!(state.land_sync_pending);
7289        state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
7290        let head = resume_observed_head(&mut state, &updated).await.unwrap();
7291        assert_eq!(head, updated);
7292        assert!(state.gate_allows_progress());
7293        assert!(!state.land_sync_pending);
7294        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7295        land_with(&mut state, &green_pr().url, &forge)
7296            .await
7297            .unwrap();
7298        assert!(state.parked);
7299        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7300        assert!(forge.argvs().is_empty());
7301    }
7302
7303    #[tokio::test]
7304    async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
7305        for divergent in [false, true] {
7306            let (_tmp, mut state, old) = landing_git_fixture().await;
7307            if divergent {
7308                git::git(&state.repo, &["reset", "--hard", "origin/main"])
7309                    .await
7310                    .unwrap();
7311            }
7312            std::fs::write(state.repo.join("external"), "external").unwrap();
7313            git::commit_all(&state.repo, "external update")
7314                .await
7315                .unwrap();
7316            let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7317            git::git(
7318                &state.repo,
7319                &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
7320            )
7321            .await
7322            .unwrap();
7323            if !divergent {
7324                git::git(&state.repo, &["reset", "--hard", &old])
7325                    .await
7326                    .unwrap();
7327            }
7328            let error = resume_observed_head(&mut state, &external)
7329                .await
7330                .unwrap_err();
7331            assert!(
7332                error
7333                    .to_string()
7334                    .contains("public head moved outside this run")
7335            );
7336            assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
7337            assert_eq!(
7338                git::remote_head(&state.repo, "origin", "task-head")
7339                    .await
7340                    .unwrap(),
7341                Some(external)
7342            );
7343        }
7344    }
7345
7346    #[tokio::test]
7347    async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
7348        let (_tmp, mut state, old) = landing_git_fixture().await;
7349        advance_landing_base(&state).await;
7350        landing_gate_fixer(
7351            &mut state,
7352            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7353        );
7354        state.config.verify.gate =
7355            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7356        state.config.verify.e2e = vec!["test -f advance".into()];
7357        // First save a red gate without spending a fixer round. Resume must
7358        // still offer the remaining budget for this exact same failed head.
7359        state.config.graph.gate_fix_rounds = 0;
7360        assert!(resume_observed_head(&mut state, &old).await.is_err());
7361        assert_eq!(
7362            git::remote_head(&state.repo, "origin", "task-head")
7363                .await
7364                .unwrap(),
7365            Some(old.clone())
7366        );
7367        state.config.graph.gate_fix_rounds = 1;
7368        let head = resume_observed_head(&mut state, &old).await.unwrap();
7369        assert_eq!(state.gate_fixes.len(), 1);
7370        assert!(state.gate_fixes[0].committed);
7371        assert!(state.gate_allows_progress());
7372        assert_eq!(state.gate_head.as_ref(), Some(&head));
7373        assert_eq!(
7374            git::remote_head(&state.repo, "origin", "task-head")
7375                .await
7376                .unwrap(),
7377            Some(head.clone())
7378        );
7379        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7380        land_with(&mut state, &green_pr().url, &forge)
7381            .await
7382            .unwrap();
7383        assert!(state.parked);
7384        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7385        assert!(forge.argvs().is_empty());
7386    }
7387
7388    #[tokio::test]
7389    async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7390        let (_tmp, mut state, old) = landing_git_fixture().await;
7391        advance_landing_base(&state).await;
7392        landing_gate_fixer(
7393            &mut state,
7394            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7395        );
7396        state.config.verify.gate = vec!["echo still red; exit 1".into()];
7397        for _ in 0..2 {
7398            assert!(resume_observed_head(&mut state, &old).await.is_err());
7399            assert_eq!(state.gate_fixes.len(), 1);
7400            assert_eq!(
7401                git::remote_head(&state.repo, "origin", "task-head")
7402                    .await
7403                    .unwrap(),
7404                Some(old.clone())
7405            );
7406        }
7407    }
7408
7409    #[tokio::test]
7410    async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7411        let (_tmp, mut state, old) = landing_git_fixture().await;
7412        advance_landing_base(&state).await;
7413        landing_gate_fixer(
7414            &mut state,
7415            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7416        );
7417        state.config.verify.gate =
7418            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7419        // The repair makes the gate green while breaking an e2e invariant.
7420        state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7421        for resume in 0..3 {
7422            assert!(resume_observed_head(&mut state, &old).await.is_err());
7423            state = RunState::load(&state.id).unwrap();
7424            let evidence = state.gate_repair_e2e.as_ref().unwrap();
7425            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7426            assert_eq!(evidence.head, head);
7427            assert!(!evidence.passed);
7428            assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7429            assert_eq!(state.gate_fixes.len(), 1);
7430            assert_eq!(
7431                git::remote_head(&state.repo, "origin", "task-head")
7432                    .await
7433                    .unwrap(),
7434                Some(old.clone())
7435            );
7436            if resume > 0 {
7437                assert!(
7438                    state.gate_allows_progress(),
7439                    "a green gate must not bypass red e2e"
7440                );
7441            }
7442        }
7443        // Once e2e genuinely succeeds on the same SHA, the saved requirement
7444        // can be satisfied without another fixer or approval bypass.
7445        state.config.verify.e2e = vec!["grep -q fixed change".into()];
7446        let head = resume_observed_head(&mut state, &old).await.unwrap();
7447        assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7448        assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7449        assert_eq!(state.gate_fixes.len(), 1);
7450        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7451        land_with(&mut state, &green_pr().url, &forge)
7452            .await
7453            .unwrap();
7454        assert!(state.parked);
7455        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7456        assert!(forge.argvs().is_empty());
7457    }
7458
7459    #[tokio::test]
7460    async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7461        for phase in 0..3 {
7462            let (_tmp, mut state, old) = landing_git_fixture().await;
7463            advance_landing_base(&state).await;
7464            let head = resume_observed_head(&mut state, &old).await.unwrap();
7465            // Persist the shapes left by interruption, resource deferral,
7466            // and a successful check for a different (older) SHA.
7467            state.land_sync_pending = true;
7468            state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7469            state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7470                head: if phase == 2 {
7471                    old.clone()
7472                } else {
7473                    head.clone()
7474                },
7475                passed: phase == 2,
7476                outcomes: if phase == 1 {
7477                    vec![crate::run::CommandOutcome {
7478                        command: state.config.verify.e2e[0].clone(),
7479                        code: None,
7480                        output_tail: "verification resource unavailable".into(),
7481                        duration_ms: 0,
7482                        resource_blocked: true,
7483                    }]
7484                } else {
7485                    Vec::new()
7486                },
7487            });
7488            state.save().unwrap();
7489            for _ in 0..2 {
7490                state = RunState::load(&state.id).unwrap();
7491                assert!(resume_observed_head(&mut state, &head).await.is_err());
7492                let evidence = state.gate_repair_e2e.as_ref().unwrap();
7493                assert_eq!(evidence.head, head);
7494                assert!(!evidence.passed);
7495                assert!(!evidence.outcomes.is_empty());
7496                assert_eq!(
7497                    git::remote_head(&state.repo, "origin", "task-head")
7498                        .await
7499                        .unwrap(),
7500                    Some(head.clone())
7501                );
7502                assert!(state.land_sync_pending);
7503            }
7504        }
7505    }
7506
7507    async fn interrupt_landing_rebase(state: &mut RunState) {
7508        state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7509        state.save().unwrap();
7510        let scratch = state.dir().join("rebase");
7511        assert_eq!(
7512            crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7513                .await
7514                .unwrap(),
7515            crate::rebase::Rebased::Applied
7516        );
7517        assert!(!git::is_clean(&state.repo).await.unwrap());
7518    }
7519
7520    async fn landing_setup(state: &mut RunState) {
7521        git::git(
7522            &state.repo,
7523            &["config", "extensions.worktreeConfig", "true"],
7524        )
7525        .await
7526        .unwrap();
7527        state.config.worktree.setup =
7528            vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7529        crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7530            .await
7531            .unwrap();
7532        state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7533    }
7534
7535    #[tokio::test]
7536    async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7537        for phase in 0..3 {
7538            let (_tmp, mut state, old) = landing_git_fixture().await;
7539            advance_landing_base(&state).await;
7540            landing_setup(&mut state).await;
7541            if phase > 0 {
7542                interrupt_landing_rebase(&mut state).await;
7543                if phase == 2 {
7544                    // Crash after clean removed setup products, before setup.
7545                    git::sync_to_head(&state.repo).await.unwrap();
7546                    assert!(!state.repo.join(".env").exists());
7547                }
7548                state = RunState::load(&state.id).unwrap();
7549            }
7550            let head = resume_observed_head(&mut state, &old).await.unwrap();
7551            assert_ne!(head, old);
7552            assert!(state.gate_allows_progress());
7553            assert!(state.land_rebase_from.is_none());
7554            assert_eq!(
7555                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7556                "secret"
7557            );
7558            assert!(git::is_clean(&state.repo).await.unwrap());
7559            assert!(
7560                !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7561                    .await
7562                    .unwrap()
7563                    .contains(".env")
7564            );
7565        }
7566    }
7567
7568    #[tokio::test]
7569    async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7570        let (_tmp, mut state, _) = landing_git_fixture().await;
7571        advance_landing_base(&state).await;
7572        landing_setup(&mut state).await;
7573        interrupt_landing_rebase(&mut state).await;
7574        // Exercise unusual names without asking Windows to create an invalid
7575        // control-character filename. Unix also covers embedded newlines.
7576        let locals = [
7577            "local notes",
7578            #[cfg(unix)]
7579            "local\nnotes",
7580        ];
7581        let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7582            .await
7583            .unwrap();
7584        std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7585        for local in locals {
7586            std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7587            assert!(
7588                git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7589                    .await
7590                    .unwrap()
7591                    .ok()
7592            );
7593        }
7594        restore_landing_worktree(&mut state).await.unwrap();
7595        for local in locals {
7596            assert_eq!(
7597                std::fs::read_to_string(state.repo.join(local)).unwrap(),
7598                "keep these notes"
7599            );
7600        }
7601        assert_eq!(
7602            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7603            "secret"
7604        );
7605        assert!(git::is_clean(&state.repo).await.unwrap());
7606        assert!(state.land_rebase_from.is_none());
7607    }
7608
7609    #[tokio::test]
7610    async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7611        for local in ["advance", "advance/notes", "new-directory"] {
7612            let (_tmp, mut state, _) = landing_git_fixture().await;
7613            advance_landing_base(&state).await;
7614            // Also cover a local file blocking a new tracked directory.
7615            git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7616            std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7617            std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7618            git::git(&state.repo, &["add", "."]).await.unwrap();
7619            git::git(&state.repo, &["commit", "-m", "add directory"])
7620                .await
7621                .unwrap();
7622            git::git(&state.repo, &["push", "origin", "main"])
7623                .await
7624                .unwrap();
7625            git::git(&state.repo, &["checkout", "winner"])
7626                .await
7627                .unwrap();
7628            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7629                .await
7630                .unwrap();
7631            std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7632            let path = state.repo.join(local);
7633            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7634            std::fs::write(&path, "local notes").unwrap();
7635            interrupt_landing_rebase(&mut state).await;
7636            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7637            let checkpoint = state.land_rebase_from.clone();
7638            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7639            assert!(
7640                error.to_string().contains("conflicting with HEAD"),
7641                "{error:#}"
7642            );
7643            assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7644            assert_eq!(
7645                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7646                before
7647            );
7648            assert_eq!(state.land_rebase_from, checkpoint);
7649        }
7650    }
7651
7652    #[tokio::test]
7653    async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7654        for interrupted in [false, true] {
7655            let (_tmp, mut state, old) = landing_git_fixture().await;
7656            assert!(state.config.worktree.setup.is_empty());
7657            // A gate product exists before the base moves, without setup metadata.
7658            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7659                .await
7660                .unwrap();
7661            std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7662            state.config.verify.gate =
7663                vec!["mkdir -p target && printf artifact > target/probe".into()];
7664            state.gate_ran = false;
7665            resume_observed_head(&mut state, &old).await.unwrap();
7666            assert_eq!(
7667                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7668                "artifact"
7669            );
7670            advance_landing_base(&state).await;
7671            if interrupted {
7672                interrupt_landing_rebase(&mut state).await;
7673                state = RunState::load(&state.id).unwrap();
7674            }
7675            state.config.verify.gate =
7676                vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7677            let head = resume_observed_head(&mut state, &old).await.unwrap();
7678            assert_ne!(head, old);
7679            assert!(state.gate_allows_progress());
7680            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7681            assert_eq!(
7682                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7683                "artifact"
7684            );
7685            assert!(state.land_rebase_from.is_none());
7686            assert_eq!(
7687                git::remote_head(&state.repo, "origin", "task-head")
7688                    .await
7689                    .unwrap(),
7690                Some(head.clone())
7691            );
7692            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7693            land_with(&mut state, &green_pr().url, &forge)
7694                .await
7695                .unwrap();
7696            assert!(state.parked);
7697            assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7698            assert!(forge.argvs().is_empty());
7699        }
7700    }
7701
7702    #[tokio::test]
7703    #[cfg(unix)]
7704    async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7705        use std::os::fd::AsRawFd as _;
7706
7707        for interrupted in [false, true] {
7708            let (_tmp, mut state, _) = landing_git_fixture().await;
7709            advance_landing_base(&state).await;
7710            landing_setup(&mut state).await;
7711            if interrupted {
7712                interrupt_landing_rebase(&mut state).await;
7713            }
7714            state.config.worktree.setup =
7715                vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7716            let common = crate::repo_lock::common_dir(&state.repo)
7717                .await
7718                .unwrap()
7719                .unwrap();
7720            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7721            state.config.coordination = crate::config::test_coordination();
7722            state.config.graph.timeout_resource_wait = 0;
7723            let external = std::fs::OpenOptions::new()
7724                .read(true)
7725                .write(true)
7726                .create(true)
7727                .truncate(false)
7728                .open(common.join("updater.lock"))
7729                .unwrap();
7730            // SAFETY: the descriptor belongs to `external` and remains open.
7731            let locked =
7732                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7733            assert_eq!(locked, 0);
7734            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7735            let checkpoint = state.land_rebase_from.clone();
7736            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7737            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7738            assert_eq!(
7739                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7740                before
7741            );
7742            assert_eq!(state.land_rebase_from, checkpoint);
7743            assert_eq!(
7744                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7745                "secret"
7746            );
7747
7748            drop(external);
7749            state.config.graph.timeout_resource_wait = 2;
7750            restore_landing_worktree(&mut state).await.unwrap();
7751            assert!(git::is_clean(&state.repo).await.unwrap());
7752            assert!(state.land_rebase_from.is_none());
7753            assert_eq!(
7754                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7755                "restored"
7756            );
7757        }
7758    }
7759
7760    #[tokio::test]
7761    #[cfg(unix)]
7762    async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7763        use std::sync::atomic::{AtomicBool, Ordering};
7764
7765        let (_tmp, mut state, _) = landing_git_fixture().await;
7766        advance_landing_base(&state).await;
7767        landing_setup(&mut state).await;
7768        interrupt_landing_rebase(&mut state).await;
7769        let common = crate::repo_lock::common_dir(&state.repo)
7770            .await
7771            .unwrap()
7772            .unwrap();
7773        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7774        state.config.coordination = crate::config::test_coordination();
7775        let holder = crate::repo_lock::SeatLease::for_checkout(
7776            &state.repo,
7777            true,
7778            state.config.coordination.as_ref(),
7779        )
7780        .await
7781        .unwrap();
7782        assert!(holder.is_some());
7783        let cancelled = Arc::new(AtomicBool::new(false));
7784        let signal = cancelled.clone();
7785        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7786            signal.load(Ordering::SeqCst)
7787        });
7788        let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7789        let checkpoint = state.land_rebase_from.clone();
7790        let (result, ()) = tokio::join!(
7791            tokio::time::timeout(
7792                Duration::from_secs(2),
7793                wait.scope(restore_landing_worktree(&mut state)),
7794            ),
7795            async {
7796                tokio::time::sleep(Duration::from_millis(100)).await;
7797                cancelled.store(true, Ordering::SeqCst);
7798            }
7799        );
7800        let error = result
7801            .expect("cancellation must release the waiting driver")
7802            .unwrap_err();
7803        assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7804        assert_eq!(
7805            git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7806            before
7807        );
7808        assert_eq!(state.land_rebase_from, checkpoint);
7809        assert_eq!(
7810            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7811            "secret"
7812        );
7813        drop(holder);
7814        restore_landing_worktree(&mut state).await.unwrap();
7815        assert!(state.land_rebase_from.is_none());
7816        assert!(git::is_clean(&state.repo).await.unwrap());
7817    }
7818
7819    #[tokio::test]
7820    #[cfg(unix)]
7821    async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
7822        use std::sync::atomic::{AtomicBool, Ordering};
7823
7824        for e2e in [false, true] {
7825            let (_tmp, mut state, head) = landing_git_fixture().await;
7826            let common = crate::repo_lock::common_dir(&state.repo)
7827                .await
7828                .unwrap()
7829                .unwrap();
7830            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7831            state.config.coordination = crate::config::test_coordination();
7832            if e2e {
7833                state.config.verify.e2e = vec!["true".into()];
7834                state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7835                    head: head.clone(),
7836                    passed: false,
7837                    outcomes: Vec::new(),
7838                });
7839            } else {
7840                state.config.verify.gate = vec!["true".into()];
7841                state.gate_ran = false;
7842            }
7843            let holder = crate::repo_lock::SeatLease::for_checkout(
7844                &state.repo,
7845                true,
7846                state.config.coordination.as_ref(),
7847            )
7848            .await
7849            .unwrap();
7850            assert!(holder.is_some());
7851            let cancelled = Arc::new(AtomicBool::new(false));
7852            let signal = cancelled.clone();
7853            let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7854                signal.load(Ordering::SeqCst)
7855            });
7856            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7857            let url = green_pr().url;
7858            let (result, ()) = tokio::join!(
7859                tokio::time::timeout(
7860                    Duration::from_secs(2),
7861                    wait.scope(publish_landing_head(&mut state, &head, &url, &forge)),
7862                ),
7863                async {
7864                    tokio::time::sleep(Duration::from_millis(100)).await;
7865                    cancelled.store(true, Ordering::SeqCst);
7866                }
7867            );
7868            let error = result
7869                .expect("cancellation must release the verification waiter")
7870                .unwrap_err();
7871            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7872            assert!(forge.argvs().is_empty());
7873            assert_eq!(
7874                git::remote_head(&state.repo, "origin", "task-head")
7875                    .await
7876                    .unwrap(),
7877                Some(head.clone())
7878            );
7879            drop(holder);
7880            // Cancellation must release its reservation so another attempt can verify.
7881            assert_eq!(
7882                publish_landing_head(&mut state, &head, &url, &forge)
7883                    .await
7884                    .unwrap(),
7885                head
7886            );
7887        }
7888    }
7889
7890    #[tokio::test]
7891    async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
7892        for staged in [false, true] {
7893            let (_tmp, mut state, old) = landing_git_fixture().await;
7894            advance_landing_base(&state).await;
7895            interrupt_landing_rebase(&mut state).await;
7896            std::fs::write(state.repo.join("change"), "user edit").unwrap();
7897            if staged {
7898                git::git(&state.repo, &["add", "change"]).await.unwrap();
7899            }
7900            assert!(resume_observed_head(&mut state, &old).await.is_err());
7901            assert_eq!(
7902                std::fs::read_to_string(state.repo.join("change")).unwrap(),
7903                "user edit"
7904            );
7905            assert_eq!(
7906                git::remote_head(&state.repo, "origin", "task-head")
7907                    .await
7908                    .unwrap(),
7909                Some(old)
7910            );
7911        }
7912    }
7913
7914    #[tokio::test]
7915    async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
7916        let (_tmp, mut state, head) = landing_git_fixture().await;
7917        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7918        let id = state.id.clone();
7919        let pr = land_with(&mut state, &green_pr().url, &forge)
7920            .await
7921            .unwrap();
7922        assert_eq!(pr.state, PrLifecycle::Open);
7923        assert_eq!(state.id, id);
7924        assert!(state.parked);
7925        assert_eq!(state.status, RunStatus::Landing);
7926        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7927        assert!(forge.argvs().is_empty());
7928
7929        state.land_sync_pending = true;
7930        std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
7931        let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
7932        closed.pr.state = PrLifecycle::Closed;
7933        let forge = Scripted::new(vec![closed], vec![]);
7934        assert_eq!(
7935            land_with(&mut state, &green_pr().url, &forge)
7936                .await
7937                .unwrap()
7938                .state,
7939            PrLifecycle::Closed
7940        );
7941        assert_eq!(
7942            std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
7943            "do not touch"
7944        );
7945        assert!(forge.argvs().is_empty());
7946    }
7947
7948    #[tokio::test]
7949    async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
7950        let (_tmp, mut state, old) = landing_git_fixture().await;
7951        advance_landing_base(&state).await;
7952        state.config.verify.gate = vec!["exit 1".into()];
7953        assert!(resume_observed_head(&mut state, &old).await.is_err());
7954        assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
7955        assert!(!state.gate_allows_progress());
7956        assert_eq!(
7957            git::remote_head(&state.repo, "origin", "task-head")
7958                .await
7959                .unwrap(),
7960            Some(old)
7961        );
7962        assert!(state.land_sync_pending);
7963    }
7964
7965    #[tokio::test]
7966    async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
7967        let (_tmp, mut state, old) = landing_git_fixture().await;
7968        advance_landing_base(&state).await;
7969        let pushed = resume_observed_head(&mut state, &old).await.unwrap();
7970        // Model a crash after the push but before saving its published SHA.
7971        state.pr_pushed_head = Some(old.clone());
7972        state.land_sync_pending = true;
7973        assert_eq!(
7974            resume_observed_head(&mut state, &pushed).await.unwrap(),
7975            pushed
7976        );
7977        // A writer races the observed PR snapshot: the explicit remote lease
7978        // must reject the push even though preflight saw our expected head.
7979        git::git(
7980            &state.repo,
7981            &["push", "--force", "origin", "main:refs/heads/task-head"],
7982        )
7983        .await
7984        .unwrap();
7985        assert!(
7986            publish_landing_head(
7987                &mut state,
7988                &pushed,
7989                &green_pr().url,
7990                &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
7991            )
7992            .await
7993            .unwrap_err()
7994            .to_string()
7995            .contains("publishing gated")
7996        );
7997        assert!(
7998            resume_observed_head(&mut state, "foreign-head")
7999                .await
8000                .is_err()
8001        );
8002    }
8003    #[tokio::test]
8004    async fn landing_publication_refuses_a_pr_closed_during_gate() {
8005        let (_tmp, mut state, head) = landing_git_fixture().await;
8006        let mut closed = seen(&head, Checks::Green, "CLEAN", false);
8007        closed.pr.state = PrLifecycle::Closed;
8008        let forge = Scripted::new(vec![closed], vec![]);
8009        assert!(
8010            publish_landing_head(&mut state, &head, &green_pr().url, &forge)
8011                .await
8012                .unwrap_err()
8013                .to_string()
8014                .contains("PR closed or merged")
8015        );
8016    }
8017
8018    /// A candidate selected by the uncontested tally.
8019    fn winning_candidate(summary: &str) -> Candidate {
8020        Candidate {
8021            index: 0,
8022            label: 'A',
8023            agent: "opus".to_owned(),
8024            branch: "magi/x/A".to_owned(),
8025            worktree: PathBuf::from("/wt/A"),
8026            summary: summary.to_owned(),
8027            stat: String::new(),
8028            files: 1,
8029            commits: 1,
8030            empty: false,
8031            failed: None,
8032            verified_noop: None,
8033            duration_ms: 0,
8034            folded: false,
8035        }
8036    }
8037
8038    fn uncontested_tally() -> Tally {
8039        Tally {
8040            first_choice: BTreeMap::from([('A', 1)]),
8041            borda: BTreeMap::new(),
8042            winner: 'A',
8043            rankings: 1,
8044            unanimous_initial: true,
8045            deliberated: false,
8046            changed_votes: 0,
8047            unanimous_final: true,
8048            tie_break: None,
8049            judges: 1,
8050            present: 1,
8051            quorum: 1,
8052            met_quorum: true,
8053            uncontested: None,
8054        }
8055    }
8056
8057    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
8058        ReviewRecord {
8059            attempts: 0,
8060            reviewer,
8061            agent: agent.to_owned(),
8062            summary: summary.to_owned(),
8063            findings: Vec::new(),
8064            vote: None,
8065            failed: None,
8066            duration_ms: 0,
8067        }
8068    }
8069
8070    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
8071        let answered = reviews.len();
8072        ReviewRound {
8073            round,
8074            head: "abc1234".to_owned(),
8075            verified_head: None,
8076            verified_at: None,
8077            reviews,
8078            e2e: Vec::new(),
8079            verify_retried: false,
8080            e2e_deferred: false,
8081            e2e_defer_reason: None,
8082            fix: None,
8083            blocking: 0,
8084            answered,
8085            expected: answered,
8086            clean: true,
8087            progressed: false,
8088            vote_split: false,
8089            reconsideration: Vec::new(),
8090            verdict: None,
8091        }
8092    }
8093
8094    #[test]
8095    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
8096        let en = panel();
8097        assert!(en.contains("Task"), "{en}");
8098        assert!(en.contains("add retries to the uploader"), "{en}");
8099
8100        let mut state = run_state();
8101        state.config.graph.language = "ja".to_owned();
8102        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8103        assert!(ja.contains("タスク"), "{ja}");
8104        assert!(
8105            ja.contains("add retries to the uploader"),
8106            "the task itself is not translated: {ja}"
8107        );
8108    }
8109
8110    #[test]
8111    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
8112        // `run_state()` has no candidates, no tally and no reviews - exactly
8113        // the shape a run has before anything has judged or reviewed it, and
8114        // the panel must not print an empty box for either.
8115        let html = panel();
8116        assert!(!html.contains("What changed"), "{html}");
8117        assert!(!html.contains("Review verdict"), "{html}");
8118    }
8119
8120    #[test]
8121    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
8122        let mut state = run_state();
8123        state.candidates = vec![winning_candidate("")];
8124        state.tally = Some(uncontested_tally());
8125        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8126        assert!(
8127            !html.contains("What changed"),
8128            "an empty summary must not render an empty box: {html}"
8129        );
8130    }
8131
8132    #[test]
8133    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
8134        let mut state = run_state();
8135        state.candidates = vec![winning_candidate(
8136            "Added a retry loop around the uploader PUT call.",
8137        )];
8138        state.tally = Some(uncontested_tally());
8139        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8140        assert!(en.contains("What changed"), "{en}");
8141        assert!(
8142            en.contains("Added a retry loop around the uploader PUT call."),
8143            "{en}"
8144        );
8145
8146        state.config.graph.language = "ja".to_owned();
8147        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8148        assert!(ja.contains("変更内容"), "{ja}");
8149        assert!(
8150            ja.contains("Added a retry loop around the uploader PUT call."),
8151            "{ja}"
8152        );
8153    }
8154
8155    #[test]
8156    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
8157        let mut state = run_state();
8158        state.reviews = vec![
8159            review_round(
8160                1,
8161                vec![review_record(1, "alpha", "found a race, sent back")],
8162            ),
8163            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
8164        ];
8165        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8166        assert!(en.contains("Review verdict"), "{en}");
8167        assert!(en.contains("race is fixed, clean"), "{en}");
8168        assert!(
8169            !en.contains("found a race, sent back"),
8170            "only the round that actually cleared the merge should show: {en}"
8171        );
8172
8173        state.config.graph.language = "ja".to_owned();
8174        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8175        assert!(ja.contains("レビューの結論"), "{ja}");
8176        assert!(ja.contains("レビュアー"), "{ja}");
8177        assert!(ja.contains("race is fixed, clean"), "{ja}");
8178    }
8179
8180    /// The `incomplete_review = "warn"` policy (see
8181    /// `graph::Runner::review_loop`) can push a `clean` round to
8182    /// `state.reviews` while one seat's own record still has `failed: Some`
8183    /// and an empty `summary` - a seat that never answered, not one that
8184    /// answered with nothing to say.
8185    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
8186        ReviewRecord {
8187            attempts: 0,
8188            reviewer,
8189            agent: agent.to_owned(),
8190            summary: String::new(),
8191            findings: Vec::new(),
8192            vote: None,
8193            failed: Some(reason.to_owned()),
8194            duration_ms: 0,
8195        }
8196    }
8197
8198    #[test]
8199    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
8200        let mut state = run_state();
8201        state.reviews = vec![review_round(
8202            1,
8203            vec![
8204                review_record(1, "alpha", "clean, nothing to add"),
8205                unanswered_review_record(2, "beta", "timed out"),
8206            ],
8207        )];
8208        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8209        assert!(en.contains("clean, nothing to add"), "{en}");
8210        assert!(
8211            en.contains("produced no answer: timed out"),
8212            "a seat that never answered must say so, not render a blank box: {en}"
8213        );
8214        assert!(
8215            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
8216            "no reviewer box may be left empty: {en}"
8217        );
8218
8219        state.config.graph.language = "ja".to_owned();
8220        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8221        assert!(ja.contains("回答なし: timed out"), "{ja}");
8222    }
8223
8224    #[test]
8225    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
8226        let html = panel();
8227        assert!(!html.contains("<script"), "no script survives the csp");
8228        assert!(!html.contains("<form"), "form-action is 'none'");
8229        let pr = green_pr();
8230        assert_eq!(
8231            html.matches("http").count(),
8232            html.matches(pr.url.as_str()).count(),
8233            "the only http url in the panel is the pull request's own link"
8234        );
8235        assert!(
8236            html.contains("target=\"_blank\""),
8237            "the PR link must open in a new tab so it escapes the iframe"
8238        );
8239        assert!(
8240            html.contains("rel=\"noopener noreferrer\""),
8241            "external links opening in new tabs must carry noopener noreferrer"
8242        );
8243    }
8244
8245    #[test]
8246    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
8247        let html = panel();
8248        assert!(
8249            html.contains(">+</span>"),
8250            "an added line carries a `+` in the gutter, not only a background"
8251        );
8252        assert!(
8253            html.contains(">-</span>"),
8254            "a removed line carries a `-` in the gutter, not only a background"
8255        );
8256        assert!(
8257            html.contains(">new line</span>"),
8258            "the marker is moved to the gutter, so the body is printed once without it"
8259        );
8260    }
8261
8262    #[test]
8263    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
8264        let total = DIFF_MAX_LINES + 100;
8265        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
8266        let html = approval_panel(
8267            &run_state(),
8268            &green_pr(),
8269            NUMSTAT,
8270            &diff,
8271            &[],
8272            "feat: something long",
8273        );
8274        assert!(
8275            html.contains(&format!("100 of {total} diff lines omitted")),
8276            "the note must say exactly how much was cut"
8277        );
8278        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
8279        assert!(
8280            !html.contains(&format!("line {DIFF_MAX_LINES}")),
8281            "nothing past the threshold is rendered"
8282        );
8283        assert!(
8284            html.contains("/repo/magi"),
8285            "the note says where the rest is"
8286        );
8287    }
8288
8289    #[test]
8290    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
8291        let html = approval_panel(
8292            &run_state(),
8293            &green_pr(),
8294            "1\t2\tsrc/<b>&\"x\"'.rs",
8295            "",
8296            &[],
8297            "subject",
8298        );
8299        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
8300        assert!(
8301            !html.contains("<b>"),
8302            "an agent-influenced path must never become markup"
8303        );
8304    }
8305
8306    #[tokio::test]
8307    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
8308        let a = std::path::PathBuf::from("/repo/a");
8309        let b = std::path::PathBuf::from("/repo/b");
8310
8311        let held = repo_merge_lock(&a).lock_owned().await;
8312
8313        // A second, concurrent land run against the *same* repository must
8314        // wait - `try_lock` fails while `held` is alive.
8315        assert!(
8316            repo_merge_lock(&a).try_lock().is_err(),
8317            "a second merge into the same repository must not proceed concurrently"
8318        );
8319
8320        // A run against a *different* repository must not be blocked by it -
8321        // this is what keeps a slow rebase or `gh pr merge` in one
8322        // repository from also stalling a land-approval resume in another.
8323        assert!(
8324            repo_merge_lock(&b).try_lock().is_ok(),
8325            "a different repository's merge lock must be independent"
8326        );
8327
8328        drop(held);
8329        assert!(
8330            repo_merge_lock(&a).try_lock().is_ok(),
8331            "the lock is released once the holder is done"
8332        );
8333    }
8334
8335    pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
8336        let mut state = landing_state();
8337        let mut rec = review_record(1, "alpha", "precise rejection reason");
8338        rec.vote = Some(if reject {
8339            crate::verdict::ReviewVote::Reject
8340        } else {
8341            crate::verdict::ReviewVote::Approve
8342        });
8343        if major {
8344            rec.findings = contested().findings;
8345        }
8346        let mut round = review_round(1, vec![rec]);
8347        round.clean = false;
8348        round.blocking = usize::from(major);
8349        state.reviews.push(round);
8350        state.config.graph.review_rounds = 1;
8351        state
8352    }
8353
8354    #[test]
8355    fn approval_policy_controls_are_independent_and_keep_all_reasons() {
8356        for every in [false, true] {
8357            for contested in [false, true] {
8358                for major in [false, true] {
8359                    for reject in [false, true] {
8360                        let mut state = repair_state(major, reject);
8361                        state.config.graph.land_approval = every;
8362                        state.config.graph.hold_contested_merge = contested;
8363                        let reasons = approval_reasons(&state);
8364                        assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
8365                        assert_eq!(
8366                            reasons.contains(&ApprovalReason::OpenBlockingFindings),
8367                            contested && major
8368                        );
8369                        assert_eq!(
8370                            reasons.contains(&ApprovalReason::FinalReject),
8371                            contested && reject
8372                        );
8373                        assert_eq!(
8374                            reasons.len(),
8375                            usize::from(every)
8376                                + usize::from(contested && major)
8377                                + usize::from(contested && reject)
8378                        );
8379                    }
8380                }
8381            }
8382        }
8383    }
8384
8385    #[test]
8386    fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8387        use crate::verdict::ReviewVote::{Approve, Reject};
8388        for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8389            let mut state = repair_state(false, initial_reject);
8390            state.reviews[0]
8391                .reconsideration
8392                .push(crate::run::ReviewRevoteRecord {
8393                    reviewer: 1,
8394                    agent: "alpha".into(),
8395                    vote: Some(final_vote),
8396                    reason: "changed after reading the panel <evidence>".into(),
8397                    failed: None,
8398                });
8399            assert_eq!(
8400                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8401                final_vote == Reject
8402            );
8403            for language in ["en", "ja"] {
8404                state.config.graph.language = language.into();
8405                let evidence = approval_evidence(&state);
8406                assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8407                assert!(evidence.contains("changed after reading the panel <evidence>"));
8408                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8409                assert!(html.contains("changed after reading the panel &lt;evidence&gt;"));
8410                assert!(!html.contains("<evidence>"));
8411            }
8412            state.reviews[0].reconsideration[0].vote = None;
8413            state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8414            assert_eq!(
8415                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8416                initial_reject
8417            );
8418            assert!(approval_evidence(&state).contains("timed out"));
8419        }
8420    }
8421
8422    #[tokio::test]
8423    async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8424        for every in [false, true] {
8425            for contested in [false, true] {
8426                for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8427                {
8428                    let mut state = repair_state(major, reject);
8429                    state.config.graph.land_approval = every;
8430                    state.config.graph.hold_contested_merge = contested;
8431                    let forge = Scripted::new(
8432                        vec![seen("abc", Checks::Green, "CLEAN", false)],
8433                        vec![(true, "")],
8434                    );
8435                    land_with(&mut state, URL, &forge).await.unwrap();
8436                    let expected = every || (contested && (major || reject));
8437                    assert_eq!(state.land_approval.is_some(), expected);
8438                    assert_eq!(forge.calls().contains(&"merge"), !expected);
8439                    if expected {
8440                        assert!(state.parked);
8441                    }
8442                }
8443            }
8444        }
8445    }
8446
8447    #[tokio::test]
8448    async fn reviewed_branch_hold_is_read_before_landing_decision() {
8449        for (base, control) in [
8450            (false, "land_approval"),
8451            (true, "land_approval"),
8452            (false, "hold_contested_merge"),
8453        ] {
8454            let (_tmp, mut state, _) = landing_git_fixture().await;
8455            state.config.graph.land_approval = base;
8456            state.config.graph.hold_contested_merge = false;
8457            state.reviewed_commits = Some(Vec::new());
8458            if control == "hold_contested_merge" {
8459                state.reviews = repair_state(false, true).reviews;
8460            }
8461            std::fs::write(
8462                state.repo.join("magi.toml"),
8463                format!("[graph]\n{control} = {}\n", !base),
8464            )
8465            .unwrap();
8466            git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8467            git::git(&state.repo, &["commit", "-m", "approval policy"])
8468                .await
8469                .unwrap();
8470            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8471            state.land_sync_pending = false;
8472            state.pr_pushed_head = Some(head.clone());
8473            state.gate_head = Some(head.clone());
8474            let forge = Scripted::new(
8475                vec![seen(&head, Checks::Green, "CLEAN", false)],
8476                vec![(true, "")],
8477            );
8478            land_with(&mut state, URL, &forge).await.unwrap();
8479            assert!(
8480                state.land_approval.is_some(),
8481                "base={base}, control={control}"
8482            );
8483            assert!(!forge.calls().contains(&"merge"));
8484            if !base {
8485                let question = ask::Questions::open()
8486                    .get(&state.land_approval.as_ref().unwrap().question)
8487                    .unwrap();
8488                assert!(question.detail.contains(&format!(
8489                    "{control}=true from the reviewed branch's magi.toml"
8490                )));
8491            }
8492        }
8493    }
8494
8495    #[tokio::test]
8496    async fn reviewed_branch_hold_asks_and_survives_removal_and_resume() {
8497        let (_tmp, mut state, _) = landing_git_fixture().await;
8498        state.config.graph.land_approval = false;
8499        state.config.graph.hold_contested_merge = false;
8500        state.reviewed_commits = Some(Vec::new());
8501        std::fs::write(
8502            state.repo.join("magi.toml"),
8503            "[graph]\nland_approval = true\n[verify]\ngate = ['untrusted']\n",
8504        )
8505        .unwrap();
8506        git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8507        git::git(&state.repo, &["commit", "-m", "require approval"])
8508            .await
8509            .unwrap();
8510        let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8511        let gate = state.config.verify.gate.clone();
8512        state.observe_branch_owner_holds(&head).await;
8513        assert_eq!(state.config.verify.gate, gate);
8514        assert_eq!(approval_reasons(&state), [ApprovalReason::EveryMerge]);
8515        git::git(&state.repo, &["rm", "magi.toml"]).await.unwrap();
8516        git::git(&state.repo, &["commit", "-m", "remove config"])
8517            .await
8518            .unwrap();
8519        let later = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8520        state = serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
8521        state.observe_branch_owner_holds(&later).await;
8522        assert_eq!(state.branch_owner_holds[0].head, head);
8523        state.land_sync_pending = false;
8524        state.pr_pushed_head = Some(later.clone());
8525        state.gate_head = Some(later.clone());
8526        let forge = Scripted::new(
8527            vec![seen(&later, Checks::Green, "CLEAN", false)],
8528            vec![(true, "")],
8529        );
8530        land_with(&mut state, URL, &forge).await.unwrap();
8531        assert!(!forge.calls().contains(&"merge"));
8532        let question = ask::Questions::open()
8533            .get(&state.land_approval.as_ref().unwrap().question)
8534            .unwrap();
8535        assert!(question.detail.contains("Approval required because:"));
8536        assert!(
8537            question
8538                .detail
8539                .contains("land_approval=true from the reviewed branch's magi.toml")
8540        );
8541    }
8542
8543    #[tokio::test]
8544    async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8545        let mut state = repair_state(false, true);
8546        state.reviews[0].clean = true;
8547        let mut finding = contested().findings.remove(0);
8548        finding.severity = crate::verdict::Severity::Minor;
8549        state.reviews[0].reviews[0].findings.push(finding);
8550        assert!(state.open_findings().is_empty());
8551        let mut pr = green_pr();
8552        pr.review_comments.clear();
8553        for language in ["en", "ja"] {
8554            state.config.graph.language = language.into();
8555            let html = approval_panel(&state, &pr, "", "", &[], "subject");
8556            assert!(html.contains("[R3-1-1] minor"));
8557            assert!(html.contains(if language == "ja" {
8558                "内部指摘の解決を意味するものではありません"
8559            } else {
8560                "This does not mean internal findings are resolved"
8561            }));
8562            assert!(
8563                html.find(if language == "ja" {
8564                    "承認が必要な理由:"
8565                } else {
8566                    "Approval required because:"
8567                })
8568                .unwrap()
8569                    < html.find("[R3-1-1]").unwrap()
8570            );
8571        }
8572        state.config.graph.language = "en".into();
8573        let observed = seen("abc", Checks::Green, "CLEAN", false);
8574        assert_eq!(
8575            approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8576                .await
8577                .unwrap(),
8578            ApprovalGate::Pending
8579        );
8580        let id = state.land_approval.as_ref().unwrap().question.clone();
8581        let q = ask::Questions::open().get(&id).unwrap();
8582        assert!(q.detail.starts_with("Approval required because:"));
8583        assert!(q.detail.contains("final reviewer reject"));
8584        assert!(q.detail.contains("[R3-1-1] minor"));
8585        assert!(
8586            q.detail
8587                .contains("No adoption or resolution evidence recorded")
8588        );
8589        state.config.graph.land_approval = false;
8590        state.config.graph.hold_contested_merge = false;
8591        state.reviews.clear();
8592        assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8593        assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8594        assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8595        let forge = Scripted::new(vec![observed], vec![]);
8596        land_with(&mut state, URL, &forge).await.unwrap();
8597        assert!(!forge.calls().contains(&"merge"));
8598        assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8599        assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8600    }
8601
8602    #[tokio::test]
8603    async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8604        let mut state = repair_state(false, true);
8605        state.config.graph.land_approval = false;
8606        let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8607        let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8608        let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8609        state.reviews[0].reviews[0].summary = long_reason.clone();
8610        for i in 1..=10 {
8611            let mut finding = contested().findings.remove(0);
8612            finding.id = format!("R1-1-{i}");
8613            finding.severity = if i == 9 {
8614                crate::verdict::Severity::Major
8615            } else {
8616                crate::verdict::Severity::Minor
8617            };
8618            finding.title = long_title.clone();
8619            finding.detail = long_detail.clone();
8620            state.reviews[0].reviews[0].findings.push(finding);
8621        }
8622        state.reviews[0].fix = Some(crate::run::FixRecord {
8623            agent: "fixer".into(),
8624            addressed: vec!["R1-1-9".into()],
8625            rejected: vec![crate::verdict::Rejection {
8626                id: "R1-1-10".into(),
8627                why: long_reason.clone(),
8628            }],
8629            notes: long_detail.clone(),
8630            committed: false,
8631            failed: None,
8632            duration_ms: 0,
8633            continuation: None,
8634        });
8635        assert_eq!(
8636            approval_reasons(&state),
8637            [
8638                ApprovalReason::OpenBlockingFindings,
8639                ApprovalReason::FinalReject
8640            ]
8641        );
8642        let excerpt = approval_excerpt(&long_reason, 300);
8643        assert_eq!(excerpt.chars().count(), 300);
8644        assert!(excerpt.ends_with('…'));
8645        let boundary = "拒".repeat(300);
8646        assert_eq!(approval_excerpt(&boundary, 300), boundary);
8647        for language in ["en", "ja"] {
8648            state.config.graph.language = language.into();
8649            for revote in [
8650                None,
8651                Some(crate::verdict::ReviewVote::Reject),
8652                Some(crate::verdict::ReviewVote::Approve),
8653            ] {
8654                state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8655                state.reviews[0].reconsideration.clear();
8656                if let Some(vote) = revote {
8657                    state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8658                    state.reviews[0]
8659                        .reconsideration
8660                        .push(crate::run::ReviewRevoteRecord {
8661                            reviewer: 1,
8662                            agent: "alpha".into(),
8663                            vote: Some(vote),
8664                            reason: long_reason.clone(),
8665                            failed: None,
8666                        });
8667                }
8668                let evidence = approval_evidence(&state);
8669                let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8670                assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8671                assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8672                assert_eq!(
8673                    approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8674                    final_vote == crate::verdict::ReviewVote::Reject
8675                );
8676                assert!(evidence.contains(&excerpt));
8677                assert!(evidence.contains("[R1-1-8] minor"));
8678                assert!(evidence.contains("[R1-1-9] major"));
8679                assert!(evidence.contains("[R1-1-10] minor"));
8680                for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8681                    assert!(!evidence.contains(tail));
8682                }
8683                assert!(evidence.contains(if language == "ja" {
8684                    "採用申告(解決未確認)"
8685                } else {
8686                    "claimed addressed (resolution unconfirmed)"
8687                }));
8688                assert!(evidence.contains(if language == "ja" {
8689                    "不採用理由"
8690                } else {
8691                    "declined"
8692                }));
8693                let major_evidence = evidence
8694                    .split("[R1-1-9] major")
8695                    .nth(1)
8696                    .unwrap()
8697                    .split("[R1-1-10]")
8698                    .next()
8699                    .unwrap();
8700                assert!(major_evidence.contains(if language == "ja" {
8701                    "採用申告(解決未確認)"
8702                } else {
8703                    "claimed addressed (resolution unconfirmed)"
8704                }));
8705                let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8706                assert!(last_evidence.contains(if language == "ja" {
8707                    "不採用理由"
8708                } else {
8709                    "declined"
8710                }));
8711                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8712                assert!(html.contains(&esc(&evidence)));
8713                assert_eq!(
8714                    approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8715                        .await
8716                        .unwrap(),
8717                    ApprovalGate::Pending
8718                );
8719                let id = &state.land_approval.take().unwrap().question;
8720                let q = ask::Questions::open().get(id).unwrap();
8721                assert!(q.detail.starts_with(&evidence));
8722            }
8723            state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8724        }
8725    }
8726
8727    #[test]
8728    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8729        for (major, reject) in [(true, false), (false, true), (true, true)] {
8730            let state = repair_state(major, reject);
8731            assert!(repair_available(&state));
8732            assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8733        }
8734        assert!(!repair_available(&repair_state(false, false)));
8735        let mut clean = repair_state(true, false);
8736        clean.reviews[0].clean = true;
8737        assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8738        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8739    }
8740
8741    #[tokio::test]
8742    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8743        let mut state = repair_state(true, false);
8744        let pr = green_pr();
8745        approval_gate(&mut state, &pr, "feat: x", None, "abc")
8746            .await
8747            .unwrap();
8748        let store = ask::Questions::open();
8749        let id = state.land_approval.as_ref().unwrap().question.clone();
8750        // Simulate an unanswered two-choice question created by an older build.
8751        store
8752            .update(&id, |q| {
8753                q.choices = vec![APPROVE.into(), HOLD.into()];
8754                Ok(())
8755            })
8756            .unwrap();
8757        let upgraded = store.get(&id).unwrap();
8758        assert_eq!(upgraded.choices, approval_choices(&state));
8759        assert!(!upgraded.actions.contains_key(HOLD));
8760        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
8761        assert!(
8762            store
8763                .list()
8764                .iter()
8765                .any(|q| q.id == id && q.choices == upgraded.choices)
8766        );
8767        assert!(
8768            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
8769        );
8770        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
8771        store
8772            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
8773            .unwrap();
8774        // An earlier build may have recorded the repair answer and stopped
8775        // before any driver consumed it. Reconcile that durable decision too.
8776        store
8777            .update(&id, |q| {
8778                q.actions.clear();
8779                Ok(())
8780            })
8781            .unwrap();
8782        assert_eq!(
8783            store.get(&id).unwrap().chosen_action(),
8784            Some(&ask::ChoiceAction::Resume {
8785                run: state.id.clone()
8786            })
8787        );
8788        let binding = state.land_approval.clone();
8789        assert_eq!(
8790            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8791                .await
8792                .unwrap(),
8793            ApprovalGate::Repair
8794        );
8795        assert!(state.land_approval.is_none());
8796        assert_eq!(state.merge_repairs.len(), 1);
8797        let request = &state.merge_repairs[0];
8798        assert_eq!(request.question, id);
8799        assert_eq!(request.head, "abc");
8800        assert_eq!(request.pr, pr.url);
8801        assert_eq!(
8802            request.reviews[0].reviews[0].summary,
8803            "precise rejection reason"
8804        );
8805        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
8806        assert_eq!(
8807            request.reviews[0].reviews[0].findings[0].detail,
8808            state.reviews[0].reviews[0].findings[0].detail
8809        );
8810        // A repeated consumption of the same answer never dispatches another repair.
8811        state.land_approval = binding;
8812        assert_eq!(
8813            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8814                .await
8815                .unwrap(),
8816            ApprovalGate::Repair
8817        );
8818        assert_eq!(state.merge_repairs.len(), 1);
8819        state.land_approval = None;
8820        state.config.graph.land_approval = false;
8821        state.config.graph.hold_contested_merge = false;
8822        state.reviews.clear();
8823        assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
8824        assert_eq!(
8825            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8826                .await
8827                .unwrap(),
8828            ApprovalGate::Pending
8829        );
8830        let fresh_id = &state.land_approval.as_ref().unwrap().question;
8831        assert_ne!(fresh_id, &id);
8832        let fresh = store.get(fresh_id).unwrap();
8833        assert!(
8834            fresh
8835                .detail
8836                .contains("fresh owner approval after merge repair")
8837        );
8838        assert!(fresh.detail.contains("No review recorded"));
8839        assert!(!fresh.detail.contains("precise rejection reason"));
8840    }
8841
8842    #[tokio::test]
8843    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
8844        for choice in [REPAIR, HOLD] {
8845            let mut state = repair_state(false, true);
8846            let observed = seen("abc", Checks::Green, "CLEAN", false);
8847            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
8848                .await
8849                .unwrap();
8850            let id = state.land_approval.as_ref().unwrap().question.clone();
8851            ask::Questions::open()
8852                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
8853                .unwrap();
8854            let forge = Scripted::new(vec![observed], vec![(true, "")]);
8855            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8856                .await
8857                .unwrap();
8858            assert!(
8859                forge
8860                    .argvs()
8861                    .iter()
8862                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
8863            );
8864            assert!(!forge.calls().contains(&"fix"));
8865            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
8866            assert_ne!(state.status, RunStatus::Merged);
8867        }
8868    }
8869
8870    #[test]
8871    fn only_the_merge_choice_merges_and_silence_holds() {
8872        let table = [
8873            (None, Approval::Hold),
8874            (Some("merge"), Approval::Merge),
8875            (Some(" merge\n"), Approval::Merge),
8876            (Some("hold"), Approval::Hold),
8877            (Some(""), Approval::Hold),
8878            (Some("yes"), Approval::Hold),
8879        ];
8880        for (answer, want) in table {
8881            assert_eq!(
8882                approval(answer),
8883                want,
8884                "answer {answer:?} must resolve to {want:?}"
8885            );
8886        }
8887    }
8888
8889    #[tokio::test]
8890    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
8891        let mut state = landing_state();
8892        state.config.graph.land_approval = true;
8893        let pr = green_pr();
8894
8895        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8896            .await
8897            .unwrap();
8898        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
8899        assert!(
8900            !state.parked,
8901            "approval_gate itself never sets `parked`; only its caller does"
8902        );
8903
8904        let store = ask::Questions::open();
8905        let filed: Vec<_> = store
8906            .list()
8907            .into_iter()
8908            .filter(|q| q.run == state.id)
8909            .collect();
8910        assert_eq!(filed.len(), 1, "exactly one question is filed");
8911        assert_eq!(filed[0].node, APPROVAL_NODE);
8912        assert_eq!(filed[0].choices, approval_choices(&state));
8913        assert!(filed[0].status.open());
8914
8915        // A second visit - standing in for a resumed run whose slot the
8916        // daemon handed to something else while nobody had answered - must
8917        // find the same question rather than filing a second one.
8918        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8919            .await
8920            .unwrap();
8921        assert_eq!(again, ApprovalGate::Pending);
8922        let still_one = store
8923            .list()
8924            .into_iter()
8925            .filter(|q| q.run == state.id)
8926            .count();
8927        assert_eq!(
8928            still_one, 1,
8929            "asking twice must not double-file the question"
8930        );
8931    }
8932
8933    #[tokio::test]
8934    async fn approving_the_existing_question_is_read_back_as_approved() {
8935        crate::run::pin_test_home();
8936        let mut state = run_state();
8937        state.config.graph.land_approval = true;
8938        let pr = green_pr();
8939        assert_eq!(
8940            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8941                .await
8942                .unwrap(),
8943            ApprovalGate::Pending
8944        );
8945
8946        let store = ask::Questions::open();
8947        let mut q = store
8948            .list()
8949            .into_iter()
8950            .find(|q| q.run == state.id)
8951            .expect("filed above");
8952        let talks = crate::talk::Talks::open();
8953        let cfg = crate::config::Config {
8954            agents: vec![crate::config::AgentSpec {
8955                id: "mock".into(),
8956                kind: crate::config::AgentKind::Command,
8957                model: None,
8958                command: vec!["true".into()],
8959                extra_args: Vec::new(),
8960                env: Default::default(),
8961                prompt_delivery: None,
8962                max_turn_secs: None,
8963            }],
8964            ..crate::config::Config::default()
8965        };
8966        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
8967        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
8968        crate::talk::drain(&mut talk, &talks).unwrap();
8969        assert_eq!(
8970            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8971                .await
8972                .unwrap(),
8973            ApprovalGate::Pending,
8974            "consultation cannot approve or hold the merge"
8975        );
8976        q = store.get(&q.id).unwrap();
8977        assert!(q.answer.is_none());
8978        // The owner's merge answer (from the chat or the card) is the approval.
8979        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8980        store.put(&mut q).unwrap();
8981
8982        assert_eq!(
8983            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8984                .await
8985                .unwrap(),
8986            ApprovalGate::Approved
8987        );
8988    }
8989
8990    #[tokio::test]
8991    async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
8992        crate::run::pin_test_home();
8993        let store = ask::Questions::open();
8994        for via_list in [false, true] {
8995            let mut state = repair_state(false, true);
8996            let pr = green_pr();
8997            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8998                .await
8999                .unwrap();
9000            let id = state.land_approval.as_ref().unwrap().question.clone();
9001            store
9002                .update(&id, |q| {
9003                    q.actions.insert(
9004                        HOLD.to_owned(),
9005                        ask::ChoiceAction::Resume {
9006                            run: state.id.clone(),
9007                        },
9008                    );
9009                    Ok(())
9010                })
9011                .unwrap();
9012            let q = if via_list {
9013                store.list().into_iter().find(|q| q.id == id).unwrap()
9014            } else {
9015                store.get(&id).unwrap()
9016            };
9017            assert!(!q.actions.contains_key(HOLD));
9018            store
9019                .update(&id, |q| {
9020                    // Check the persisted migration, without another read upgrade.
9021                    assert!(!q.actions.contains_key(HOLD));
9022                    assert!(!upgrade_repair_choice(q, &state));
9023                    q.answer(ask::Answer::Choice(HOLD.to_owned()))
9024                })
9025                .unwrap();
9026            assert!(store.get(&id).unwrap().chosen_action().is_none());
9027            assert_eq!(
9028                approval_gate(&mut state, &pr, "feat: x", None, "abc")
9029                    .await
9030                    .unwrap(),
9031                ApprovalGate::Held
9032            );
9033        }
9034    }
9035
9036    #[tokio::test]
9037    async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
9038        crate::run::pin_test_home();
9039        let store = ask::Questions::open();
9040
9041        let mut held_state = run_state();
9042        held_state.config.graph.land_approval = true;
9043        let pr = green_pr();
9044        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9045            .await
9046            .unwrap();
9047        let mut q = store
9048            .list()
9049            .into_iter()
9050            .find(|q| q.run == held_state.id)
9051            .expect("filed above");
9052        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
9053        store.put(&mut q).unwrap();
9054        assert_eq!(
9055            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9056                .await
9057                .unwrap(),
9058            ApprovalGate::Held
9059        );
9060
9061        let mut abandoned_state = run_state();
9062        abandoned_state.config.graph.land_approval = true;
9063        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9064            .await
9065            .unwrap();
9066        let mut q = store
9067            .list()
9068            .into_iter()
9069            .find(|q| q.run == abandoned_state.id)
9070            .expect("filed above");
9071        q.abandon("no answer within the timeout");
9072        store.put(&mut q).unwrap();
9073        assert_eq!(
9074            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9075                .await
9076                .unwrap(),
9077            ApprovalGate::Pending,
9078            "an expired approval must ask again without merging"
9079        );
9080        assert_eq!(
9081            store.get(&q.id).unwrap().status,
9082            ask::QuestionStatus::Abandoned
9083        );
9084        assert_ne!(
9085            abandoned_state.land_approval.as_ref().unwrap().question,
9086            q.id
9087        );
9088    }
9089
9090    fn contested() -> ContestedHandoff {
9091        let finding = |id: &str, n: u32| crate::verdict::Finding {
9092            id: id.to_owned(),
9093            severity: crate::verdict::Severity::Major,
9094            file: Some("src/a.rs".to_owned()),
9095            line: Some(n),
9096            title: format!("problem {id}"),
9097            detail: String::new(),
9098        };
9099        ContestedHandoff {
9100            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
9101            rejecters: vec![(1, "alpha".to_owned())],
9102        }
9103    }
9104
9105    #[test]
9106    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
9107        let mut state = run_state();
9108        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
9109        state.contested_handoff = Some(contested());
9110        assert!(contested_to_ask(&state).is_some());
9111        state.config.graph.hold_contested_merge = false;
9112        assert!(
9113            contested_to_ask(&state).is_none(),
9114            "the switch restores today"
9115        );
9116    }
9117
9118    #[test]
9119    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
9120        let q = ask::Question::new(
9121            "run-1".to_owned(),
9122            APPROVAL_NODE.to_owned(),
9123            "land".to_owned(),
9124            "Merge?".to_owned(),
9125            String::new(),
9126            vec![APPROVE.to_owned(), HOLD.to_owned()],
9127        );
9128        let none = deputy_brief(&q, None);
9129        assert!(none.contains("could not be read"), "{none}");
9130        assert!(none.contains("Silence is a hold"), "{none}");
9131
9132        let mut state = run_state();
9133        state.pr = Some(crate::run::PrRecord {
9134            url: "https://example.test/pull/7".to_owned(),
9135            number: 7,
9136            state: "open".to_owned(),
9137            checks: "green".to_owned(),
9138            round: 0,
9139            rounds: 3,
9140            red_at_merge: Vec::new(),
9141        });
9142        state.contested_handoff = Some(contested());
9143        let b = deputy_brief(&q, Some(&state));
9144        assert!(b.contains("https://example.test/pull/7"), "{b}");
9145        assert!(b.contains("not recorded in this legacy question"), "{b}");
9146        assert!(!b.contains("This question was filed although"));
9147        state.contested_handoff = None;
9148        assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
9149    }
9150
9151    #[test]
9152    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
9153        for lang in ["en", "ja"] {
9154            let mut cfg = crate::config::Config::default();
9155            cfg.graph.language = lang.to_owned();
9156            let w = words(&cfg.graph.language);
9157            let text = w.approval_detail(
9158                "https://github.com/yukimemi/magi/pull/42",
9159                "main",
9160                "feat: x",
9161                Some(&contested()),
9162            );
9163            assert!(text.contains("pull/42"), "{text}");
9164            assert!(
9165                text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
9166                "{text}"
9167            );
9168            assert!(text.contains("R3-1-5"), "{text}");
9169            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
9170            assert!(text.contains("2"), "the rest are counted: {text}");
9171            assert!(text.contains("#1 (alpha)"), "{text}");
9172        }
9173        let plain = words("en").approval_detail("u", "main", "s", None);
9174        assert!(!plain.contains("reject"), "{plain}");
9175    }
9176
9177    #[tokio::test]
9178    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
9179        crate::run::pin_test_home();
9180        let mut state = run_state();
9181        state.config.graph.land_approval = false;
9182        state.contested_handoff = Some(contested());
9183        let pr = green_pr();
9184        let c = contested_to_ask(&state);
9185        assert_eq!(
9186            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9187                .await
9188                .unwrap(),
9189            ApprovalGate::Pending,
9190            "silence is a hold"
9191        );
9192        let store = ask::Questions::open();
9193        let filed: Vec<_> = store
9194            .list()
9195            .into_iter()
9196            .filter(|q| q.run == state.id)
9197            .collect();
9198        assert_eq!(filed.len(), 1);
9199        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
9200
9201        assert_eq!(
9202            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9203                .await
9204                .unwrap(),
9205            ApprovalGate::Pending
9206        );
9207        let mut q = store
9208            .list()
9209            .into_iter()
9210            .find(|q| q.run == state.id)
9211            .unwrap();
9212        assert_eq!(q.id, filed[0].id, "the same question after a resume");
9213        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9214        store.put(&mut q).unwrap();
9215        assert_eq!(
9216            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9217                .await
9218                .unwrap(),
9219            ApprovalGate::Approved
9220        );
9221    }
9222
9223    #[test]
9224    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
9225        let rows = parse_numstat(NUMSTAT);
9226        assert_eq!(
9227            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
9228            ["src/web.rs", "src/land.rs", "assets/logo.png"]
9229        );
9230        assert_eq!(rows[2].added, None, "a binary file has no line counts");
9231    }
9232    #[test]
9233    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
9234        // Reported from a real run: the merge question arrived in English on a
9235        // repository with `language = "ja"`. magi's own strings have to follow
9236        // that setting too - "it is a literal in Rust" is not an answer.
9237        let mut state = run_state();
9238        state.config.graph.language = "ja".to_owned();
9239        let pr = green_pr();
9240        let commits = ["c1".to_owned()];
9241
9242        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9243        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
9244        assert!(ja.contains("squash されるコミット"), "{ja}");
9245        assert!(ja.contains("レビューコメント"), "{ja}");
9246        assert!(ja.contains("差分"), "{ja}");
9247        assert!(
9248            !ja.contains("Commits being squashed"),
9249            "no English left over"
9250        );
9251
9252        let w = words("ja");
9253        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
9254        assert!(
9255            w.approval_detail("http://x/1", "main", "feat: x", None)
9256                .contains("パネル")
9257        );
9258
9259        // The evidence itself is language-neutral and must survive either way.
9260        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
9261        assert!(ja.contains("feat: x"), "nor is the merge subject");
9262
9263        // English stays the default, and a language magi cannot check falls
9264        // back to it rather than shipping a guess.
9265        state.config.graph.language = "en".to_owned();
9266        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9267        assert!(en.contains("Commits being squashed"), "{en}");
9268        assert_eq!(words("Klingon").html_lang, "en");
9269    }
9270
9271    /// A `gh pr list` result naming exactly one pull request whose base and
9272    /// merge time both fit the run is exactly the case
9273    /// [`find_external_merge`] exists to act on.
9274    #[test]
9275    fn pick_open_pr_classifies_by_count_and_base() {
9276        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
9277        assert_eq!(
9278            pick_open_pr(one, "main").unwrap(),
9279            OpenPr::One {
9280                url: "https://x/pull/58".into(),
9281                title: "t".into()
9282            }
9283        );
9284        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
9285        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
9286        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
9287                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
9288        assert_eq!(
9289            pick_open_pr(two, "main").unwrap(),
9290            OpenPr::Many(vec!["u1".into(), "u2".into()])
9291        );
9292        // A fork's same-named branch is not a pull request for this head.
9293        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
9294        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
9295        assert!(pick_open_pr("not json", "main").is_err());
9296        // An incomplete record is an error, never "nothing open".
9297        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
9298        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
9299    }
9300
9301    #[test]
9302    fn pick_merged_pr_picks_the_unique_match() {
9303        let json = r#"[
9304            {"url": "https://github.com/o/r/pull/42", "number": 42,
9305             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
9306        ]"#;
9307        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9308        let found = pick_merged_pr(json, "main", created_at)
9309            .expect("valid json")
9310            .expect("one unambiguous match");
9311        assert_eq!(found.url, "https://github.com/o/r/pull/42");
9312        assert_eq!(found.number, 42);
9313    }
9314
9315    /// Two candidates surviving the filter is exactly as uninformative as
9316    /// zero — a branch name can be reused across runs — so neither is
9317    /// preferred over the other and nothing is recorded automatically.
9318    #[test]
9319    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
9320        let json = r#"[
9321            {"url": "https://github.com/o/r/pull/42", "number": 42,
9322             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
9323            {"url": "https://github.com/o/r/pull/43", "number": 43,
9324             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
9325        ]"#;
9326        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9327        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9328    }
9329
9330    /// A pull request that targets a different base branch cannot be this
9331    /// run's, whatever its head branch is named — a reused branch name from
9332    /// an unrelated task must not be recorded as this run's merge.
9333    #[test]
9334    fn pick_merged_pr_ignores_a_different_base_branch() {
9335        let json = r#"[
9336            {"url": "https://github.com/o/r/pull/42", "number": 42,
9337             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
9338        ]"#;
9339        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9340        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9341    }
9342
9343    /// A pull request merged before this run was even created cannot be this
9344    /// run's winner, no matter how its head branch is spelled.
9345    #[test]
9346    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
9347        let json = r#"[
9348            {"url": "https://github.com/o/r/pull/42", "number": 42,
9349             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
9350        ]"#;
9351        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9352        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9353    }
9354
9355    #[test]
9356    fn slug_of_pr_url_reads_host_owner_and_repo() {
9357        assert_eq!(
9358            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
9359            Some("github.com/yukimemi/shun")
9360        );
9361    }
9362
9363    #[test]
9364    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
9365        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
9366        assert_eq!(slug_of_pr_url("not a url at all"), None);
9367        assert_eq!(slug_of_pr_url("https://github.com"), None);
9368    }
9369
9370    #[test]
9371    fn slug_of_repo_url_reads_host_owner_and_repo() {
9372        assert_eq!(
9373            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
9374            Some("github.com/yukimemi/magi")
9375        );
9376        assert_eq!(slug_of_repo_url("https://github.com"), None);
9377    }
9378
9379    #[test]
9380    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
9381        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
9382            .expect("same repo, different case");
9383    }
9384
9385    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
9386    /// own in-progress run and rewrote its status from a pull request in a
9387    /// completely different repository. This is the guard that must catch
9388    /// that even when an explicit (but wrong) id is given.
9389    #[test]
9390    fn ensure_same_repo_refuses_a_different_repo() {
9391        let err =
9392            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
9393        let msg = format!("{err:#}");
9394        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9395        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
9396    }
9397
9398    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
9399    /// instance mirroring a `github.com` repository's name, say) must not be
9400    /// treated as the same repository just because the trailing path
9401    /// matches.
9402    #[test]
9403    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
9404        let err = ensure_same_repo(
9405            "github.com/yukimemi/magi",
9406            "github.example.com/yukimemi/magi",
9407        )
9408        .unwrap_err();
9409        let msg = format!("{err:#}");
9410        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9411        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
9412    }
9413
9414    /// No winner decided yet means there is no branch to ask GitHub about at
9415    /// all — `find_external_merge` must return `None` without ever spawning
9416    /// `gh`, which this proves by never providing a real repository to spawn
9417    /// it in.
9418    #[tokio::test]
9419    async fn find_external_merge_returns_none_without_a_winner() {
9420        let state = RunState::new(
9421            PathBuf::from("/no/such/repo"),
9422            "main".to_owned(),
9423            "0000000000000000000000000000000000000000".to_owned(),
9424            "irrelevant".to_owned(),
9425            crate::config::Config::default(),
9426        );
9427        assert_eq!(find_external_merge(&state).await.unwrap(), None);
9428    }
9429
9430    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
9431        let mut run = RunState::new(
9432            PathBuf::from(repo),
9433            "main".to_owned(),
9434            "abcdef1234".to_owned(),
9435            "x".to_owned(),
9436            crate::config::Config::default(),
9437        );
9438        run.id = id.to_owned();
9439        run.status = status;
9440        run.driver_exited = true;
9441        run.pr = Some(crate::run::PrRecord {
9442            number: url.rsplit('/').next().unwrap().parse().unwrap(),
9443            url: url.to_owned(),
9444            state: state.to_owned(),
9445            checks: "red".to_owned(),
9446            round: 0,
9447            rounds: 2,
9448            red_at_merge: Vec::new(),
9449        });
9450        run.save_under(home).unwrap();
9451    }
9452
9453    fn recorded(home: &Path, id: &str) -> String {
9454        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9455        serde_json::from_str::<RunState>(&body)
9456            .unwrap()
9457            .pr
9458            .unwrap()
9459            .state
9460    }
9461
9462    const PR: &str = "https://github.com/o/r/pull/7";
9463
9464    #[test]
9465    fn write_through_updates_predecessors_and_siblings_only() {
9466        let tmp = tempfile::tempdir().unwrap();
9467        let h = tmp.path();
9468        pr_run(
9469            h,
9470            "20261004-100000-aaaa",
9471            "/repo/r",
9472            RunStatus::Superseded,
9473            PR,
9474            "open",
9475        );
9476        pr_run(
9477            h,
9478            "20261004-100100-bbbb",
9479            "/repo/r",
9480            RunStatus::Blocked,
9481            PR,
9482            "open",
9483        );
9484        // Not terminal: a driver may be writing it.
9485        pr_run(
9486            h,
9487            "20261004-100200-cccc",
9488            "/repo/r",
9489            RunStatus::Landing,
9490            PR,
9491            "open",
9492        );
9493        // Another repository's pull request with the same number.
9494        pr_run(
9495            h,
9496            "20261004-100300-dddd",
9497            "/repo/other",
9498            RunStatus::Blocked,
9499            "https://github.com/o/other/pull/7",
9500            "open",
9501        );
9502        // A different pull request of the same repository.
9503        pr_run(
9504            h,
9505            "20261004-100400-eeee",
9506            "/repo/r",
9507            RunStatus::Blocked,
9508            "https://github.com/o/r/pull/8",
9509            "open",
9510        );
9511        pr_run(
9512            h,
9513            "20261004-100500-ffff",
9514            "/repo/r",
9515            RunStatus::Merged,
9516            PR,
9517            "open",
9518        );
9519        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9520
9521        assert_eq!(
9522            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9523            2
9524        );
9525        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9526        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9527        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9528        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9529        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9530        // The source's own record is the caller's to write.
9531        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9532        // Idempotent.
9533        assert_eq!(
9534            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9535            0
9536        );
9537        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9538        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9539    }
9540
9541    #[test]
9542    fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9543        let home = tempfile::tempdir().unwrap();
9544        let live_id = "20261004-100000-aaaa";
9545        let unknown_id = "20261004-100100-bbbb";
9546        for id in [live_id, unknown_id] {
9547            pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9548            let mut s = RunState::load_under(id, home.path()).unwrap();
9549            s.driver_exited = false;
9550            // Model legacy driver evidence, not a newly reserved unstarted run.
9551            s.driver_claimed = None;
9552            if id == live_id {
9553                s.driver_pid = Some(std::process::id());
9554                s.driver_started_at = crate::proc::process_started_at(std::process::id());
9555            }
9556            s.save_under(home.path()).unwrap();
9557            if id == unknown_id {
9558                assert_eq!(
9559                    s.liveness_under(home.path(), false),
9560                    crate::run::Liveness::Unknown
9561                );
9562            }
9563        }
9564        let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9565        assert_eq!(apply_pr_states(home.path(), &known), 0);
9566        for id in [live_id, unknown_id] {
9567            assert_eq!(recorded(home.path(), id), "open");
9568        }
9569    }
9570
9571    #[test]
9572    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9573        let tmp = tempfile::tempdir().unwrap();
9574        let h = tmp.path();
9575        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9576        pr_run(
9577            h,
9578            "20261004-100000-aaaa",
9579            "/repo/r",
9580            RunStatus::Superseded,
9581            &url(1),
9582            "open",
9583        );
9584        pr_run(
9585            h,
9586            "20261004-100100-bbbb",
9587            "/repo/r",
9588            RunStatus::Blocked,
9589            &url(2),
9590            "open",
9591        );
9592        pr_run(
9593            h,
9594            "20261004-100200-cccc",
9595            "/repo/r",
9596            RunStatus::Ready,
9597            &url(3),
9598            "open",
9599        );
9600        pr_run(
9601            h,
9602            "20261004-100300-dddd",
9603            "/repo/r",
9604            RunStatus::Ready,
9605            &url(4),
9606            "open",
9607        );
9608        pr_run(
9609            h,
9610            "20261004-100400-eeee",
9611            "/repo/r",
9612            RunStatus::Implementing,
9613            &url(1),
9614            "open",
9615        );
9616        assert_eq!(stale_open_prs(h).len(), 4);
9617
9618        let mut known = BTreeMap::new();
9619        known.insert(url(1), PrLifecycle::Merged);
9620        known.insert(url(2), PrLifecycle::Closed);
9621        known.insert(url(3), PrLifecycle::Open);
9622        // #4: the forge could not be read, so it has no answer.
9623        assert_eq!(apply_pr_states(h, &known), 2);
9624        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9625        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9626        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9627        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9628        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9629        assert_eq!(apply_pr_states(h, &known), 0);
9630    }
9631
9632    // --- the land loop against a scripted forge -------------------------
9633
9634    use std::collections::VecDeque;
9635    use std::sync::Mutex;
9636
9637    /// Answers views from a script (the last one repeats), merges from a
9638    /// queue, and records every call so a test can assert the order.
9639    struct Scripted {
9640        evidence: Mutex<Option<CiEvidence>>,
9641        views: Mutex<VecDeque<Seen>>,
9642        merges: Mutex<VecDeque<(bool, String)>>,
9643        fix: Mutex<Option<Fixed>>,
9644        log: Mutex<Vec<&'static str>>,
9645        argvs: Mutex<Vec<Vec<String>>>,
9646        required: Mutex<Option<BTreeSet<String>>>,
9647        /// Set once a merge answered ok: the forge then reports `merged`,
9648        /// unless `queued` says the merge only entered a queue.
9649        merged: Mutex<bool>,
9650        queued: Mutex<bool>,
9651        /// Views fail once a merge answered ok.
9652        unreadable_after_merge: Mutex<bool>,
9653        /// Workflow runs asked to rerun, and whether that call fails.
9654        reruns: Mutex<Vec<String>>,
9655        rerun_fails: Mutex<bool>,
9656        /// Unix seconds; every poll moves it on by `POLL`.
9657        clock: Mutex<i64>,
9658    }
9659
9660    impl Scripted {
9661        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9662            Self {
9663                evidence: Mutex::new(None),
9664                views: Mutex::new(views.into()),
9665                merges: Mutex::new(
9666                    merges
9667                        .into_iter()
9668                        .map(|(ok, m)| (ok, m.to_owned()))
9669                        .collect(),
9670                ),
9671                fix: Mutex::new(None),
9672                log: Mutex::new(Vec::new()),
9673                argvs: Mutex::new(Vec::new()),
9674                required: Mutex::new(None),
9675                merged: Mutex::new(false),
9676                queued: Mutex::new(false),
9677                unreadable_after_merge: Mutex::new(false),
9678                reruns: Mutex::new(Vec::new()),
9679                rerun_fails: Mutex::new(false),
9680                clock: Mutex::new(1_000),
9681            }
9682        }
9683        fn argvs(&self) -> Vec<Vec<String>> {
9684            self.argvs.lock().unwrap().clone()
9685        }
9686        fn calls(&self) -> Vec<&'static str> {
9687            self.log.lock().unwrap().clone()
9688        }
9689    }
9690
9691    impl Forge for Scripted {
9692        async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
9693            self.evidence.lock().unwrap().clone()
9694        }
9695        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9696            self.log.lock().unwrap().push("view");
9697            if *self.unreadable_after_merge.lock().unwrap()
9698                && !self.argvs.lock().unwrap().is_empty()
9699            {
9700                anyhow::bail!("forge unreachable");
9701            }
9702            let mut v = self.views.lock().unwrap();
9703            let mut seen = if v.len() > 1 {
9704                v.pop_front().unwrap()
9705            } else {
9706                v[0].clone()
9707            };
9708            if *self.merged.lock().unwrap() {
9709                seen.pr.state = PrLifecycle::Merged;
9710            }
9711            Ok(seen)
9712        }
9713        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9714            self.log.lock().unwrap().push("merge");
9715            self.argvs.lock().unwrap().push(argv.to_vec());
9716            let out = self
9717                .merges
9718                .lock()
9719                .unwrap()
9720                .pop_front()
9721                .expect("unscripted merge");
9722            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9723            {
9724                *self.merged.lock().unwrap() = true;
9725            }
9726            Ok(out)
9727        }
9728        async fn poll(&self) {
9729            self.log.lock().unwrap().push("poll");
9730            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9731        }
9732        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9733            self.log.lock().unwrap().push("rerun");
9734            self.reruns.lock().unwrap().push(run.to_owned());
9735            if *self.rerun_fails.lock().unwrap() {
9736                anyhow::bail!("gh run rerun: no");
9737            }
9738            Ok(())
9739        }
9740        fn now(&self) -> i64 {
9741            *self.clock.lock().unwrap()
9742        }
9743        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9744            self.required.lock().unwrap().clone()
9745        }
9746        async fn fix(
9747            &self,
9748            _state: &mut RunState,
9749            _pr: &PrState,
9750            _round: usize,
9751            _budget: usize,
9752            _reason: &str,
9753            _logs: &str,
9754        ) -> Result<Fixed> {
9755            self.log.lock().unwrap().push("fix");
9756            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
9757        }
9758    }
9759
9760    const REFUSED: &str =
9761        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
9762
9763    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
9764        let mut pr = green_pr();
9765        pr.checks = checks;
9766        pr.blocking = Blocking::of(merge_state);
9767        if !comments {
9768            pr.review_comments.clear();
9769        }
9770        Seen {
9771            pr,
9772            title: "feat: x".to_owned(),
9773            failing_urls: Vec::new(),
9774            head: head.to_owned(),
9775            rollup_head: head.to_owned(),
9776            merge_state: merge_state.to_owned(),
9777            contexts: Vec::new(),
9778            base: "main".to_owned(),
9779        }
9780    }
9781
9782    fn landing_state() -> RunState {
9783        crate::run::pin_test_home();
9784        let mut state = run_state();
9785        state.config.graph.land_approval = false;
9786        state
9787    }
9788
9789    #[test]
9790    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
9791        assert!(!awaiting_new_head(None, "aaa"));
9792        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
9793        assert!(awaiting_new_head(Some("abc123"), "def456"));
9794        assert!(awaiting_new_head(Some("abc123"), ""));
9795    }
9796
9797    #[test]
9798    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
9799        let open = |c, m: &str| seen("a", c, m, false);
9800        let table = [
9801            (None, false, Refused::Pending),
9802            (
9803                Some(open(Checks::Pending, "BLOCKED")),
9804                false,
9805                Refused::Pending,
9806            ),
9807            (
9808                Some(open(Checks::Unknown, "BLOCKED")),
9809                false,
9810                Refused::Pending,
9811            ),
9812            (
9813                Some(open(Checks::Green, "UNKNOWN")),
9814                false,
9815                Refused::Pending,
9816            ),
9817            (Some(open(Checks::Green, "")), false, Refused::Pending),
9818            (
9819                Some(open(Checks::Green, "BLOCKED")),
9820                false,
9821                Refused::Recheck,
9822            ),
9823            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
9824        ];
9825        for (after, rechecked, want) in table {
9826            assert_eq!(
9827                classify_refusal(after.as_ref(), rechecked, "a", CiExpectation::Expected),
9828                want
9829            );
9830        }
9831        let mut closed = open(Checks::Green, "CLEAN");
9832        closed.pr.state = PrLifecycle::Closed;
9833        assert_eq!(
9834            classify_refusal(Some(&closed), false, "a", CiExpectation::Expected),
9835            Refused::Final
9836        );
9837    }
9838
9839    #[tokio::test]
9840    async fn a_normal_landing_merges_on_the_first_look() {
9841        let mut state = landing_state();
9842        let forge = Scripted::new(
9843            vec![seen("a", Checks::Green, "CLEAN", false)],
9844            vec![(true, "")],
9845        );
9846        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9847            .await
9848            .unwrap();
9849        // One fresh read, then the head-bound merge.
9850        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9851        assert_eq!(state.status, RunStatus::Merged);
9852    }
9853
9854    #[tokio::test]
9855    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
9856        let mut state = landing_state();
9857        let forge = Scripted::new(
9858            vec![seen("a", Checks::Green, "CLEAN", false)],
9859            std::iter::repeat_n((true, ""), 100).collect(),
9860        );
9861        *forge.queued.lock().unwrap() = true;
9862        let task = async {
9863            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9864                .await
9865                .unwrap();
9866        };
9867        // Still open after the command succeeded: it keeps watching and
9868        // never records a merge (it stops at the wait ceiling instead).
9869        task.await;
9870        assert_ne!(state.status, RunStatus::Merged);
9871    }
9872
9873    #[tokio::test]
9874    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
9875        let mut state = landing_state();
9876        let forge = Scripted::new(
9877            vec![seen("a", Checks::Green, "CLEAN", false)],
9878            std::iter::repeat_n((true, ""), 100).collect(),
9879        );
9880        *forge.unreadable_after_merge.lock().unwrap() = true;
9881        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9882            .await
9883            .ok();
9884        assert_ne!(state.status, RunStatus::Merged);
9885    }
9886
9887    #[tokio::test]
9888    async fn a_task_public_head_is_awaited_on_land_entry() {
9889        let mut state = landing_state();
9890        state.pr_head = Some("magi/task-abcd-1234".into());
9891        state.pr_pushed_head = Some("new".into());
9892        let forge = Scripted::new(
9893            vec![
9894                seen("old", Checks::Green, "CLEAN", false),
9895                seen("new", Checks::Green, "CLEAN", false),
9896            ],
9897            vec![(true, "")],
9898        );
9899        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9900            .await
9901            .unwrap();
9902        assert_eq!(
9903            forge.calls(),
9904            ["view", "poll", "view", "view", "merge", "view"]
9905        );
9906        assert_eq!(state.status, RunStatus::Merged);
9907    }
9908
9909    #[tokio::test]
9910    async fn a_superseded_public_head_owner_cannot_reenter_land() {
9911        let mut state = landing_state();
9912        state.pr_taken_over_by = Some("next".into());
9913        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
9914        assert!(
9915            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9916                .await
9917                .is_err()
9918        );
9919        assert!(forge.calls().is_empty());
9920    }
9921
9922    #[tokio::test]
9923    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
9924        let mut state = landing_state();
9925        let forge = Scripted::new(
9926            vec![
9927                seen("old", Checks::Green, "CLEAN", true),
9928                // The forge has not moved to the new head yet: still green.
9929                seen("old", Checks::Green, "CLEAN", true),
9930                seen("new", Checks::Pending, "BLOCKED", true),
9931                seen("new", Checks::Green, "CLEAN", true),
9932            ],
9933            vec![(true, "")],
9934        );
9935        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9936            head: "NEW".to_owned(),
9937        });
9938        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9939            .await
9940            .unwrap();
9941        assert_eq!(
9942            forge.calls(),
9943            [
9944                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
9945                "view"
9946            ]
9947        );
9948        assert_eq!(state.status, RunStatus::Merged);
9949    }
9950
9951    #[tokio::test]
9952    async fn a_head_that_never_arrives_stops_naming_both_commits() {
9953        let mut state = landing_state();
9954        let forge = Scripted::new(
9955            vec![
9956                seen("old", Checks::Green, "CLEAN", true),
9957                seen("someone-elses", Checks::Green, "CLEAN", true),
9958            ],
9959            vec![],
9960        );
9961        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9962            head: "mine".to_owned(),
9963        });
9964        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9965            .await
9966            .unwrap();
9967        assert!(!forge.calls().contains(&"merge"));
9968        let why = state.merge.as_ref().unwrap().detail.clone();
9969        assert!(
9970            why.contains("mine") && why.contains("someone-elses"),
9971            "{why}"
9972        );
9973        assert_eq!(state.status, RunStatus::Blocked);
9974    }
9975
9976    #[tokio::test]
9977    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
9978        let mut state = landing_state();
9979        let forge = Scripted::new(
9980            vec![
9981                seen("a", Checks::Green, "CLEAN", false),
9982                seen("a", Checks::Green, "CLEAN", false),
9983                seen("a", Checks::Pending, "BLOCKED", false),
9984                seen("a", Checks::Pending, "BLOCKED", false),
9985                seen("a", Checks::Green, "CLEAN", false),
9986            ],
9987            vec![(false, REFUSED), (true, "")],
9988        );
9989        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9990            .await
9991            .unwrap();
9992        assert_eq!(
9993            forge.calls(),
9994            [
9995                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
9996                "view"
9997            ]
9998        );
9999        assert_eq!(state.status, RunStatus::Merged);
10000    }
10001
10002    #[tokio::test]
10003    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
10004        let mut state = landing_state();
10005        let forge = Scripted::new(
10006            vec![
10007                seen("a", Checks::Green, "CLEAN", false),
10008                seen("a", Checks::Green, "BLOCKED", false),
10009            ],
10010            vec![(false, REFUSED), (false, REFUSED)],
10011        );
10012        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10013            .await
10014            .unwrap();
10015        // One re-look is allowed for the forge's own lag, then it is final.
10016        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
10017        let why = state.merge.as_ref().unwrap().detail.clone();
10018        assert!(
10019            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
10020            "{why}"
10021        );
10022        assert_eq!(state.status, RunStatus::Blocked);
10023    }
10024
10025    #[test]
10026    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
10027        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
10028        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
10029        // The pull request is still on the commit before the push.
10030        assert_eq!(bound_head("old", "old", Some("new")), None);
10031        // The checks are the previous commit's.
10032        assert_eq!(bound_head("new", "old", Some("new")), None);
10033        assert_eq!(bound_head("new", "old", None), None);
10034        // Nothing readable.
10035        assert_eq!(bound_head("", "", None), None);
10036        assert_eq!(bound_head("", "", Some("new")), None);
10037        assert_eq!(bound_head("abc", "", None), None);
10038    }
10039
10040    fn view_json(head: &str) -> String {
10041        format!(
10042            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
10043            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
10044            "reviews":[],"comments":[]}}"#
10045        )
10046    }
10047
10048    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
10049        format!(
10050            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
10051            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
10052            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
10053            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
10054        )
10055    }
10056
10057    #[test]
10058    fn rollup_is_bound_to_the_commit_in_the_same_node() {
10059        // The view already points at the new head, but the node still answers
10060        // for the old commit with its red check: the head stays unbound.
10061        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
10062        assert_eq!(s.rollup_head, "old");
10063        assert_eq!(s.pr.checks, Checks::Red);
10064        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
10065        assert_eq!(s.failing_urls.len(), 1);
10066    }
10067
10068    #[test]
10069    fn checks_come_from_the_node_not_the_view() {
10070        let view = view_json("new").replace(
10071            r#""reviews""#,
10072            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
10073        );
10074        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
10075        assert_eq!(s.pr.checks, Checks::Green);
10076        assert!(s.pr.failing.is_empty());
10077        assert_eq!(
10078            bound_head(&s.head, &s.rollup_head, Some("new")),
10079            Some("new")
10080        );
10081    }
10082
10083    #[test]
10084    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
10085        for node in [
10086            None,
10087            Some("not json".to_owned()),
10088            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
10089            Some(node_json("new", "SUCCESS", true)),
10090        ] {
10091            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
10092            assert!(s.rollup_head.is_empty());
10093            assert_eq!(s.pr.checks, Checks::Unknown);
10094            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
10095        }
10096    }
10097
10098    #[test]
10099    fn the_merge_command_is_pinned_to_the_observed_head() {
10100        let argv = merge_argv_at(7, "feat: x", "deadbeef");
10101        let at = argv
10102            .iter()
10103            .position(|a| a == "--match-head-commit")
10104            .unwrap();
10105        assert_eq!(argv[at + 1], "deadbeef");
10106    }
10107
10108    #[tokio::test]
10109    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
10110        let mut state = landing_state();
10111        // The pull request is on the pushed head but the rollup is still the
10112        // previous commit's red, non-required result.
10113        let mut stale = seen("new", Checks::Red, "CLEAN", false);
10114        stale.rollup_head = "old".to_owned();
10115        let forge = Scripted::new(
10116            vec![seen("old", Checks::Green, "CLEAN", true), stale],
10117            vec![],
10118        );
10119        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10120            head: "new".to_owned(),
10121        });
10122        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10123            .await
10124            .unwrap();
10125        assert!(!forge.calls().contains(&"merge"));
10126        assert_eq!(state.status, RunStatus::Blocked);
10127        let why = state.merge.as_ref().unwrap().detail.clone();
10128        assert!(why.contains("new") && why.contains("old"), "{why}");
10129    }
10130
10131    #[test]
10132    fn a_refusal_read_against_another_commits_checks_is_pending() {
10133        let mut after = seen("a", Checks::Green, "BLOCKED", false);
10134        after.rollup_head = "old".to_owned();
10135        assert_eq!(
10136            classify_refusal(Some(&after), true, "a", CiExpectation::Expected),
10137            Refused::Pending
10138        );
10139    }
10140
10141    #[tokio::test]
10142    async fn absent_ci_empty_rollup_reaches_approval_then_merges_on_the_approved_head() {
10143        let mut state = landing_state();
10144        state.config.merge.ci = crate::config::MergeCi::None;
10145        state.config.graph.land_approval = true;
10146        let forge = Scripted::new(
10147            vec![seen("a", Checks::Unknown, "CLEAN", false)],
10148            vec![(true, "")],
10149        );
10150        land_with(&mut state, URL, &forge).await.unwrap();
10151        assert!(state.parked);
10152        assert_eq!(forge.calls(), ["view"]);
10153        let store = ask::Questions::open();
10154        let mut q = store
10155            .get(&state.land_approval.as_ref().unwrap().question)
10156            .unwrap();
10157        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10158        store.put(&mut q).unwrap();
10159
10160        land_with(&mut state, URL, &forge).await.unwrap();
10161        assert_eq!(forge.calls(), ["view", "view", "view", "merge", "view"]);
10162        assert_eq!(state.status, RunStatus::Merged);
10163        assert_eq!(
10164            value_of(&forge.argvs()[0], "--match-head-commit"),
10165            Some("a")
10166        );
10167    }
10168
10169    #[tokio::test]
10170    async fn absent_ci_ignores_failed_workflow_runs_without_rerunning_them() {
10171        let mut state = landing_state();
10172        state.config.merge.ci = crate::config::MergeCi::None;
10173        let forge = Scripted::new(vec![red("a", Some("7"))], vec![(true, "")]);
10174        land_with(&mut state, URL, &forge).await.unwrap();
10175        assert!(forge.reruns.lock().unwrap().is_empty());
10176        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10177        assert_eq!(state.status, RunStatus::Merged);
10178    }
10179
10180    #[test]
10181    fn absent_ci_still_requires_a_readable_matching_head_and_resolved_comments() {
10182        let shown = BTreeSet::new();
10183        let guard = |s: Option<&Seen>| {
10184            direct_merge_is_safe(s, "a", &shown, 0, 4, Duration::ZERO, CiExpectation::Absent)
10185        };
10186        let mut fresh = seen("a", Checks::Unknown, "CLEAN", false);
10187        assert!(guard(Some(&fresh)));
10188        assert!(!guard(None));
10189        fresh.rollup_head = "old".to_owned();
10190        assert!(!guard(Some(&fresh)));
10191        fresh.rollup_head = "a".to_owned();
10192        fresh.head = "b".to_owned();
10193        assert!(!guard(Some(&fresh)));
10194        assert!(!guard(Some(&seen("a", Checks::Unknown, "CLEAN", true))));
10195        assert!(!guard(Some(&seen("a", Checks::Unknown, "DIRTY", false))));
10196    }
10197
10198    #[test]
10199    fn absent_ci_refusal_does_not_wait_for_third_party_checks() {
10200        for checks in [Checks::Unknown, Checks::Pending, Checks::Red, Checks::Green] {
10201            let after = seen("a", checks, "BLOCKED", false);
10202            assert_eq!(
10203                classify_refusal(Some(&after), false, "a", CiExpectation::Absent),
10204                Refused::Recheck
10205            );
10206            assert_eq!(
10207                classify_refusal(Some(&after), true, "a", CiExpectation::Absent),
10208                Refused::Final
10209            );
10210        }
10211    }
10212
10213    #[tokio::test]
10214    async fn a_matching_head_with_red_non_required_checks_still_merges() {
10215        let mut state = landing_state();
10216        let forge = Scripted::new(
10217            vec![seen("a", Checks::Red, "CLEAN", false)],
10218            vec![(true, "")],
10219        );
10220        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10221            .await
10222            .unwrap();
10223        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10224        assert_eq!(state.status, RunStatus::Merged);
10225    }
10226
10227    #[tokio::test]
10228    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
10229        let mut state = landing_state();
10230        let forge = Scripted::new(
10231            vec![
10232                seen("a", Checks::Green, "CLEAN", false),
10233                seen("a", Checks::Green, "CLEAN", false),
10234                // Re-viewed after the refusal: someone pushed.
10235                seen("b", Checks::Green, "BLOCKED", false),
10236                seen("b", Checks::Green, "CLEAN", false),
10237            ],
10238            vec![(false, REFUSED), (true, "")],
10239        );
10240        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10241            .await
10242            .unwrap();
10243        assert_eq!(
10244            forge.calls(),
10245            [
10246                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
10247            ]
10248        );
10249        assert_eq!(state.status, RunStatus::Merged);
10250    }
10251
10252    fn merged_view(head: &str) -> Seen {
10253        let mut m = seen(head, Checks::Green, "CLEAN", false);
10254        m.pr.state = PrLifecycle::Merged;
10255        m
10256    }
10257
10258    fn has(argv: &[String], flag: &str) -> bool {
10259        argv.iter().any(|a| a == flag)
10260    }
10261
10262    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
10263        let at = argv.iter().position(|a| a == flag)?;
10264        argv.get(at + 1).map(String::as_str)
10265    }
10266
10267    const URL: &str = "https://github.com/o/r/pull/42";
10268
10269    #[tokio::test]
10270    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
10271        let mut state = landing_state();
10272        let forge = Scripted::new(
10273            vec![
10274                seen("abc", Checks::Green, "CLEAN", false),
10275                seen("abc", Checks::Green, "CLEAN", false),
10276            ],
10277            vec![(true, "")],
10278        );
10279        land_with(&mut state, URL, &forge).await.unwrap();
10280        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10281        let argv = &forge.argvs()[0];
10282        assert!(has(argv, "--squash") && has(argv, "--subject"));
10283        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
10284        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
10285        assert_eq!(state.status, RunStatus::Merged);
10286        assert!(state.land_armed_head.is_none());
10287    }
10288
10289    #[tokio::test]
10290    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
10291        let mut state = landing_state();
10292        state.land_armed_head = Some("a".to_owned());
10293        let forge = Scripted::new(
10294            vec![seen("a", Checks::Green, "CLEAN", false)],
10295            vec![(true, ""), (true, "")],
10296        );
10297        land_with(&mut state, URL, &forge).await.unwrap();
10298        let argvs = forge.argvs();
10299        assert!(has(&argvs[0], "--disable-auto"));
10300        assert!(!has(&argvs[1], "--auto"));
10301        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
10302        assert_eq!(state.status, RunStatus::Merged);
10303        assert!(state.land_armed_head.is_none());
10304    }
10305
10306    #[tokio::test]
10307    async fn an_approval_never_carries_over_to_a_new_head() {
10308        crate::run::pin_test_home();
10309        let mut state = run_state();
10310        state.config.graph.land_approval = true;
10311        let pr = green_pr();
10312        let store = ask::Questions::open();
10313
10314        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
10315            .await
10316            .unwrap();
10317        let mut q = store
10318            .list()
10319            .into_iter()
10320            .find(|q| q.run == state.id)
10321            .unwrap();
10322        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10323        store.put(&mut q).unwrap();
10324        assert_eq!(
10325            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
10326                .await
10327                .unwrap(),
10328            ApprovalGate::Approved,
10329            "the same head keeps its approval"
10330        );
10331
10332        // A new head is a new question, not the old word.
10333        assert_eq!(
10334            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10335                .await
10336                .unwrap(),
10337            ApprovalGate::Pending
10338        );
10339        let all: Vec<_> = store
10340            .list()
10341            .into_iter()
10342            .filter(|q| q.run == state.id)
10343            .collect();
10344        assert_eq!(all.len(), 2);
10345
10346        // A question recorded before heads were tracked is not reused either.
10347        state.land_approval = None;
10348        assert_eq!(
10349            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10350                .await
10351                .unwrap(),
10352            ApprovalGate::Pending
10353        );
10354        let open = store
10355            .list()
10356            .into_iter()
10357            .filter(|q| q.run == state.id && q.status.open())
10358            .count();
10359        assert_eq!(open, 1, "the superseded question was retired");
10360    }
10361
10362    #[tokio::test]
10363    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
10364        let mut state = landing_state();
10365        let forge = Scripted::new(
10366            vec![
10367                seen("a", Checks::Green, "CLEAN", false),
10368                // The fresh read before the merge: someone pushed.
10369                seen("b", Checks::Green, "CLEAN", false),
10370            ],
10371            vec![(true, "")],
10372        );
10373        land_with(&mut state, URL, &forge).await.unwrap();
10374        let argvs = forge.argvs();
10375        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
10376        assert!(!has(&argvs[0], "--auto"));
10377        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
10378        assert_eq!(state.status, RunStatus::Merged);
10379    }
10380
10381    fn passing(label: &str) -> CheckInfo {
10382        CheckInfo {
10383            label: label.to_owned(),
10384            verdict: Verdict::Pass,
10385            required: Some(false),
10386            run: None,
10387        }
10388    }
10389
10390    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
10391        (label.to_owned(), run.map(str::to_owned))
10392    }
10393
10394    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
10395        LandReruns {
10396            head: head.to_owned(),
10397            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
10398        }
10399    }
10400
10401    #[test]
10402    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
10403        let f = [failing("test (macos)", Some("7"))];
10404        assert_eq!(
10405            rerun_step(&f, None, "h", 100),
10406            RerunStep::Rerun(vec!["7".to_owned()])
10407        );
10408        let rec = reran("h", &[("7", 100)]);
10409        let grace = crate::release_watch::RERUN_GRACE;
10410        assert_eq!(
10411            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
10412            RerunStep::Grace
10413        );
10414        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
10415    }
10416
10417    #[test]
10418    fn a_failure_without_a_run_id_goes_to_the_fixer() {
10419        let f = [failing("a", Some("7")), failing("ci/external", None)];
10420        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
10421        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
10422    }
10423
10424    #[test]
10425    fn a_moved_head_forgets_the_reruns() {
10426        let f = [failing("a", Some("7"))];
10427        let rec = reran("old", &[("7", 100)]);
10428        assert_eq!(
10429            rerun_step(&f, Some(&rec), "new", 101),
10430            RerunStep::Rerun(vec!["7".to_owned()])
10431        );
10432    }
10433
10434    #[test]
10435    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
10436        let f = [
10437            failing("a", Some("7")),
10438            failing("b", Some("7")),
10439            failing("c", Some("8")),
10440        ];
10441        assert_eq!(
10442            rerun_step(&f, None, "h", 0),
10443            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
10444        );
10445        let rec = reran("h", &[("7", 0)]);
10446        assert_eq!(
10447            rerun_step(&f, Some(&rec), "h", 1),
10448            RerunStep::Rerun(vec!["8".to_owned()])
10449        );
10450    }
10451
10452    #[test]
10453    fn only_blocking_failures_are_candidates_for_a_rerun() {
10454        let check = |label: &str, verdict, required, run: &str| CheckInfo {
10455            label: label.to_owned(),
10456            verdict,
10457            required,
10458            run: Some(run.to_owned()),
10459        };
10460        let got = rerunnable(&[
10461            check("passed", Verdict::Pass, Some(true), "1"),
10462            check("optional", Verdict::Fail, Some(false), "2"),
10463            check("required", Verdict::Fail, Some(true), "3"),
10464            check("unknown", Verdict::Fail, None, "4"),
10465        ]);
10466        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
10467        assert_eq!(labels, ["required", "unknown"]);
10468    }
10469
10470    fn unrelated_ci_fixture() -> CiEvidence {
10471        let failure = CiFailure {
10472            path: "tests/web_render.rs".into(),
10473            start_line: 42,
10474            end_line: 42,
10475            title: "question_identity_disclosure_copy_and_live_updates".into(),
10476            message: "assertion failed".into(),
10477        };
10478        CiEvidence {
10479            head: "a".into(),
10480            base: "base-sha".into(),
10481            changed: vec![
10482                "AGENTS.md".into(),
10483                "src/talk.rs".into(),
10484                "tests/consult_investigation.rs".into(),
10485            ],
10486            checks: vec![CiCheckEvidence {
10487                label: "coverage".into(),
10488                head_check: 70,
10489                base_check: 60,
10490                app: 1,
10491                run: "7".into(),
10492                base_run: "6".into(),
10493                head_failures: vec![failure.clone()],
10494                base_failures: vec![failure],
10495                rerun_exhausted: true,
10496            }],
10497        }
10498    }
10499
10500    #[test]
10501    fn unrelated_ci_requires_complete_structural_evidence_for_every_failure() {
10502        let good = unrelated_ci_fixture();
10503        let red = vec!["coverage".into()];
10504        assert!(good.unrelated("a", &red));
10505        assert!(!good.unrelated("another-head", &red));
10506        assert!(!good.unrelated("a", &["coverage".into(), "test".into()]));
10507        assert!(!good.unrelated("a", &[]));
10508        for mutation in 0..8 {
10509            let mut bad = good.clone();
10510            match mutation {
10511                0 => bad.checks[0].rerun_exhausted = false,
10512                1 => bad.checks[0].base_failures.clear(),
10513                2 => bad.checks[0].head_failures.clear(),
10514                3 => bad.changed.push("tests/web_render.rs".into()),
10515                4 => bad.changed.push(".github/workflows/ci.yml".into()),
10516                5 => bad.checks[0].base_failures[0].message = "different failure".into(),
10517                6 => bad.checks.push(bad.checks[0].clone()),
10518                _ => bad.base.clear(),
10519            }
10520            assert!(!bad.unrelated("a", &red), "mutation {mutation}");
10521        }
10522    }
10523
10524    #[test]
10525    fn red_unknown_merge_state_waits_before_repair_but_never_implies_merge() {
10526        let pr = seen("a", Checks::Red, "UNKNOWN", false).pr;
10527        assert_eq!(decide(&pr, 0, 2, Duration::ZERO), Step::Wait);
10528        assert!(matches!(decide(&pr, 0, 2, CHECKS_GRACE), Step::Fix { .. }));
10529        assert!(matches!(
10530            decide(&pr, 2, 2, CHECKS_GRACE),
10531            Step::GiveUp { .. }
10532        ));
10533        let optional = CheckInfo {
10534            label: "coverage".into(),
10535            verdict: Verdict::Fail,
10536            required: Some(false),
10537            run: Some("7".into()),
10538        };
10539        assert!(rerunnable_with(std::slice::from_ref(&optional), false).is_empty());
10540        assert_eq!(
10541            rerunnable_with(&[optional], true),
10542            vec![("coverage".into(), Some("7".into()))]
10543        );
10544    }
10545
10546    #[tokio::test]
10547    async fn unrelated_ci_after_a_comment_round_does_not_charge_a_no_commit_stop() {
10548        let mut state = landing_state();
10549        let mut red = coverage_unknown();
10550        red.pr = seen("a", Checks::Red, "BLOCKED", true).pr;
10551        red.pr.failing = vec!["coverage".into()];
10552        let forge = Scripted::new(vec![red], vec![]);
10553        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10554        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10555        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10556        assert!(forge.calls().contains(&"fix"));
10557        assert!(state.land_unrelated.is_some());
10558        assert!(state.parked);
10559        assert!(
10560            !state
10561                .events
10562                .iter()
10563                .any(|e| e.message.contains("fixer produced no commit"))
10564        );
10565    }
10566
10567    fn coverage_unknown() -> Seen {
10568        let mut s = red("a", Some("7"));
10569        s.pr.blocking = Blocking::Unsaid;
10570        s.merge_state = "UNKNOWN".into();
10571        s.pr.failing = vec!["coverage".into()];
10572        s.contexts[0].label = "coverage".into();
10573        s.contexts[0].required = Some(false);
10574        s
10575    }
10576
10577    #[tokio::test]
10578    async fn run_196c_unknown_then_unstable_does_not_spend_a_fix_round() {
10579        let mut state = landing_state();
10580        let unknown = coverage_unknown();
10581        let mut unstable = unknown.clone();
10582        unstable.pr.blocking = Blocking::of("UNSTABLE");
10583        unstable.merge_state = "UNSTABLE".into();
10584        let forge = Scripted::new(vec![unknown, unstable], vec![(true, "")]);
10585        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10586        assert!(!forge.calls().contains(&"fix"));
10587        assert!(!forge.calls().contains(&"rerun"));
10588        assert_eq!(state.status, RunStatus::Merged);
10589        assert_eq!(state.pr.unwrap().round, 0);
10590    }
10591
10592    #[tokio::test]
10593    async fn run_196c_persistent_unknown_reruns_optional_once_then_parks_with_evidence() {
10594        let mut state = landing_state();
10595        let forge = Scripted::new(vec![coverage_unknown()], vec![]);
10596        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10597        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10598        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10599        assert!(!forge.calls().contains(&"fix"));
10600        assert!(state.parked);
10601        assert_eq!(state.status, RunStatus::Landing);
10602        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10603        let hold = state.land_unrelated.as_ref().unwrap();
10604        let q = ask::Questions::open().get(&hold.question).unwrap();
10605        assert!(
10606            q.actions
10607                .values()
10608                .any(|a| matches!(a, ask::ChoiceAction::Resume { .. }))
10609        );
10610        assert!(
10611            state
10612                .events
10613                .iter()
10614                .any(|e| e.message.contains("[ci-unrelated]") && e.message.contains("base-sha"))
10615        );
10616        let first = state.land_unsaid.clone();
10617        // A resume on this head neither extends UNKNOWN grace nor reruns 7.
10618        state.parked = false;
10619        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10620        assert_eq!(state.land_unsaid, first);
10621        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10622    }
10623
10624    fn red(head: &str, run: Option<&str>) -> Seen {
10625        let mut s = seen(head, Checks::Red, "BLOCKED", false);
10626        s.pr.failing = vec!["test (macos)".to_owned()];
10627        s.contexts = vec![CheckInfo {
10628            label: "test (macos)".to_owned(),
10629            verdict: Verdict::Fail,
10630            required: Some(true),
10631            run: run.map(str::to_owned),
10632        }];
10633        s
10634    }
10635
10636    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
10637
10638    #[tokio::test]
10639    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
10640        let mut state = landing_state();
10641        let forge = Scripted::new(
10642            vec![
10643                red("a", Some("7")),
10644                seen("a", Checks::Pending, "BLOCKED", false),
10645                seen("a", Checks::Green, "CLEAN", false),
10646            ],
10647            vec![(true, "")],
10648        );
10649        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10650        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10651        assert!(!forge.calls().contains(&"fix"));
10652        assert_eq!(state.status, RunStatus::Merged);
10653        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10654        let argvs = forge.argvs();
10655        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
10656        assert!(
10657            state
10658                .events
10659                .iter()
10660                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
10661        );
10662    }
10663
10664    #[tokio::test]
10665    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
10666        let mut state = landing_state();
10667        let forge = Scripted::new(
10668            vec![
10669                red("a", Some("7")),
10670                seen("a", Checks::Green, "CLEAN", false),
10671            ],
10672            vec![(true, "")],
10673        );
10674        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10675        // The red view repeats for the grace, then the fixer is asked once.
10676        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10677        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
10678        let calls = forge.calls();
10679        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
10680        let fix_at = calls.iter().position(|c| *c == "fix");
10681        assert!(fix_at.is_none_or(|f| f > rerun_at));
10682    }
10683
10684    #[tokio::test]
10685    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
10686        let mut state = landing_state();
10687        let mut v = red("a", Some("7"));
10688        v.pr.checks = Checks::Pending;
10689        v.contexts.push(CheckInfo {
10690            label: "slow".to_owned(),
10691            verdict: Verdict::Pending,
10692            required: Some(true),
10693            run: Some("8".to_owned()),
10694        });
10695        let forge = Scripted::new(
10696            vec![v, seen("a", Checks::Green, "CLEAN", false)],
10697            vec![(true, "")],
10698        );
10699        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10700        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10701        assert_eq!(state.status, RunStatus::Merged);
10702    }
10703
10704    #[test]
10705    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10706        let mut v = red("a", Some("7"));
10707        v.contexts.push(CheckInfo {
10708            label: "same run, still going".to_owned(),
10709            verdict: Verdict::Pending,
10710            required: Some(true),
10711            run: Some("7".to_owned()),
10712        });
10713        assert!(rerunnable(&v.contexts).is_empty());
10714    }
10715
10716    #[tokio::test]
10717    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10718        let mut state = landing_state();
10719        state.config.graph.land_rounds = 0;
10720        let forge = Scripted::new(
10721            vec![
10722                red("a", Some("7")),
10723                seen("a", Checks::Green, "CLEAN", false),
10724            ],
10725            vec![(true, "")],
10726        );
10727        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10728        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10729        assert_eq!(state.status, RunStatus::Merged);
10730        assert!(!forge.calls().contains(&"fix"));
10731        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10732    }
10733
10734    #[tokio::test]
10735    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
10736        let mut state = landing_state();
10737        state.config.graph.land_rounds = 1;
10738        let forge = Scripted::new(
10739            vec![
10740                seen("a", Checks::Green, "CLEAN", true),
10741                red("b", Some("7")),
10742                seen("b", Checks::Green, "CLEAN", false),
10743            ],
10744            vec![(true, "")],
10745        );
10746        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10747            head: "b".to_owned(),
10748        });
10749        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10750        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10751        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
10752        assert_eq!(state.pr.as_ref().unwrap().round, 1);
10753        assert_eq!(state.status, RunStatus::Merged);
10754        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
10755        assert_eq!(
10756            value_of(&forge.argvs()[0], "--match-head-commit"),
10757            Some("b")
10758        );
10759    }
10760
10761    #[tokio::test]
10762    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
10763        let mut state = landing_state();
10764        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10765        *forge.rerun_fails.lock().unwrap() = true;
10766        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10767        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10768        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
10769        assert_eq!(
10770            forge.calls().iter().filter(|c| **c == "fix").count(),
10771            1,
10772            "{:?}",
10773            forge.calls()
10774        );
10775    }
10776
10777    #[tokio::test]
10778    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
10779        let mut state = landing_state();
10780        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10781        // The record was written just before the previous pass died.
10782        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
10783        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10784        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10785        assert!(forge.reruns.lock().unwrap().is_empty());
10786    }
10787
10788    #[tokio::test]
10789    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
10790        let mut state = landing_state();
10791        state.config.graph.rerun_flaky_checks = false;
10792        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10793        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10794        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10795        assert!(forge.reruns.lock().unwrap().is_empty());
10796        assert!(forge.calls().contains(&"fix"));
10797
10798        let mut state = landing_state();
10799        let forge = Scripted::new(vec![red("a", None)], vec![]);
10800        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10801        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10802        assert!(forge.reruns.lock().unwrap().is_empty());
10803        assert!(forge.calls().contains(&"fix"));
10804    }
10805
10806    fn names(xs: &[&str]) -> BTreeSet<String> {
10807        xs.iter().map(|x| (*x).to_owned()).collect()
10808    }
10809
10810    #[test]
10811    fn a_required_check_the_rollup_never_listed_is_named() {
10812        let req = names(&["build"]);
10813        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
10814        assert!(why.contains("never reported: build"), "{why}");
10815        assert!(!why.contains("probably waiting for a review"), "{why}");
10816    }
10817
10818    #[test]
10819    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
10820        let why = waiting_on("BLOCKED", &[passing("review")], None);
10821        assert!(why.contains("could not be read"), "{why}");
10822        assert!(!why.contains("probably waiting for a review"), "{why}");
10823    }
10824
10825    #[test]
10826    fn all_required_reported_keeps_the_review_guess() {
10827        let req = names(&["build"]);
10828        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10829        assert!(why.contains("probably waiting for a review"), "{why}");
10830        assert!(!why.contains("never reported"), "{why}");
10831    }
10832
10833    #[test]
10834    fn required_names_match_the_rollup_ignoring_case_only() {
10835        let req = names(&["Build"]);
10836        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10837        assert!(!why.contains("never reported"), "{why}");
10838    }
10839
10840    #[test]
10841    fn required_contexts_are_read_from_protection_and_rulesets() {
10842        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
10843        assert_eq!(
10844            parse_classic_required(classic),
10845            Some(names(&["build", "lint"]))
10846        );
10847        let rules = r#"[{"type":"pull_request","parameters":{}},
10848            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
10849        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
10850        assert_eq!(parse_ruleset_required("nope"), None);
10851        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
10852    }
10853
10854    #[test]
10855    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
10856        let shown = BTreeSet::new();
10857        let ok = seen("a", Checks::Green, "CLEAN", false);
10858        let guard = |s: Option<&Seen>| {
10859            direct_merge_is_safe(
10860                s,
10861                "A",
10862                &shown,
10863                0,
10864                4,
10865                Duration::ZERO,
10866                CiExpectation::Expected,
10867            )
10868        };
10869        assert!(guard(Some(&ok)));
10870        assert!(!guard(None));
10871        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
10872        let mut stale = ok.clone();
10873        stale.rollup_head = "old".to_owned();
10874        assert!(!guard(Some(&stale)));
10875        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
10876        assert!(!guard(Some(&merged_view("a"))));
10877    }
10878
10879    #[test]
10880    fn the_rollup_node_carries_whether_each_check_is_required() {
10881        let node = node_json("new", "SUCCESS", false)
10882            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
10883        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
10884        assert_eq!(s.contexts.len(), 1);
10885        assert_eq!(s.contexts[0].required, Some(true));
10886        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
10887        assert_eq!(s.contexts[0].required, None);
10888    }
10889
10890    #[tokio::test]
10891    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
10892        let mut state = landing_state();
10893        state.land_armed_head = Some("a".to_owned());
10894        let forge = Scripted::new(
10895            vec![seen("a", Checks::Green, "CLEAN", true)],
10896            vec![(false, "disable exploded")],
10897        );
10898        land_with(&mut state, URL, &forge).await.unwrap();
10899        assert!(!forge.calls().contains(&"fix"));
10900        assert_eq!(state.status, RunStatus::Blocked);
10901        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
10902        let why = state.merge.as_ref().unwrap().detail.clone();
10903        assert!(why.contains("disable exploded"), "{why}");
10904    }
10905}