Skip to main content

magi/
land.rs

1//! Landing the winner: watch the pull request, fix what it complains about,
2//! and merge it.
3//!
4//! Opening the pull request used to be where magi stopped and the operator
5//! started: watch the checks, read what the review bots found, push a fix,
6//! wait again, merge. That loop is mechanical, it takes an hour of wall-clock
7//! time per pull request, and doing it by hand six times in one session is how
8//! a queue that drains unattended stops being unattended. So it lives here.
9//!
10//! # Shape
11//!
12//! [`PrState`] is one observation of a pull request and [`decide`] is the whole
13//! policy as a *pure* function of it. Nothing in [`decide`] talks to `gh`,
14//! which is what makes "green with an unresolved comment is a fix, not a merge"
15//! an assertion in a test rather than a claim in a comment. [`land`] is the
16//! only part that performs I/O: observe, decide, act, repeat.
17//!
18//! # What it refuses to do
19//!
20//! Merging is the one irreversible thing magi can do to a repository, so the
21//! loop is built to stop rather than to guess:
22//!
23//! * A red pull request is never merged. When the budget runs out the pull
24//!   request is left open with a comment naming what is still failing, because
25//!   a magi that force-merges a red pull request is worse than one that stops.
26//! * A pull request whose checks cannot be read at all (`gh` reported no
27//!   rollup) is not merged either. Landing is for repositories with CI; with no
28//!   signal there is nothing to be green.
29//! * A pull request a human merged or closed underneath us is
30//!   [`Step::Done`] - the person won, and their decision is not an error.
31//!
32//! # Why `--subject` is not optional
33//!
34//! A candidate branch holds one commit whose subject is
35//! `magi: candidate A (uncommitted work)`, and `gh pr merge --squash` prefers a
36//! single commit's message over the pull request title. Merging without
37//! [`merge_argv`]'s explicit `--subject` therefore writes a `main` history that
38//! says nothing about what landed. `AGENTS.md` records the trap; this module is
39//! where it is prevented.
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::MergeMode;
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{
58    ContestedHandoff, LandApproval, LandReruns, MergeOutcome, RunState, RunStatus, tail,
59};
60
61/// How often the pull request is re-read while its checks are still running.
62///
63/// Thirty seconds: a CI matrix takes minutes, so anything shorter is spent
64/// entirely on `gh` invocations, and anything much longer adds latency to every
65/// single round of a loop that already waits for agents.
66pub const POLL: Duration = Duration::from_secs(30);
67
68/// How long one wait may last before landing gives up on the checks finishing.
69///
70/// A workflow that has not settled in forty-five minutes is stuck on a runner
71/// queue, a missing approval, or a hung job - none of which more polling fixes,
72/// and all of which a person needs to see.
73pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
74
75/// How long the checks may stay unreadable before landing gives up on them.
76///
77/// GitHub registers a workflow run some seconds after the branch is pushed, so
78/// immediately after a pull request is opened "no checks" and "no CI in this
79/// repository" look identical. Measured on run 01c2: magi opened pull request
80/// 22, read `unknown` four seconds later, refused to merge on a guess and
81/// marked the run blocked - and every check on that pull request was green
82/// minutes afterwards, with the whole competition then re-run from scratch for
83/// a task that was already finished. Three minutes is well past the observed
84/// registration delay and still bounded, so a repository that genuinely has no
85/// checks costs one three-minute wait and then says so.
86pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
87
88/// Bytes of failing log kept per check. The fixer needs the assertion and the
89/// frame around it, not the forty thousand lines of `cargo` output above it.
90const LOG_TAIL: usize = 4_000;
91
92/// Failing checks whose logs are fetched. Beyond a handful the failures share a
93/// cause, and fetching each one costs a `gh` round trip.
94const MAX_LOGS: usize = 3;
95
96/// Marker carried by every comment magi posts on a pull request.
97///
98/// Without it magi's own "still failing" comment is indistinguishable from a
99/// reviewer's, and the next observation would hand magi's own prose to the
100/// fixer as a finding.
101pub const MARKER: &str = "<!-- magi:land -->";
102
103/// Markers a bot puts in a comment to say that the comment is not a review.
104///
105/// CodeRabbit labels its own machinery in HTML comments - the trigger notice,
106/// the walkthrough summary, the "thanks for using" footer - and its actual
107/// findings arrive as *inline* review comments with a path and a line. Taking
108/// the bot at its word is more honest than guessing from prose, and it is the
109/// difference between a fix round that has something to fix and one that asks
110/// an agent to act on a quota notice.
111const NOT_A_REVIEW: [&str; 3] = [
112    "skip review by coderabbit.ai",
113    "summarize by coderabbit.ai",
114    "<!-- tips_start -->",
115];
116
117/// Where a pull request is in its life.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrLifecycle {
120    /// Still ours to land.
121    Open,
122    /// Already merged, by us or by a person.
123    Merged,
124    /// Closed without merging.
125    Closed,
126}
127
128/// The aggregate verdict of a pull request's checks.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Checks {
131    /// At least one check has not finished.
132    Pending,
133    /// Every check passed (a skipped check counts as passed: the review
134    /// workflow skips release and bot pull requests by design).
135    Green,
136    /// At least one check finished without passing.
137    Red,
138    /// Nothing readable - no rollup at all, or a status magi does not know.
139    Unknown,
140}
141
142impl PrLifecycle {
143    /// Stable lower-case name, as the API and the reports spell it.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::Open => "open",
147            Self::Merged => "merged",
148            Self::Closed => "closed",
149        }
150    }
151}
152
153impl Checks {
154    /// Stable lower-case name, as the API and the reports spell it.
155    pub fn as_str(self) -> &'static str {
156        match self {
157            Self::Pending => "pending",
158            Self::Green => "green",
159            Self::Red => "red",
160            Self::Unknown => "unknown",
161        }
162    }
163}
164
165/// One outstanding review comment, human or bot.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ReviewComment {
168    /// Login of whoever wrote it.
169    pub author: String,
170    /// File it was left on, for inline review comments.
171    pub path: Option<String>,
172    /// Line it was left on, when the comment is inline and still anchored.
173    pub line: Option<u64>,
174    /// The comment itself, as written.
175    pub body: String,
176}
177
178/// One observation of a pull request.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct PrState {
181    /// Pull request url, as `gh` reports it.
182    pub url: String,
183    /// Pull request number.
184    pub number: u64,
185    /// Open, merged, or closed.
186    pub state: PrLifecycle,
187    /// Aggregate check verdict.
188    pub checks: Checks,
189    /// Names of the checks that finished without passing.
190    pub failing: Vec<String>,
191    /// Comments that still want an answer, human and bot.
192    pub review_comments: Vec<ReviewComment>,
193    /// Whether the forge itself considers the failures blocking.
194    pub blocking: Blocking,
195}
196
197/// Whether a failing check actually stands between the pull request and
198/// `main`, according to the forge.
199///
200/// The rollup lists every check equally, so `coverage` going red on a
201/// repository that deliberately does not require it looked exactly like a
202/// broken build - and magi answered by spending a fix round on a change that
203/// was fine. Pull request 37 had to be merged by hand for that reason: the
204/// only red check was `editorconfig`, which was failing because the *action*
205/// could not fetch its own binary, and which the repository does not require.
206///
207/// `mergeStateStatus` is where GitHub applies the required-check set, so it
208/// is the one field that can tell the difference.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Blocking {
211    /// Required checks are satisfied and the branch merges cleanly.
212    No,
213    /// Something required is failing or missing.
214    Yes,
215    /// The branch no longer merges: the base moved under it.
216    Conflict,
217    /// The forge did not say - an older `gh`, or a token without the scope.
218    /// Treated as `Yes`, because refusing to guess is the rule everywhere
219    /// else in this module.
220    Unsaid,
221}
222
223impl Blocking {
224    /// Read `mergeStateStatus`, which is upper-case in `gh`'s output.
225    fn of(raw: &str) -> Self {
226        match raw.to_ascii_uppercase().as_str() {
227            // Mergeable. `UNSTABLE` is the interesting one: mergeable, with a
228            // non-required check failing or still running.
229            "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
230            "DIRTY" => Self::Conflict,
231            "" | "UNKNOWN" => Self::Unsaid,
232            // BLOCKED, BEHIND, DRAFT: something has to change first.
233            _ => Self::Yes,
234        }
235    }
236
237    /// Does this stand between the pull request and the base branch?
238    #[must_use]
239    pub fn stops_a_merge(self) -> bool {
240        !matches!(self, Self::No)
241    }
242}
243
244/// What the loop decided to do next. Pure, so the policy is testable.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Step {
247    /// Checks are still running; re-read the pull request after [`POLL`].
248    Wait,
249    /// The base moved and the branch no longer merges: rebase it.
250    ///
251    /// Not a fix round. Nothing is wrong with the change - a competition
252    /// that runs for two hours against a repository merging pull requests
253    /// all day conflicts on the way in, and that is arithmetic rather than a
254    /// defect. Pull requests 35 and 37 were both rebased by hand for exactly
255    /// this.
256    Rebase,
257    /// Red checks or unresolved comments; run a fix round.
258    Fix {
259        /// What is unhappy, in one line, for the run log and the fix prompt.
260        reason: String,
261    },
262    /// Green and nothing outstanding; merge it.
263    Merge,
264    /// The pull request left our hands.
265    Done {
266        /// Did it land, or was it closed?
267        merged: bool,
268    },
269    /// Stop and leave the pull request to a person.
270    GiveUp {
271        /// Why magi stopped, in one line.
272        reason: String,
273    },
274}
275
276/// The outcome to record when `gh pr merge` exits non-zero, given what the
277/// pull request looked like immediately afterwards.
278///
279/// `gh pr merge` merges server-side first and only then does local work -
280/// deleting the branch, switching back to a base branch - so a non-zero exit
281/// does not mean the merge did not happen. In a jj-colocated repository it
282/// reliably does not mean that: git HEAD is detached, and `--delete-branch`
283/// ends with "could not determine current branch: not on any branch" *after*
284/// the merge has landed. Run ec12 merged pull request 28 into `main` and
285/// recorded `ok: false`, and its task was held waiting for a merge that was
286/// already done.
287///
288/// So the forge is asked, and its answer wins - the same authority [`decide`]
289/// gives the pull request's own state over everything else. The recorded
290/// detail carries both facts, because "the command failed and the merge
291/// happened anyway" is exactly what someone reading the run later needs to
292/// know.
293///
294/// `None` means the merge really did not happen, including when the pull
295/// request could not be read at all: an unreadable answer is not evidence of
296/// success.
297pub(crate) fn merged_after_all(
298    argv: &[String],
299    stderr: &str,
300    after: Option<PrLifecycle>,
301) -> Option<MergeOutcome> {
302    if after? != PrLifecycle::Merged {
303        return None;
304    }
305    Some(MergeOutcome {
306        mode: MergeMode::Pr,
307        ok: true,
308        detail: format!(
309            "gh {} (the command reported `{}`, but the pull request is merged)",
310            argv.join(" "),
311            stderr.trim()
312        ),
313        empty: false,
314    })
315}
316
317/// Decide the next step. No I/O.
318///
319/// `round` counts the fix rounds already spent, so `round == budget` means the
320/// budget is gone. A wait never spends a round: waiting is free, and a slow CI
321/// must not consume the allowance meant for actual fixes.
322///
323/// The order of the tests is the policy:
324///
325/// 1. **The pull request's own state wins.** A merge or a close that happened
326///    underneath us is the end of the story regardless of what the checks say.
327/// 2. **Pending beats red.** A check that is still running may yet fail, and one
328///    fix round that addresses every failure is cheaper than two that each
329///    address half - the fix pushes and restarts the whole suite anyway.
330/// 3. **Comments outrank green.** An unresolved comment holds the merge even
331///    when CI is happy; that is what a review is for.
332/// 4. **Unreadable is not absent.** Checks that cannot be read yet are waited
333///    on for [`CHECKS_GRACE`], because a pull request opened a moment ago has
334///    not been given its workflow runs yet. Past the grace they are treated as
335///    genuinely missing and magi stops rather than merge on a guess.
336pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
337    decide_with(pr, round, budget, waited, CiExpectation::Expected)
338}
339
340/// Whether the repository's CI is expected to report on this pull request.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum CiExpectation {
343    /// Checks will come: wait for them, judge them (the default, and what
344    /// [`decide`] always uses).
345    Expected,
346    /// No check will ever report (`[release] mode = "local"` on a repository
347    /// whose Actions never run). Waiting out [`CHECKS_GRACE`] or reading
348    /// `unknown` as a refusal would stall forever, so the checks are read as
349    /// absent and the pull request is ready as soon as it merges cleanly.
350    Absent,
351}
352
353/// [`decide`] with the CI expectation made explicit. `Expected` is exactly
354/// [`decide`]; `Absent` reads the absence of CI as the reason to proceed, and
355/// still stops for a conflict (the base moved), which a rebase cures and no
356/// check state does.
357pub fn decide_with(
358    pr: &PrState,
359    round: usize,
360    budget: usize,
361    waited: Duration,
362    ci: CiExpectation,
363) -> Step {
364    match pr.state {
365        PrLifecycle::Merged => return Step::Done { merged: true },
366        PrLifecycle::Closed => return Step::Done { merged: false },
367        PrLifecycle::Open => {}
368    }
369
370    // Before the checks: every check on a branch that cannot land is an
371    // answer about a state that cannot land.
372    if pr.blocking == Blocking::Conflict {
373        return Step::Rebase;
374    }
375
376    if ci == CiExpectation::Absent {
377        return Step::Merge;
378    }
379
380    let spent = round >= budget;
381    match pr.checks {
382        Checks::Pending => Step::Wait,
383        Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
384        Checks::Unknown => Step::GiveUp {
385            reason: format!(
386                "no check status is readable on the pull request after {} minute(s); \
387                 refusing to merge on a guess",
388                CHECKS_GRACE.as_secs() / 60
389            ),
390        },
391        // Red, but the forge says it does not stand in the way: the failing
392        // checks are ones this repository chose not to require. Spending a fix
393        // round on them asks an agent to repair something nobody is gating on
394        // - and pull request 37's only red check was an *action* that could
395        // not fetch its own binary. Merge, and name them so the record is
396        // honest about what was red when it landed.
397        Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
398        Checks::Red => {
399            let what = format!(
400                "{} check(s) failing: {}",
401                pr.failing.len(),
402                pr.failing.join(", ")
403            );
404            if spent {
405                Step::GiveUp {
406                    reason: format!("{what} — still red after {budget} fix round(s)"),
407                }
408            } else {
409                Step::Fix { reason: what }
410            }
411        }
412        Checks::Green if pr.review_comments.is_empty() => Step::Merge,
413        Checks::Green => {
414            let what = format!(
415                "checks are green but {} review comment(s) are unresolved: {}",
416                pr.review_comments.len(),
417                authors(&pr.review_comments)
418            );
419            if spent {
420                Step::GiveUp {
421                    reason: format!("{what} — still unresolved after {budget} fix round(s)"),
422                }
423            } else {
424                Step::Fix { reason: what }
425            }
426        }
427    }
428}
429
430/// Distinct comment authors, in the order they first appear.
431fn authors(comments: &[ReviewComment]) -> String {
432    let mut seen: Vec<&str> = Vec::new();
433    for c in comments {
434        if !seen.contains(&c.author.as_str()) {
435            seen.push(&c.author);
436        }
437    }
438    seen.join(", ")
439}
440
441/// The argv magi merges with, minus the program name.
442///
443/// `--subject` is the point of this function existing: see the module docs.
444pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
445    vec![
446        "pr".to_owned(),
447        "merge".to_owned(),
448        number.to_string(),
449        "--squash".to_owned(),
450        "--delete-branch".to_owned(),
451        "--subject".to_owned(),
452        subject.to_owned(),
453    ]
454}
455
456/// [`merge_argv`] pinned to the commit the decision was made about.
457///
458/// `--match-head-commit` makes the forge refuse the merge if the branch moved
459/// after it was observed, so a push landing between the look and the merge is
460/// never merged unseen.
461pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
462    let mut argv = merge_argv(number, subject);
463    argv.push("--match-head-commit".to_owned());
464    argv.push(head.to_owned());
465    argv
466}
467
468/// Take auto-merge back. magi no longer arms auto-merge, but a run recorded by
469/// an older build may still have one standing on the forge, so the disarm
470/// paths (and `RunState::land_armed_head`) stay. Run before anything that changes the head, so an
471/// armed merge never outlives the commit that was approved for it.
472pub fn disable_automerge_argv(number: u64) -> Vec<String> {
473    ["pr", "merge", &number.to_string(), "--disable-auto"]
474        .map(str::to_owned)
475        .to_vec()
476}
477
478/// May the direct merge go ahead, judged from a fresh read?
479///
480/// The pull request must still be open on the approved head, the checks must
481/// have been read for that very head, and the policy must still say merge.
482/// Pure, so the head guard is asserted without a forge.
483fn direct_merge_is_safe(
484    fresh: Option<&Seen>,
485    approved_head: &str,
486    shown: &BTreeSet<String>,
487    round: usize,
488    budget: usize,
489    waited: Duration,
490) -> bool {
491    let Some(fresh) = fresh else {
492        return false;
493    };
494    if fresh.pr.state != PrLifecycle::Open {
495        return false;
496    }
497    let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
498        return false;
499    };
500    if !bound.eq_ignore_ascii_case(approved_head) {
501        return false;
502    }
503    let mut pr = fresh.pr.clone();
504    pr.review_comments.retain(|c| !shown.contains(&c.body));
505    decide(&pr, round, budget, waited) == Step::Merge
506}
507
508/// What GitHub is still waiting for, for the stop reason when an armed merge
509/// does not happen in time. No I/O.
510///
511/// Required checks that are pending or failing are named. A check whose
512/// required-ness could not be read is never assumed optional: every unsettled
513/// check is listed and the reason says so.
514fn waiting_on(
515    merge_state: &str,
516    contexts: &[CheckInfo],
517    required_set: Option<&BTreeSet<String>>,
518) -> String {
519    let state = if merge_state.is_empty() {
520        "unknown"
521    } else {
522        merge_state
523    };
524    let tag = |c: &CheckInfo| match c.verdict {
525        Verdict::Fail => "failed",
526        _ => "pending",
527    };
528    let unsettled: Vec<&CheckInfo> = contexts
529        .iter()
530        .filter(|c| c.verdict != Verdict::Pass)
531        .collect();
532    let required: Vec<String> = unsettled
533        .iter()
534        .filter(|c| c.required == Some(true))
535        .map(|c| format!("{} ({})", c.label, tag(c)))
536        .collect();
537    let unknown: Vec<String> = unsettled
538        .iter()
539        .filter(|c| c.required.is_none())
540        .map(|c| format!("{} ({})", c.label, tag(c)))
541        .collect();
542    // Required contexts the rollup never listed: nothing reported them, so no
543    // pending/failing entry exists to name. Matched case-insensitively against
544    // the labels as the rollup spells them, and no further.
545    let never: Vec<&str> = required_set
546        .map(|set| {
547            set.iter()
548                .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
549                .map(String::as_str)
550                .collect()
551        })
552        .unwrap_or_default();
553    let mut out = format!("merge state: {state}");
554    if !never.is_empty() {
555        let _ = write!(
556            out,
557            "; required checks never reported: {}",
558            never.join(", ")
559        );
560    }
561    if !required.is_empty() {
562        let _ = write!(
563            out,
564            "; required checks not passing: {}",
565            required.join(", ")
566        );
567    }
568    if !unknown.is_empty() {
569        let _ = write!(
570            out,
571            "; whether these are required could not be read, so they may be: {}",
572            unknown.join(", ")
573        );
574    }
575    if required.is_empty() && unknown.is_empty() && never.is_empty() {
576        if required_set.is_some() {
577            out.push_str(
578                "; no required check is pending, failing or unreported, so GitHub is probably \
579                 waiting for a review or another branch rule",
580            );
581        } else {
582            out.push_str(
583                "; the required check list could not be read, so a required check that was \
584                 never reported cannot be ruled out",
585            );
586        }
587    }
588    out
589}
590
591/// The squash subject to merge under.
592///
593/// The pull request title, unless it is empty or is a candidate branch's commit
594/// subject that leaked into the title - in which case the task's own first line
595/// is used, because `magi: candidate A (uncommitted work)` in `main` tells a
596/// reader nothing about what landed.
597pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
598    let title = pr_title.trim();
599    if !title.is_empty() && !title.starts_with("magi: candidate") {
600        return title.to_owned();
601    }
602    let first = instruction
603        .lines()
604        .map(str::trim)
605        .find(|l| !l.is_empty())
606        .unwrap_or("magi: land the winning candidate");
607    first.trim_start_matches(['#', ' ']).to_owned()
608}
609
610/// The choice that lets the merge happen, verbatim as the owner taps it.
611pub const APPROVE: &str = "merge";
612
613/// The choice that leaves the pull request open.
614pub const HOLD: &str = "hold";
615
616/// A bounded repair followed by a new review and a fresh owner decision.
617pub const REPAIR: &str = "fix findings and re-review";
618
619/// Whether review evidence offers a meaningful repair choice. This does not
620/// decide whether owner approval is required.
621pub fn repair_available(state: &RunState) -> bool {
622    state.reviews.last().is_some_and(|r| {
623        state.open_findings().iter().any(|f| f.severity.blocks())
624            || r.final_votes()
625                .iter()
626                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
627    })
628}
629
630/// All applicable reasons for consulting the owner, independent of repair choices.
631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632pub enum ApprovalReason {
633    /// Universal owner approval is enabled for every merge.
634    EveryMerge,
635    /// Contested-merge approval is enabled and blocking findings remain open.
636    OpenBlockingFindings,
637    /// Contested-merge approval is enabled and a final reviewer vote rejects.
638    FinalReject,
639    /// An existing owner decision must be honored even if controls change.
640    ExistingDecision,
641    /// Merge repair requires a fresh owner decision, even on the same head.
642    AfterRepair,
643}
644
645/// Disabling fresh approval controls never bypasses an existing owner decision
646/// or the fresh decision owed after repair, even if repair kept the same head.
647pub fn approval_reasons(state: &RunState) -> Vec<ApprovalReason> {
648    let mut reasons = Vec::new();
649    if state.owner_hold(crate::config::OwnerHold::LandApproval) {
650        reasons.push(ApprovalReason::EveryMerge);
651    }
652    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
653        if state.open_findings().iter().any(|f| f.severity.blocks()) {
654            reasons.push(ApprovalReason::OpenBlockingFindings);
655        }
656        if state.reviews.last().is_some_and(|r| {
657            r.final_votes()
658                .iter()
659                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
660        }) {
661            reasons.push(ApprovalReason::FinalReject);
662        }
663    }
664    if state.land_approval.is_some() {
665        reasons.push(ApprovalReason::ExistingDecision);
666    }
667    if !state.merge_repairs.is_empty() {
668        reasons.push(ApprovalReason::AfterRepair);
669    }
670    reasons
671}
672
673/// Bound free-form approval evidence by characters, including an explicit
674/// ellipsis when abbreviated. Keep UTF-8 text intact in questions and panels.
675fn approval_excerpt(text: &str, max_chars: usize) -> String {
676    if text.chars().count() <= max_chars {
677        text.to_owned()
678    } else {
679        let mut excerpt: String = text.chars().take(max_chars.saturating_sub(1)).collect();
680        excerpt.push('…');
681        excerpt
682    }
683}
684
685/// Persisted verbatim in the question and panel at creation. Deputies and
686/// resumed questions use that snapshot, never reconstruct past policy from
687/// today's settings. Fixer claims and successful gates are not resolutions.
688fn approval_evidence(state: &RunState) -> String {
689    const TITLE_CHARS: usize = 100;
690    const REASON_CHARS: usize = 300;
691    let reason = |text: &str| approval_excerpt(text, REASON_CHARS);
692    let ja = words(&state.config.graph.language).html_lang == "ja";
693    let mut s = if ja {
694        "承認が必要な理由:\n"
695    } else {
696        "Approval required because:\n"
697    }
698    .to_owned();
699    for reason in approval_reasons(state) {
700        let label = match (reason, ja) {
701            (ApprovalReason::EveryMerge, false) => {
702                "land_approval=true: owner approval for every merge"
703            }
704            (ApprovalReason::EveryMerge, true) => {
705                "land_approval=true: すべてのマージに所有者の承認が必要"
706            }
707            (ApprovalReason::OpenBlockingFindings, false) => {
708                "hold_contested_merge=true: open Major-or-above internal findings"
709            }
710            (ApprovalReason::OpenBlockingFindings, true) => {
711                "hold_contested_merge=true: Major 以上の内部指摘が未解決"
712            }
713            (ApprovalReason::FinalReject, false) => {
714                "hold_contested_merge=true: final reviewer reject after reconsideration (even without Major findings)"
715            }
716            (ApprovalReason::FinalReject, true) => {
717                "hold_contested_merge=true: 再考後の最終票が reject(Major 指摘がなくても承認が必要)"
718            }
719            (ApprovalReason::ExistingDecision, false) => {
720                "existing owner decision: changing controls cannot bypass it"
721            }
722            (ApprovalReason::ExistingDecision, true) => {
723                "既存の所有者判断: 設定変更では迂回できません"
724            }
725            (ApprovalReason::AfterRepair, false) => {
726                "fresh owner approval after merge repair, even with an unchanged head"
727            }
728            (ApprovalReason::AfterRepair, true) => {
729                "マージ修復後の新しい所有者承認(head に変更がなくても必要)"
730            }
731        };
732        let control = match reason {
733            ApprovalReason::EveryMerge => Some(crate::config::OwnerHold::LandApproval),
734            ApprovalReason::OpenBlockingFindings | ApprovalReason::FinalReject => {
735                Some(crate::config::OwnerHold::HoldContestedMerge)
736            }
737            _ => None,
738        };
739        let source = control
740            .filter(|c| !c.base_value(&state.config.graph))
741            .and_then(|c| state.branch_owner_holds.iter().find(|h| h.control == c));
742        if let Some(source) = source {
743            if ja {
744                let _ = writeln!(
745                    s,
746                    "- {}=true(レビュー対象ブランチの {} 由来, {})",
747                    source.control.key(),
748                    source.file,
749                    source.head
750                );
751            } else {
752                let _ = writeln!(
753                    s,
754                    "- {}=true from the reviewed branch's {} ({})",
755                    source.control.key(),
756                    source.file,
757                    source.head
758                );
759            }
760        }
761        let _ = writeln!(s, "- {label}");
762    }
763    s.push_str(if ja {
764        "\n再考後の各レビュアーの最終票:\n"
765    } else {
766        "\nFinal reviewer votes after reconsideration:\n"
767    });
768    if let Some(round) = state.reviews.last() {
769        let votes = round.final_votes();
770        for r in &round.reviews {
771            let vote = votes
772                .iter()
773                .find(|(seat, _, _)| *seat == r.reviewer)
774                .map(|(_, _, v)| v.label())
775                .unwrap_or(if ja { "記録なし" } else { "not recorded" });
776            let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
777            let rv = round
778                .reconsideration
779                .iter()
780                .find(|rv| rv.reviewer == r.reviewer);
781            if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
782                let initial = r.vote.map(|v| v.label()).unwrap_or("?");
783                let _ = writeln!(
784                    s,
785                    "  {}: {initial} → {vote}",
786                    if ja { "再考" } else { "reconsideration" }
787                );
788                let _ = writeln!(
789                    s,
790                    "  {}: {}",
791                    if ja { "理由" } else { "reason" },
792                    reason(if rv.reason.trim().is_empty() {
793                        if ja { "記録なし" } else { "not recorded" }
794                    } else {
795                        &rv.reason
796                    })
797                );
798            } else {
799                let _ = writeln!(
800                    s,
801                    "  {}: {}",
802                    if ja {
803                        "初回票を保持・理由"
804                    } else {
805                        "initial vote retained; reason"
806                    },
807                    reason(if r.summary.trim().is_empty() {
808                        if ja { "記録なし" } else { "not recorded" }
809                    } else {
810                        &r.summary
811                    })
812                );
813                if let Some(rv) = rv {
814                    let _ = writeln!(
815                        s,
816                        "  {}: {}",
817                        if ja {
818                            "再投票の回答なし"
819                        } else {
820                            "no reconsideration vote"
821                        },
822                        reason(rv.failed.as_deref().unwrap_or(if ja {
823                            "理由の記録なし"
824                        } else {
825                            "reason not recorded"
826                        }))
827                    );
828                }
829            }
830            if let Some(failed) = &r.failed {
831                let _ = writeln!(
832                    s,
833                    "  {}: {}",
834                    if ja { "回答なし" } else { "no answer" },
835                    reason(failed)
836                );
837            }
838        }
839    } else {
840        s.push_str(if ja {
841            "レビュー記録なし\n"
842        } else {
843            "No review recorded\n"
844        });
845    }
846    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" });
847    let findings = state.last_round_findings();
848    for f in &findings {
849        let _ = writeln!(
850            s,
851            "[{}] {} {}:{}: {}\n{}",
852            f.id,
853            f.severity,
854            f.file.as_deref().unwrap_or("?"),
855            f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
856            approval_excerpt(&f.title, TITLE_CHARS),
857            reason(&f.detail)
858        );
859        let mut evidence = false;
860        for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
861            state
862                .operator_fixes
863                .iter()
864                .filter_map(|request| request.fix.as_ref()),
865        ) {
866            if fix.addressed.contains(&f.id) {
867                evidence = true;
868                let _ = writeln!(
869                    s,
870                    "  {} ({}): {}",
871                    if ja {
872                        "採用申告(解決未確認)"
873                    } else {
874                        "claimed addressed (resolution unconfirmed)"
875                    },
876                    fix.agent,
877                    reason(&fix.notes)
878                );
879            }
880            for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
881                evidence = true;
882                let _ = writeln!(
883                    s,
884                    "  {}: {}",
885                    if ja { "不採用理由" } else { "declined" },
886                    reason(&rejection.why)
887                );
888            }
889        }
890        if !evidence {
891            s.push_str(if ja {
892                "  採用・解決確認の記録なし\n"
893            } else {
894                "  No adoption or resolution evidence recorded\n"
895            });
896        }
897    }
898    if findings.is_empty() {
899        s.push_str(if ja {
900            "最終ラウンドに指摘の記録なし\n"
901        } else {
902            "No findings recorded in the last round\n"
903        });
904    }
905    s
906}
907
908/// Shared choices for web, CLI and consultation questions.
909pub fn approval_choices(state: &RunState) -> Vec<String> {
910    let mut choices = vec![
911        APPROVE.to_owned(),
912        HOLD.to_owned(),
913        REPAIR.to_owned(),
914        crate::merge_decision::WITHDRAW.to_owned(),
915        crate::merge_decision::DISMISS.to_owned(),
916    ];
917    choices.extend(
918        state
919            .config
920            .agents
921            .iter()
922            .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
923    );
924    choices
925}
926
927/// Extend unanswered legacy cards in place. Answered cards keep their choices;
928/// a legacy repair answer gains only its missing dispatch action.
929pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
930    if q.node != APPROVAL_NODE
931        || !state
932            .land_approval
933            .as_ref()
934            .is_some_and(|a| a.question == q.id)
935    {
936        return false;
937    }
938    let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
939    if q.status.open() {
940        // Older cards attached Resume to hold; a hold must remain answerable
941        // without dispatching a question action.
942        q.actions.remove(HOLD);
943        for choice in approval_choices(state) {
944            if !q.choices.contains(&choice) {
945                q.choices.push(choice);
946            }
947        }
948        if q.merge_target.is_none() {
949            q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
950                pr: pr.url.clone(),
951                head: state.land_approval.as_ref().unwrap().head.clone(),
952                language: state.config.graph.language.clone(),
953            });
954        }
955    }
956    for choice in q.choices.clone() {
957        if (q.status.open()
958            && crate::merge_decision::stops(&choice)
959            && choice != HOLD
960            && choice != crate::merge_decision::DISMISS)
961            || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
962        {
963            q.actions.insert(
964                choice,
965                ask::ChoiceAction::Resume {
966                    run: state.id.clone(),
967                },
968            );
969        }
970    }
971    before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
972}
973
974/// Whether `quote` is a verbatim part of the owner's `message` and nothing in
975/// the whole message carries a hedge, a condition, a negation, a question or a
976/// retraction. Used by `deputy::destructive`.
977///
978/// The whole message is read, not just the quote's sentence: a condition or a
979/// second thought in another sentence ("Merge it. Only if CI passes.") makes
980/// the approval conditional all the same. Doubt anywhere is `false`.
981pub fn unhedged(message: &str, quote: &str) -> bool {
982    let quote = quote.trim();
983    !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
984}
985
986/// Hedging, conditional, negated or interrogative wording. ASCII words are
987/// matched as whole words so `note` is not `not`. A bare negation or stop word
988/// (`no`, `stop`, `nope`, ...) anywhere in the message voids the approval.
989fn hedged(text: &str) -> bool {
990    const WORDS: &[&str] = &[
991        "maybe",
992        "probably",
993        "perhaps",
994        "might",
995        "if",
996        "unless",
997        "not",
998        "no",
999        "nope",
1000        "stop",
1001        "dont",
1002        "abort",
1003        "revert",
1004        "undo",
1005        "never",
1006        "wait",
1007        "hold",
1008        "cancel",
1009        "but",
1010        "think",
1011        "guess",
1012        "suppose",
1013        "unsure",
1014        "yet",
1015        "except",
1016        "only",
1017        "cannot",
1018        "should",
1019        "once",
1020        "provided",
1021        "providing",
1022        "when",
1023        "whenever",
1024        "after",
1025        "until",
1026        "before",
1027        "assuming",
1028        "given",
1029        "while",
1030        "whether",
1031        "depending",
1032        "pending",
1033    ];
1034    const JA: &[&str] = &[
1035        "かも",
1036        "たぶん",
1037        "多分",
1038        "なら",
1039        "たら",
1040        "ちょっと待",
1041        "しないで",
1042        "しない",
1043        "保留",
1044        "まだ",
1045        "ただし",
1046        "やめ",
1047        "いや",
1048        "止め",
1049        "だめ",
1050        "ダメ",
1051        "じゃない",
1052        "ではない",
1053        "ですか",
1054        "かな",
1055        "でしょう",
1056        "思う",
1057        "でも",
1058        "けど",
1059        "ただ",
1060        "次第",
1061        "場合",
1062        "限り",
1063        "条件",
1064        "とき",
1065        "まで",
1066        "後で",
1067    ];
1068    if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
1069        return true;
1070    }
1071    text.to_lowercase()
1072        .replace('\u{2019}', "'")
1073        .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
1074        .filter(|w| !w.is_empty())
1075        .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
1076}
1077
1078/// Wording that takes back what the rest of the message said. Bare negation
1079/// and stop words are `hedged`'s whole-word list, not substrings here.
1080fn retracts(message: &str) -> bool {
1081    let lower = message.to_lowercase();
1082    [
1083        "やっぱ",
1084        "待って",
1085        "撤回",
1086        "never mind",
1087        "actually",
1088        "on second thought",
1089    ]
1090    .iter()
1091    .any(|w| lower.contains(w))
1092        || lower
1093            .split(|c: char| !c.is_ascii_alphabetic())
1094            .any(|w| w == "wait")
1095}
1096
1097/// Graph node recorded on the approval question.
1098///
1099/// The phone keys its high-stakes card off this rather than off the choice
1100/// strings, so renaming a button cannot silently downgrade the card that
1101/// guards the one irreversible action magi takes.
1102pub const APPROVAL_NODE: &str = "land-approval";
1103
1104/// Unified diff lines carried in the panel before it is truncated.
1105///
1106/// Four hundred: the panel is read on a 390px phone, where a diff line often
1107/// wraps to two rows, so this is already a few thousand rows of scrolling -
1108/// past that nobody is reading, and the bytes still count against the panel's
1109/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
1110/// cut and which worktree holds the whole patch.
1111pub const DIFF_MAX_LINES: usize = 400;
1112
1113/// What the owner's answer to the approval question means.
1114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1115pub enum Approval {
1116    /// The owner said [`APPROVE`]. Merge.
1117    Merge,
1118    /// Repair the existing PR, then ask again; never approval to merge.
1119    Repair,
1120    /// Anything else, including silence. Leave the pull request open.
1121    Hold,
1122}
1123
1124/// Read the owner's answer, where `None` is an unanswered question.
1125///
1126/// Silence is a hold. A timed-out question means the owner never saw it or
1127/// never decided, and defaulting an irreversible merge to "yes" would make this
1128/// gate worse than no gate at all: it would merge unattended while claiming to
1129/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
1130/// function does not recognise holds too.
1131pub fn approval(answer: Option<&str>) -> Approval {
1132    match answer {
1133        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1134        Some(a) if a.trim() == REPAIR => Approval::Repair,
1135        _ => Approval::Hold,
1136    }
1137}
1138
1139/// What [`approval_gate`] found on one check of the owner's merge decision.
1140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1141enum ApprovalGate {
1142    /// The owner said [`APPROVE`]. Merge.
1143    Approved,
1144    /// The owner explicitly requested repair, without authorizing a merge.
1145    Repair,
1146    /// The owner said anything else, the question timed out, or it was
1147    /// closed with no decision recorded.
1148    Held,
1149    /// Filed and still waiting - the caller parks rather than blocking on it.
1150    Pending,
1151}
1152
1153/// Escape text for HTML, including both quote characters.
1154///
1155/// Every string in the panel is agent-influenced: a branch name, a file path, a
1156/// commit subject, a review comment. The sandboxed frame stops such text from
1157/// *running*, but it does not stop a `<` from ending the document early or a
1158/// `"` from ending an attribute and inventing a new one - the panel would then
1159/// render a lie, or not render at all. Both quotes are escaped because the same
1160/// function is used inside attributes, where remembering which quote style the
1161/// caller used is one mistake away from an injected attribute.
1162fn esc(s: &str) -> String {
1163    let mut out = String::with_capacity(s.len());
1164    for c in s.chars() {
1165        match c {
1166            '&' => out.push_str("&amp;"),
1167            '<' => out.push_str("&lt;"),
1168            '>' => out.push_str("&gt;"),
1169            '"' => out.push_str("&quot;"),
1170            '\'' => out.push_str("&#39;"),
1171            _ => out.push(c),
1172        }
1173    }
1174    out
1175}
1176
1177/// One row of the diffstat table.
1178#[derive(Debug, Clone, PartialEq, Eq)]
1179struct StatRow {
1180    path: String,
1181    /// `None` for a binary file, which `git` reports as `-`.
1182    added: Option<u64>,
1183    removed: Option<u64>,
1184}
1185
1186impl StatRow {
1187    /// Lines touched, for sorting. A binary file counts as zero rather than as
1188    /// unknown, which puts it at the bottom where it needs no attention.
1189    fn churn(&self) -> u64 {
1190        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1191    }
1192}
1193
1194/// Parse `git diff --numstat` into rows, biggest churn first.
1195///
1196/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
1197/// terminal width, so counting its characters would print fabricated numbers in
1198/// the one table an operator approves an irreversible action from.
1199fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1200    let mut rows: Vec<StatRow> = numstat
1201        .lines()
1202        .filter_map(|line| {
1203            let mut parts = line.splitn(3, '\t');
1204            let added = parts.next()?.trim();
1205            let removed = parts.next()?.trim();
1206            let path = parts.next()?.trim();
1207            if path.is_empty() {
1208                return None;
1209            }
1210            Some(StatRow {
1211                path: path.to_owned(),
1212                added: added.parse().ok(),
1213                removed: removed.parse().ok(),
1214            })
1215        })
1216        .collect();
1217    // Path breaks the tie so the same change always renders the same table; an
1218    // operator comparing two panels should not see rows shuffle.
1219    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1220    rows
1221}
1222
1223/// How one diff line is shown: a gutter character, a style, and the body to
1224/// print - which is the line minus its marker, so the marker appears exactly
1225/// once, in the gutter.
1226///
1227/// The gutter is why this exists at all. The operator may be colour blind, or
1228/// reading in sunlight with the screen dimmed, so an added line is never
1229/// distinguished by its background alone: `+` and `-` sit in a fixed column,
1230/// the same mark they already read in a terminal.
1231fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1232    if line.starts_with("+++") || line.starts_with("---") {
1233        (" ", "color:#57606a;font-weight:600", line)
1234    } else if let Some(body) = line.strip_prefix('+') {
1235        ("+", "background:#e6ffec;color:#0a3622", body)
1236    } else if let Some(body) = line.strip_prefix('-') {
1237        ("-", "background:#ffebe9;color:#5c1a17", body)
1238    } else if line.starts_with("@@") {
1239        ("~", "background:#eef2ff;color:#3730a3", line)
1240    } else if let Some(body) = line.strip_prefix(' ') {
1241        (" ", "", body)
1242    } else {
1243        (" ", "color:#57606a;font-weight:600", line)
1244    }
1245}
1246
1247/// The handful of words the approval panel says in its own voice.
1248///
1249/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
1250/// the operator asked why the merge question spoke English on a repository
1251/// configured for Japanese, and "because that string is a literal in Rust" is
1252/// not an answer. Only the languages magi can actually check are translated;
1253/// anything else falls back to English rather than shipping a guess, and that
1254/// fallback is deliberate.
1255struct Words {
1256    html_lang: &'static str,
1257    task: &'static str,
1258    what_changed: &'static str,
1259    review_verdict: &'static str,
1260    reviewer: &'static str,
1261    reviewer_no_answer: &'static str,
1262    checks: &'static str,
1263    nothing_failing: &'static str,
1264    files_changed: &'static str,
1265    commits: &'static str,
1266    no_commits: &'static str,
1267    comments: &'static str,
1268    no_comments: &'static str,
1269    diff: &'static str,
1270    truncated: &'static str,
1271    lands_as: &'static str,
1272}
1273
1274const EN: Words = Words {
1275    html_lang: "en",
1276    task: "Task",
1277    what_changed: "What changed",
1278    review_verdict: "Review verdict",
1279    reviewer: "Reviewer",
1280    reviewer_no_answer: "produced no answer",
1281    checks: "Checks",
1282    nothing_failing: "Nothing failing.",
1283    files_changed: "file(s) changed",
1284    commits: "Commits being squashed",
1285    no_commits: "No commit subjects could be read from the branch.",
1286    comments: "External PR review comments",
1287    no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1288    diff: "Diff",
1289    truncated: "Truncated",
1290    lands_as: "They land as one commit titled",
1291};
1292
1293const JA: Words = Words {
1294    html_lang: "ja",
1295    task: "タスク",
1296    what_changed: "変更内容",
1297    review_verdict: "レビューの結論",
1298    reviewer: "レビュアー",
1299    reviewer_no_answer: "回答なし",
1300    checks: "チェック",
1301    nothing_failing: "失敗しているものはありません。",
1302    files_changed: "ファイル変更",
1303    commits: "squash されるコミット",
1304    no_commits: "ブランチからコミット件名を読めませんでした。",
1305    comments: "外部 PR レビューコメント",
1306    no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1307    diff: "差分",
1308    truncated: "省略",
1309    lands_as: "これらは次の件名の1コミットとして入ります:",
1310};
1311
1312impl Words {
1313    /// The clause after the merge subject. Split out because word order moves:
1314    /// Japanese puts the subject before the verb, so a shared template with a
1315    /// hole in the middle would read as machine translation.
1316    fn lands_as_tail(&self) -> &'static str {
1317        if self.html_lang == "ja" {
1318            "。この件名も承認の対象です。"
1319        } else {
1320            ", which you are approving too."
1321        }
1322    }
1323
1324    /// The question's own one-line summary, which is what a phone shows first.
1325    fn approval_summary(&self, number: u64, subject: &str) -> String {
1326        if self.html_lang == "ja" {
1327            format!("プルリクエスト #{number} をマージ: {subject}")
1328        } else {
1329            format!("merge pull request #{number}: {subject}")
1330        }
1331    }
1332
1333    /// The body under the summary, above the panel.
1334    fn approval_detail(
1335        &self,
1336        url: &str,
1337        base: &str,
1338        subject: &str,
1339        contested: Option<&ContestedHandoff>,
1340    ) -> String {
1341        let body = if self.html_lang == "ja" {
1342            format!(
1343                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1344                 できる状態です。差分の要約・パッチ・squash されるコミットは\
1345                 下のパネルにあります。"
1346            )
1347        } else {
1348            format!(
1349                "{url} is green and ready to squash into `{base}` as `{subject}`. \
1350                 The panel holds the diffstat, the patch and the commits being squashed."
1351            )
1352        };
1353        match contested {
1354            Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1355            None => body,
1356        }
1357    }
1358
1359    /// Why this question exists although merge approvals are off: the open
1360    /// blocking findings and who rejected. Short enough for a phone.
1361    fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1362        const SHOWN: usize = 5;
1363        const TITLE_CHARS: usize = 100;
1364        let ja = self.html_lang == "ja";
1365        let mut out = if ja {
1366            format!(
1367                "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1368                 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1369            )
1370        } else {
1371            format!(
1372                "{url} is held for approval although merge approvals are off: the \
1373                 review ended with blocking findings still open and a reviewer \
1374                 voting reject.\n"
1375            )
1376        };
1377        for f in c.findings.iter().take(SHOWN) {
1378            let at = match (&f.file, f.line) {
1379                (Some(file), Some(line)) => format!("{file}:{line}"),
1380                (Some(file), None) => file.clone(),
1381                _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1382            };
1383            let title: String = f.title.chars().take(TITLE_CHARS).collect();
1384            let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1385        }
1386        if c.findings.len() > SHOWN {
1387            let more = c.findings.len() - SHOWN;
1388            let _ = writeln!(
1389                out,
1390                "{}",
1391                if ja {
1392                    format!("- ほか {more} 件")
1393                } else {
1394                    format!("- and {more} more")
1395                }
1396            );
1397        }
1398        let seats: Vec<String> = c
1399            .rejecters
1400            .iter()
1401            .map(|(seat, agent)| format!("#{seat} ({agent})"))
1402            .collect();
1403        let _ = write!(
1404            out,
1405            "{} {}",
1406            if ja {
1407                "却下したレビュアー:"
1408            } else {
1409                "Rejected by reviewer:"
1410            },
1411            seats.join(", ")
1412        );
1413        out
1414    }
1415
1416    /// The truncation note, written whole in each language for the same reason.
1417    fn truncated_note(
1418        &self,
1419        omitted: usize,
1420        total: usize,
1421        shown: usize,
1422        where_: &str,
1423        base: &str,
1424        head: &str,
1425    ) -> String {
1426        if self.html_lang == "ja" {
1427            format!(
1428                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1429                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1430                 プルリクエストにあります。"
1431            )
1432        } else {
1433            format!(
1434                "{omitted} of {total} diff lines omitted after the first {shown}. \
1435                 The whole patch is in <code>{where_}</code> \
1436                 (<code>git diff {base}...{head}</code>) and on the pull request."
1437            )
1438        }
1439    }
1440}
1441
1442/// Pick the panel's language. Codes and names both, because `[graph] language`
1443/// has always accepted either.
1444fn words(language: &str) -> &'static Words {
1445    if crate::lang::is_japanese(language) {
1446        &JA
1447    } else {
1448        &EN
1449    }
1450}
1451
1452/// The approval panel's html: what is about to land, and the evidence for it.
1453///
1454/// Pure, so the whole document is asserted in tests without `gh`, without a
1455/// network and without a repository. The caller gathers `diffstat`
1456/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
1457/// being squashed) and `subject` (what the squash will be called) from the
1458/// winner's worktree.
1459///
1460/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
1461/// content security policy blocks all three: anything of the sort here would be
1462/// dead markup that misleads the next reader into thinking it works.
1463pub fn approval_panel(
1464    state: &RunState,
1465    pr: &PrState,
1466    diffstat: &str,
1467    diff: &str,
1468    commits: &[String],
1469    subject: &str,
1470) -> String {
1471    let rows = parse_numstat(diffstat);
1472    let w = words(&state.config.graph.language);
1473    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1474
1475    let _ = writeln!(
1476        h,
1477        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1478         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1479        w.html_lang
1480    );
1481    let _ = writeln!(
1482        h,
1483        "<title>merge #{} — {}</title>\n</head>",
1484        pr.number,
1485        esc(subject)
1486    );
1487    h.push_str(
1488        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1489         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1490         word-break:break-word\">\n",
1491    );
1492
1493    // The decision, in the words the operator is approving.
1494    let _ = writeln!(
1495        h,
1496        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1497         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1498         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1499         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1500         <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1501        pr.number,
1502        esc(&state.base_branch),
1503        esc(subject),
1504        esc(&state.id),
1505        esc(&pr.url),
1506        esc(&pr.url),
1507    );
1508
1509    let _ = writeln!(
1510        h,
1511        "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1512        esc(&approval_evidence(state))
1513    );
1514
1515    // The task, verbatim: the operator's own words for what was asked, so the
1516    // panel does not make them reconstruct the request from a diffstat.
1517    let _ = writeln!(
1518        h,
1519        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1520         <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1521        w.task,
1522        esc(&state.instruction)
1523    );
1524
1525    // The winner's own account of what it did and why, when there is one.
1526    if let Some(summary) = state
1527        .winner()
1528        .map(|c| c.summary.as_str())
1529        .filter(|s| !s.is_empty())
1530    {
1531        let _ = writeln!(
1532            h,
1533            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1534             <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1535            w.what_changed,
1536            esc(summary)
1537        );
1538    }
1539
1540    // Only the last round's reviewer prose is current. Its votes and
1541    // remaining findings are shown explicitly in the evidence above.
1542    if let Some(round) = state.reviews.last() {
1543        let _ = writeln!(
1544            h,
1545            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1546            w.review_verdict
1547        );
1548        for r in &round.reviews {
1549            // A seat the review loop counted as answered has real prose in
1550            // `summary`; one it counted against `incomplete` (see
1551            // `graph::Runner::review_loop`) never produced any and left it
1552            // empty - which must not be read back as a blank verdict, since
1553            // an empty box here looks like "nothing to say" rather than
1554            // "never answered".
1555            let body = match &r.failed {
1556                Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1557                None => esc(&r.summary),
1558            };
1559            let _ = writeln!(
1560                h,
1561                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1562                 border-radius:6px\">\
1563                 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1564                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1565                w.reviewer,
1566                r.reviewer,
1567                esc(&r.agent),
1568                body,
1569            );
1570        }
1571    }
1572
1573    let _ = writeln!(
1574        h,
1575        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1576        w.checks,
1577        esc(pr.checks.as_str())
1578    );
1579    if pr.failing.is_empty() {
1580        let _ = writeln!(
1581            h,
1582            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1583            w.nothing_failing
1584        );
1585    } else {
1586        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1587        for f in &pr.failing {
1588            let _ = writeln!(h, "<li>{}</li>", esc(f));
1589        }
1590        h.push_str("</ul>\n");
1591    }
1592
1593    // Diffstat as a real table, so a phone reads what moved without scrolling
1594    // sideways through a terminal bar chart.
1595    let _ = writeln!(
1596        h,
1597        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1598        rows.len(),
1599        w.files_changed
1600    );
1601    h.push_str(
1602        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1603         <thead><tr>\
1604         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1605         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1606         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1607         </th></tr></thead>\n<tbody>\n",
1608    );
1609    let mut total_added = 0u64;
1610    let mut total_removed = 0u64;
1611    for r in &rows {
1612        total_added += r.added.unwrap_or(0);
1613        total_removed += r.removed.unwrap_or(0);
1614        let cell = |n: Option<u64>| match n {
1615            Some(n) => n.to_string(),
1616            None => "bin".to_owned(),
1617        };
1618        let _ = writeln!(
1619            h,
1620            "<tr>\
1621             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1622             font-family:ui-monospace,monospace\">{}</td>\
1623             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1624             color:#0a3622\">{}</td>\
1625             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1626             color:#5c1a17\">{}</td></tr>",
1627            esc(&r.path),
1628            cell(r.added),
1629            cell(r.removed),
1630        );
1631    }
1632    let _ = writeln!(
1633        h,
1634        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1635         <td style=\"padding:4px 2px\">total</td>\
1636         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1637         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1638         </tr></tfoot>\n</table>"
1639    );
1640
1641    // The commits being squashed, and the subject that replaces them.
1642    let _ = writeln!(
1643        h,
1644        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1645        w.commits
1646    );
1647    if commits.is_empty() {
1648        h.push_str(&format!(
1649            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1650            w.no_commits
1651        ));
1652    } else {
1653        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1654        for c in commits {
1655            let _ = writeln!(h, "<li>{}</li>", esc(c));
1656        }
1657        h.push_str("</ol>\n");
1658    }
1659    let _ = writeln!(
1660        h,
1661        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1662        w.lands_as,
1663        esc(subject),
1664        w.lands_as_tail()
1665    );
1666
1667    // The review comments that shaped this branch, and who asked for them.
1668    let _ = writeln!(
1669        h,
1670        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1671        w.comments
1672    );
1673    if pr.review_comments.is_empty() {
1674        h.push_str(&format!(
1675            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1676            w.no_comments
1677        ));
1678    } else {
1679        for c in &pr.review_comments {
1680            let anchor = match (&c.path, c.line) {
1681                (Some(p), Some(l)) => format!("{p}:{l}"),
1682                (Some(p), None) => p.clone(),
1683                _ => "pull request thread".to_owned(),
1684            };
1685            let _ = writeln!(
1686                h,
1687                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1688                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1689                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1690                esc(&c.author),
1691                esc(&anchor),
1692                esc(&tail(&c.body, 800)),
1693            );
1694        }
1695    }
1696
1697    // The patch itself.
1698    let total = diff.lines().count();
1699    let shown = total.min(DIFF_MAX_LINES);
1700    let _ = writeln!(
1701        h,
1702        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1703        w.diff
1704    );
1705    h.push_str(
1706        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1707         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1708    );
1709    for line in diff.lines().take(shown) {
1710        let (gutter, style, body) = diff_row(line);
1711        let _ = writeln!(
1712            h,
1713            "<div style=\"display:flex;{style}\">\
1714             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1715             border-right:1px solid #d0d7de\">{gutter}</span>\
1716             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1717            esc(body),
1718        );
1719    }
1720    h.push_str("</div>\n");
1721    if total > shown {
1722        let omitted = total - shown;
1723        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1724        let where_ = state.winner().map_or_else(
1725            || state.repo.display().to_string(),
1726            |w| w.worktree.display().to_string(),
1727        );
1728        let _ = writeln!(
1729            h,
1730            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1731             font-size:13px\">{}: {}</p>",
1732            w.truncated,
1733            w.truncated_note(
1734                omitted,
1735                total,
1736                shown,
1737                &esc(&where_),
1738                &esc(&state.base_branch),
1739                &esc(head),
1740            ),
1741        );
1742    }
1743
1744    h.push_str("</body>\n</html>\n");
1745    h
1746}
1747
1748/// Historical contested hand-off evidence for the question detail. Approval
1749/// itself is decided by `approval_reasons`, including reject-only cases.
1750fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1751    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
1752        state.contested_handoff.clone()
1753    } else {
1754        None
1755    }
1756}
1757
1758/// What a merge-approval question's deputy is told: the pull request, the run,
1759/// what each answer does, and the evidence saved when the question was filed.
1760///
1761/// A snapshot taken when the deputy is attached, so it says so and points at
1762/// `magi show` / `gh pr view` for anything current. `state` is `None` for a run
1763/// that cannot be read; what is missing is named as missing, and no past
1764/// approval basis is rebuilt from today's state.
1765pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1766    let mut s = format!(
1767        "This is the merge approval for run {run} (`magi show {run}`). The question's \
1768         own text above names the pull request. Answering `{APPROVE}` squash-merges \
1769         it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1770         request open. Silence is a hold: the owner not answering never merges. Only \
1771         the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1772         own words, merges. Whether their wording is a clear, unconditional instruction \
1773         is your judgement alone: if it is doubtful, conditional, retracted or a \
1774         question, do not settle - ask back with `magi ask --thread`. Doubt and \
1775         silence are a hold.\n\n\
1776         This brief is a snapshot from when you were attached: check `magi show {run}` \
1777         and `gh pr view` (read-only) before telling the owner anything current. \
1778         You run with permission to write the question record, and what keeps you \
1779         from touching anything else is this brief and your instructions - so do \
1780         not change files, branches or the pull request.",
1781        run = q.run
1782    );
1783    if let Some(target) = &q.merge_target {
1784        s.push_str(&format!("\n\nThis decision names PR {} at head `{}`. Merge through chat requires the owner's latest whole message and quote to be exactly `merge {}`. Discussion alone never approves. For hold, repair, withdrawal or replacement, use the exact offered token from the owner's latest whole message. Withdrawal explicitly closes the PR and ends the task. Replacement closes the old PR before dispatching a new attempt with the selected implementer; do not start a separate task yourself.", target.pr, target.head, target.head));
1785    }
1786    if q.choices.iter().any(|c| c == REPAIR) {
1787        s.push_str("\n\n`fix findings and re-review` requests one repair and bounded re-review on the existing PR. It never approves a merge. Settle this choice only when the owner's whole latest message is exactly this label; otherwise discuss it with `--thread`. The driver performs the repair; do not edit or queue another task. Updated review and gate results require a fresh merge decision.");
1788    }
1789    if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1790    {
1791        s.push_str("\n\nSaved approval evidence (at question creation):\n");
1792        s.push_str(&q.detail);
1793    } else {
1794        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.");
1795    }
1796    let Some(state) = state else {
1797        s.push_str(
1798            "\n\nThe run's record could not be read, so the pull request, the panel \
1799             summary and any contested findings are not known to you beyond the \
1800             question's own text. Say so to the owner rather than guessing.",
1801        );
1802        return s;
1803    };
1804    if let Some(pr) = &state.pr {
1805        s.push_str(&format!(
1806            "\n\nPull request #{} {} (recorded state: {}, last seen).",
1807            pr.number, pr.url, pr.state
1808        ));
1809    }
1810    s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1811    if let Some(w) = state.winner() {
1812        s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1813    }
1814
1815    s
1816}
1817
1818// Releasing a task permits work to resume; it does not renew merge consent.
1819// Check the persisted operator decision both when reusing a card and at the
1820// final merge boundary, since a hold/release can arrive between those reads.
1821fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1822    if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1823        return Ok(false);
1824    }
1825    let Some(task_id) = state
1826        .origin
1827        .as_ref()
1828        .and_then(|origin| origin.task.as_ref())
1829    else {
1830        return Ok(false);
1831    };
1832    let task = crate::queue::Queue::open()
1833        .get(task_id)
1834        .context("cannot verify operator decisions for the merge approval")?;
1835    Ok(task
1836        .operator_decided_at
1837        .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1838}
1839
1840/// Ask the owner before merging, with the whole case attached as a panel.
1841///
1842/// The evidence is gathered from the winner's own worktree with the `git` CLI,
1843/// never from the network, so a phone on a slow link gets the diff magi is
1844/// looking at rather than a link it has to go and open.
1845///
1846/// Never blocks. `land` used to sit inside [`ask::ask_and_wait`]'s poll loop
1847/// for up to a day right here, which held the whole run's task claim - and
1848/// the daemon's one slot with it - for exactly as long as the owner took to
1849/// notice their phone. [`ApprovalGate::Pending`] is the answer that lets the
1850/// caller park the run and hand the slot back instead: the question is on
1851/// disk either way, so nothing about the wait itself changes, only who is
1852/// blocked on it.
1853///
1854/// Idempotent across resumes: called again for a run already waiting on its
1855/// own question, this finds that question by [`crate::ask::Questions::list`]
1856/// rather than filing a second one - asking twice would double the
1857/// notification for one decision, and leave the first question's panel an
1858/// orphan nobody's answer ever reaches.
1859async fn approval_gate(
1860    state: &mut RunState,
1861    pr: &PrState,
1862    subject: &str,
1863    contested: Option<&ContestedHandoff>,
1864    head: &str,
1865) -> Result<ApprovalGate> {
1866    let store = ask::Questions::open();
1867    // An answer belongs to the commit its panel showed. A question recorded
1868    // for another head - or none recorded at all, as for a question filed
1869    // before heads were tracked - is never reused: a fix or rebase push made
1870    // a commit the owner has not seen, and their word does not carry over.
1871    let reusable = state.land_approval.as_ref().and_then(|a| {
1872        store
1873            .list()
1874            .into_iter()
1875            .find(|q| q.id == a.question)
1876            .filter(|q| {
1877                q.status != ask::QuestionStatus::Abandoned
1878                    && (a.head.eq_ignore_ascii_case(head)
1879                        || q.resolution()
1880                            .as_deref()
1881                            .is_some_and(crate::merge_decision::stops))
1882            })
1883    });
1884    let reusable = match reusable {
1885        Some(q) if merge_consent_superseded(state, &q)? => None,
1886        other => other,
1887    };
1888    if reusable.is_none() {
1889        for stale in store
1890            .list()
1891            .into_iter()
1892            .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1893        {
1894            let why =
1895                "the pull request head or operator decision changed; asked again for approval";
1896            if let Err(e) = store.update(&stale.id, |q| {
1897                q.abandon(why);
1898                Ok(())
1899            }) {
1900                tracing::warn!("could not retire the superseded approval question: {e:#}");
1901            }
1902        }
1903    }
1904
1905    let q = match reusable {
1906        Some(mut q) => {
1907            if upgrade_repair_choice(&mut q, state) {
1908                q = store
1909                    .update(&q.id, |current| {
1910                        upgrade_repair_choice(current, state);
1911                        Ok(())
1912                    })?
1913                    .0;
1914            }
1915            q
1916        }
1917        None => {
1918            let worktree = match state.winner() {
1919                Some(w) => w.worktree.clone(),
1920                None => state.repo.clone(),
1921            };
1922            // The diff is built from the commit being approved, not from the
1923            // branch name, which may already point somewhere else.
1924            let head = if head.is_empty() {
1925                state
1926                    .winner()
1927                    .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1928            } else {
1929                head.to_owned()
1930            };
1931            // The diff is against what the remote's base holds, never the
1932            // local branch of that name; unreadable means less evidence.
1933            let remote = &state.config.merge.remote;
1934            let tracking = format!("{remote}/{}", state.base_branch);
1935            let base = if git::rev_exists(&worktree, &tracking).await {
1936                tracking
1937            } else {
1938                String::new()
1939            };
1940            let range = format!("{base}...{head}");
1941            // A failed `git` must not decide the merge: the panel degrades to
1942            // less evidence and the owner still chooses. Merging because the
1943            // diff could not be read would be the worst of both.
1944            let numstat = if base.is_empty() {
1945                String::new()
1946            } else {
1947                git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1948                    .await
1949                    .map(|o| o.stdout)
1950                    .unwrap_or_default()
1951            };
1952            let diff = if base.is_empty() {
1953                String::new()
1954            } else {
1955                git::diff(&worktree, &base, &head).await.unwrap_or_default()
1956            };
1957            let commits: Vec<String> = if base.is_empty() {
1958                Vec::new()
1959            } else {
1960                git::git_raw(
1961                    &worktree,
1962                    &[
1963                        "log",
1964                        "--reverse",
1965                        "--format=%s",
1966                        &format!("{base}..{head}"),
1967                    ],
1968                )
1969                .await
1970                .map(|o| o.stdout)
1971                .unwrap_or_default()
1972                .lines()
1973                .filter(|l| !l.trim().is_empty())
1974                .map(str::to_owned)
1975                .collect()
1976            };
1977
1978            let w = words(&state.config.graph.language);
1979            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1980            let mut fresh = ask::Question::new(
1981                state.id.clone(),
1982                APPROVAL_NODE.to_owned(),
1983                "land".to_owned(),
1984                w.approval_summary(pr.number, subject),
1985                format!(
1986                    "{}\n\n{}",
1987                    approval_evidence(state),
1988                    w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1989                ),
1990                approval_choices(state),
1991            );
1992            fresh.merge_target = Some(crate::merge_decision::Target {
1993                pr: pr.url.clone(),
1994                head: head.clone(),
1995                language: state.config.graph.language.clone(),
1996            });
1997            if fresh.choices.iter().any(|c| c == REPAIR) {
1998                fresh.actions.insert(
1999                    REPAIR.to_owned(),
2000                    ask::ChoiceAction::Resume {
2001                        run: state.id.clone(),
2002                    },
2003                );
2004                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.");
2005            }
2006            for choice in fresh.choices.clone() {
2007                if crate::merge_decision::stops(&choice)
2008                    && choice != HOLD
2009                    && choice != crate::merge_decision::DISMISS
2010                {
2011                    fresh.actions.insert(
2012                        choice,
2013                        ask::ChoiceAction::Resume {
2014                            run: state.id.clone(),
2015                        },
2016                    );
2017                }
2018            }
2019            store
2020                .put_panel(&mut fresh, &html, &[])
2021                .context("write the merge approval panel")?;
2022            store
2023                .put_executable(&mut fresh)
2024                .context("file the merge approval question")?;
2025            state.land_approval = Some(LandApproval {
2026                question: fresh.id.clone(),
2027                head: head.clone(),
2028            });
2029            state.event(
2030                "land",
2031                format!("asking for merge approval ({})", fresh.short()),
2032            );
2033            state.save()?;
2034            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
2035                // A broken webhook is not a reason to lose the merge: the
2036                // question is already on disk and the web UI already shows
2037                // it, so the operator still has a way in.
2038                tracing::warn!(
2039                    "could not notify about merge approval question {}: {e:#} - \
2040                     the web UI is the only surface for it now",
2041                    fresh.short()
2042                );
2043            }
2044            fresh
2045        }
2046    };
2047
2048    Ok(match q.status {
2049        ask::QuestionStatus::Open => ApprovalGate::Pending,
2050        // Nobody answered before `state.config.graph.answer_timeout` passed,
2051        // or the question was closed with no decision recorded underneath
2052        // this run - either way there is nothing left to wait on.
2053        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
2054        // The merge gate does not speak `--thread`: an owner who talked back
2055        // instead of choosing never reaches `Answered`, so this arm only
2056        // ever sees an actual decision.
2057        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
2058            Approval::Merge => ApprovalGate::Approved,
2059            Approval::Repair if !state.reviews.is_empty() => {
2060                if !state.merge_repairs.iter().any(|r| r.question == q.id) {
2061                    state.merge_repairs.push(crate::run::MergeRepair {
2062                        question: q.id.clone(),
2063                        head: head.to_owned(),
2064                        pr: pr.url.clone(),
2065                        reviews: state.reviews.clone(),
2066                        gate: state.gate.clone(),
2067                        gate_head: state.gate_head.clone(),
2068                        gate_fixes: state.gate_fixes.clone(),
2069                        pre_gate: state.pre_gate.clone(),
2070                        phase: crate::run::MergeRepairPhase::Requested,
2071                        failure: None,
2072                    });
2073                    state.land_approval = None;
2074                    state.event(
2075                        "repair",
2076                        "owner requested fix findings and re-review; merge is not approved",
2077                    );
2078                    state.save()?;
2079                }
2080                ApprovalGate::Repair
2081            }
2082            Approval::Repair | Approval::Hold => ApprovalGate::Held,
2083        },
2084    })
2085}
2086
2087/// Fold a rollup's entries into one verdict plus the failing checks' labels.
2088/// No I/O. An empty rollup is `Unknown`, never green.
2089fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
2090    let mut failing = Vec::new();
2091    let mut pending = false;
2092    let mut unknown = false;
2093    for check in rollup {
2094        match check.verdict() {
2095            Verdict::Pass => {}
2096            Verdict::Pending => pending = true,
2097            Verdict::Fail => failing.push(check.label()),
2098            Verdict::Unknown => unknown = true,
2099        }
2100    }
2101    let checks = if rollup.is_empty() {
2102        Checks::Unknown
2103    } else if pending {
2104        Checks::Pending
2105    } else if !failing.is_empty() {
2106        Checks::Red
2107    } else if unknown {
2108        Checks::Unknown
2109    } else {
2110        Checks::Green
2111    };
2112    (checks, failing)
2113}
2114
2115/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
2116/// output into a [`PrState`]. No I/O.
2117pub fn parse_pr(json: &str) -> Result<PrState> {
2118    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2119    let state = match raw.state.to_ascii_uppercase().as_str() {
2120        "OPEN" => PrLifecycle::Open,
2121        "MERGED" => PrLifecycle::Merged,
2122        "CLOSED" => PrLifecycle::Closed,
2123        other => bail!("unknown pull request state `{other}`"),
2124    };
2125
2126    let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2127
2128    let mut review_comments = Vec::new();
2129    for r in raw.reviews {
2130        push_if_outstanding(
2131            &mut review_comments,
2132            ReviewComment {
2133                author: r.author.login,
2134                path: None,
2135                line: None,
2136                body: r.body,
2137            },
2138        );
2139    }
2140    for c in raw.comments {
2141        push_if_outstanding(
2142            &mut review_comments,
2143            ReviewComment {
2144                author: c.author.login,
2145                path: None,
2146                line: None,
2147                body: c.body,
2148            },
2149        );
2150    }
2151
2152    Ok(PrState {
2153        url: raw.url,
2154        number: raw.number,
2155        state,
2156        checks,
2157        failing,
2158        review_comments,
2159        blocking: Blocking::of(&raw.merge_state_status),
2160    })
2161}
2162
2163/// One rollup entry as the release watcher reads it.
2164#[derive(Debug, Clone, PartialEq, Eq)]
2165pub(crate) struct CheckView {
2166    pub name: String,
2167    pub verdict: Verdict,
2168    /// Workflow run behind the check, when its url names one.
2169    pub run: Option<String>,
2170    pub url: Option<String>,
2171}
2172
2173/// A pull request's lifecycle, head commit and per-check verdicts, without
2174/// [`rollup_verdict`]'s aggregation (a pending check anywhere hides a failure
2175/// from it, which is exactly what the release watcher must not inherit).
2176#[derive(Debug, Clone, PartialEq, Eq)]
2177pub(crate) struct RollupView {
2178    pub url: String,
2179    pub number: u64,
2180    pub state: PrLifecycle,
2181    pub head: String,
2182    pub checks: Vec<CheckView>,
2183}
2184
2185/// Parse `gh pr view --json url,number,state,headRefOid,statusCheckRollup`
2186/// output. No I/O.
2187pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2188    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2189    let state = match raw.state.to_ascii_uppercase().as_str() {
2190        "OPEN" => PrLifecycle::Open,
2191        "MERGED" => PrLifecycle::Merged,
2192        "CLOSED" => PrLifecycle::Closed,
2193        other => bail!("unknown pull request state `{other}`"),
2194    };
2195    let checks = raw
2196        .status_check_rollup
2197        .iter()
2198        .map(|c| CheckView {
2199            name: c.label(),
2200            verdict: c.verdict(),
2201            run: c.url().and_then(run_of),
2202            url: c.url().map(str::to_owned),
2203        })
2204        .collect();
2205    Ok(RollupView {
2206        url: raw.url,
2207        number: raw.number,
2208        state,
2209        head: raw.head_ref_oid,
2210        checks,
2211    })
2212}
2213
2214/// Read just a pull request's lifecycle state - open, merged, or closed -
2215/// with none of the checks/reviews/comments [`land`] itself needs to decide
2216/// what to do next.
2217///
2218/// For a caller that only ever wants one fact and must not risk anything
2219/// else: `magi fold --merged` uses this to confirm a URL the operator hands
2220/// it is actually a merged pull request *before* touching a run's state, so a
2221/// typo or a still-open PR fails loudly instead of quietly recording a merge
2222/// that never happened.
2223pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2224    let view = gh(
2225        repo,
2226        &[
2227            "pr".to_owned(),
2228            "view".to_owned(),
2229            pr_url.to_owned(),
2230            "--json".to_owned(),
2231            "state".to_owned(),
2232        ],
2233    )
2234    .await?;
2235    if !view.0 {
2236        bail!("gh pr view {pr_url}: {}", view.1);
2237    }
2238    // `parse_pr` reads every other field of `GhPr` as its serde default
2239    // (empty string, empty vec, zero) when this narrower `--json` selection
2240    // does not carry them - harmless, since only `.state` is read back.
2241    Ok(parse_pr(&view.1)?.state)
2242}
2243
2244/// A pull request the operator merged outside of `land::land`'s own loop,
2245/// found by asking GitHub about the run's own winning branch rather than
2246/// requiring the operator to go and find the URL themselves.
2247#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2248pub struct ExternalMerge {
2249    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
2250    pub url: String,
2251    /// The pull request's number.
2252    pub number: u64,
2253}
2254
2255#[derive(Debug, Deserialize)]
2256#[serde(rename_all = "camelCase")]
2257struct GhMergedPr {
2258    url: String,
2259    number: u64,
2260    merged_at: String,
2261    base_ref_name: String,
2262}
2263
2264/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
2265/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
2266/// decide whether exactly one of the pull requests it lists could actually
2267/// be *this* run's.
2268///
2269/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
2270/// from the run's own short id, so a collision with some other, unrelated
2271/// task's merged pull request from a same-named branch is rare but not
2272/// impossible once branches are deleted and ids run out. Filtering on
2273/// `base_ref_name` (the branch this run actually targets) and `merged_at`
2274/// (which cannot predate the run itself) rules that case out. More than one
2275/// survivor is exactly as uninformative as zero — something this run cannot
2276/// tell apart from another — so only a unique survivor is returned.
2277fn pick_merged_pr(
2278    json: &str,
2279    base_branch: &str,
2280    created_at: Timestamp,
2281) -> Result<Option<ExternalMerge>> {
2282    let raw: Vec<GhMergedPr> =
2283        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2284    let mut matches: Vec<ExternalMerge> = Vec::new();
2285    for pr in raw {
2286        if pr.base_ref_name != base_branch {
2287            continue;
2288        }
2289        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2290            continue;
2291        };
2292        if merged_at < created_at {
2293            continue;
2294        }
2295        matches.push(ExternalMerge {
2296            url: pr.url,
2297            number: pr.number,
2298        });
2299    }
2300    if matches.len() == 1 {
2301        Ok(matches.pop())
2302    } else {
2303        Ok(None)
2304    }
2305}
2306
2307/// What `gh pr list --head <branch> --base <base> --state open` found.
2308#[derive(Debug, Clone, PartialEq, Eq)]
2309pub enum OpenPr {
2310    /// Nothing open: the caller creates one.
2311    None,
2312    /// Exactly one: the caller adopts it instead of creating a second.
2313    One {
2314        /// The pull request's URL.
2315        url: String,
2316        /// Its current title.
2317        title: String,
2318    },
2319    /// More than one: magi does not pick between them.
2320    Many(Vec<String>),
2321}
2322
2323#[derive(Debug, Deserialize)]
2324#[serde(rename_all = "camelCase")]
2325struct GhOpenPr {
2326    // `url` and `baseRefName` are required: a record missing either must be a
2327    // parse error, not a pull request that silently fails the base filter and
2328    // reads as "none open" (which would go on to create a duplicate).
2329    url: String,
2330    #[serde(default)]
2331    title: String,
2332    base_ref_name: String,
2333    // A fork's branch of the same name is not the task head; `gh pr list --head`
2334    // matches by branch name alone, so the filter has to be ours.
2335    #[serde(default)]
2336    is_cross_repository: bool,
2337}
2338
2339/// Pure half of [`find_open_pr`]: classify the raw `--json
2340/// number,url,title,baseRefName` output. Entries whose base is not `base` are
2341/// dropped even though the query already filtered on it, so a stub or an old
2342/// `gh` that ignores `--base` cannot get a pull request into the wrong branch
2343/// adopted.
2344pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2345    let raw: Vec<GhOpenPr> =
2346        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2347    let mut hits: Vec<GhOpenPr> = raw
2348        .into_iter()
2349        .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2350        .collect();
2351    Ok(match hits.len() {
2352        0 => OpenPr::None,
2353        1 => {
2354            let p = hits.remove(0);
2355            OpenPr::One {
2356                url: p.url,
2357                title: p.title,
2358            }
2359        }
2360        _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2361    })
2362}
2363
2364/// Open pull requests whose head is `branch` and whose base is `base`. A
2365/// failing `gh` is an error carrying its own output, never "none": guessing
2366/// there is how a duplicate gets created.
2367pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2368    let (ok, out) = gh(
2369        repo,
2370        &[
2371            "pr".to_owned(),
2372            "list".to_owned(),
2373            "--head".to_owned(),
2374            branch.to_owned(),
2375            "--base".to_owned(),
2376            base.to_owned(),
2377            "--state".to_owned(),
2378            "open".to_owned(),
2379            "--json".to_owned(),
2380            "number,url,title,baseRefName,isCrossRepository".to_owned(),
2381        ],
2382    )
2383    .await?;
2384    if !ok {
2385        bail!("gh pr list failed: {out}");
2386    }
2387    pick_open_pr(&out, base)
2388}
2389
2390#[derive(Debug, Deserialize)]
2391#[serde(rename_all = "camelCase")]
2392struct GhPrHead {
2393    head_ref_name: String,
2394    base_ref_name: String,
2395    state: String,
2396    // Required, like `GhOpenPr`'s fields: a record that cannot say whether the
2397    // head lives in a fork must be a parse error, never "same repository".
2398    is_cross_repository: bool,
2399    // Required too: it is what ties the pull request to the commits that were
2400    // actually proven to be on the base.
2401    head_ref_oid: String,
2402}
2403
2404/// Why [`closable`] said no, and whether asking again later could say yes.
2405#[derive(Debug, Clone, PartialEq, Eq)]
2406pub struct Refusal {
2407    /// A later attempt may succeed (the head moved, the view was unreadable);
2408    /// `false` means this pull request is simply not the run's to close.
2409    pub retry: bool,
2410    /// What was wrong.
2411    pub why: String,
2412}
2413
2414impl Refusal {
2415    fn final_(why: String) -> Self {
2416        Self { retry: false, why }
2417    }
2418}
2419
2420/// Pure half of [`close_superseded_pr`]: read `gh pr view --json
2421/// headRefName,baseRefName,state,isCrossRepository` and say whether closing
2422/// is safe, `Err` carrying the reason when it is not.
2423///
2424/// Closing is outward-facing, so every property is checked on what the forge
2425/// says now, not on what magi recorded: the head must be exactly `branch` in
2426/// this repository (a fork's branch of the same name is somebody else's), the
2427/// base must be `base`, and the pull request must still be open.
2428pub fn closable(
2429    json: &str,
2430    branch: &str,
2431    base: &str,
2432    verified: &[String],
2433) -> std::result::Result<(), Refusal> {
2434    let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2435        retry: true,
2436        why: format!("could not read the pull request ({e})"),
2437    })?;
2438    if pr.head_ref_name != branch {
2439        return Err(Refusal::final_(format!(
2440            "its head is `{}`, not this run's `{branch}`",
2441            pr.head_ref_name
2442        )));
2443    }
2444    if pr.is_cross_repository {
2445        return Err(Refusal::final_("its head lives in a fork".to_owned()));
2446    }
2447    if pr.base_ref_name != base {
2448        return Err(Refusal::final_(format!(
2449            "it targets `{}`, not `{base}`",
2450            pr.base_ref_name
2451        )));
2452    }
2453    if !pr.state.eq_ignore_ascii_case("open") {
2454        return Err(Refusal::final_(format!(
2455            "it is already {}",
2456            pr.state.to_ascii_lowercase()
2457        )));
2458    }
2459    // Last, so a pull request that is not this run's at all is reported as
2460    // such. A head that is this branch but not a commit checked against the
2461    // base (somebody pushed since) may well get checked next time: retry.
2462    if !verified.contains(&pr.head_ref_oid) {
2463        return Err(Refusal {
2464            retry: true,
2465            why: format!(
2466                "its head {} is not a commit this run checked against the base",
2467                crate::already::short_sha(&pr.head_ref_oid)
2468            ),
2469        });
2470    }
2471    Ok(())
2472}
2473
2474/// Does `remote` point at something a forge could host - a URL or an scp-style
2475/// `user@host:path` - rather than a filesystem path or nothing at all? Judged
2476/// from the URL alone, so it answers the same on every machine, whatever `gh`
2477/// happens to be installed or logged in to.
2478async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2479    let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2480        return false;
2481    };
2482    is_forge_url(url.trim())
2483}
2484
2485fn is_forge_url(url: &str) -> bool {
2486    url.contains("://") && !url.starts_with("file://")
2487        || url
2488            .split_once(':')
2489            .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2490}
2491
2492/// Does this `gh` failure mean there is no GitHub to ask, as opposed to a
2493/// request that failed?
2494fn forge_unavailable(message: &str) -> bool {
2495    message.contains("known GitHub host") || message.contains("spawn gh")
2496}
2497
2498/// The comment left on a pull request closed because its change is already on
2499/// the base.
2500pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2501    let how = match evidence.proof {
2502        crate::already::Proof::PatchId => format!(
2503            "carried by commit {} on `{base}` with the same patch",
2504            evidence.names()
2505        ),
2506        crate::already::Proof::Ancestry => {
2507            format!("already in the history of `{base}` as {}", evidence.names())
2508        }
2509        crate::already::Proof::Tree => format!(
2510            "already part of `{base}` (merging this branch changes nothing at {})",
2511            crate::already::short_sha(&evidence.tip)
2512        ),
2513    };
2514    format!(
2515        "Closing: everything this branch adds is {how}, so there is nothing left to \
2516         land. This pull request was closed automatically after that was verified; \
2517         reopen it if you disagree."
2518    )
2519}
2520
2521/// Close the open pull request for `branch`, if there is one and it is
2522/// provably this run's, with a comment naming what supersedes it. `Ok(Ok(url))` is
2523/// the URL closed; `Ok(Err(why))` is a final "nothing to close" (no pull request,
2524/// not this run's, no forge); `Err` is anything a later attempt could resolve (a
2525/// failed `gh` call, a head that moved since it was checked), which the caller
2526/// must not treat as settled.
2527///
2528/// The recorded `state.pr` is preferred, else the forge is asked for an open
2529/// pull request on `branch`; either way the candidate is re-read and passed
2530/// through [`closable`] before anything is written; `verified` lists the
2531/// commits proven to be on the base, and the pull request's head must be one.
2532pub async fn close_superseded_pr(
2533    state: &mut RunState,
2534    branch: &str,
2535    evidence: &crate::already::Evidence,
2536    verified: &[String],
2537) -> Result<std::result::Result<String, String>> {
2538    let repo = state.repo.clone();
2539    let base = state.base_branch.clone();
2540    let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2541        Some(p) => p.url.clone(),
2542        // No recorded pull request. A forge that cannot be asked at all (no
2543        // GitHub remote, no `gh`) says nothing about this run, so that is
2544        // "none found"; any other lookup failure is an error, because "could
2545        // not look" is not "nothing there" and the caller must retry.
2546        None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2547            return Ok(Err(
2548                "the remote is not a forge, so there is no pull request".to_owned(),
2549            ));
2550        }
2551        None => match find_open_pr(&repo, branch, &base).await {
2552            Err(e) if forge_unavailable(&format!("{e:#}")) => {
2553                return Ok(Err(format!("no forge to ask: {e:#}")));
2554            }
2555            Err(e) => return Err(e),
2556            Ok(OpenPr::One { url, .. }) => url,
2557            Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2558            Ok(OpenPr::Many(urls)) => {
2559                return Ok(Err(format!(
2560                    "{} open pull requests name it; not choosing between them",
2561                    urls.len()
2562                )));
2563            }
2564        },
2565    };
2566    let (ok, view) = gh(
2567        &repo,
2568        &[
2569            "pr".to_owned(),
2570            "view".to_owned(),
2571            url.clone(),
2572            "--json".to_owned(),
2573            "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2574        ],
2575    )
2576    .await?;
2577    if !ok {
2578        bail!("gh pr view {url} failed: {view}");
2579    }
2580    if let Err(refusal) = closable(&view, branch, &base, verified) {
2581        // A pull request whose head cannot be tied to what was proven is not
2582        // one to walk away from: the caller keeps the run resumable.
2583        if refusal.retry {
2584            bail!("left {url} open: {}", refusal.why);
2585        }
2586        return Ok(Err(format!("left {url} open: {}", refusal.why)));
2587    }
2588    let (ok, out) = gh(
2589        &repo,
2590        &[
2591            "pr".to_owned(),
2592            "close".to_owned(),
2593            url.clone(),
2594            "--comment".to_owned(),
2595            superseded_comment(&base, evidence),
2596        ],
2597    )
2598    .await?;
2599    if !ok {
2600        bail!("gh pr close {url} failed: {out}");
2601    }
2602    if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2603        p.state = "closed".to_owned();
2604    }
2605    Ok(Ok(url))
2606}
2607
2608/// `gh pr edit <url> --title <title>`, for an adopted pull request whose title
2609/// differs from the one this run computed. Only the title: the body may have
2610/// been edited by the owner and cannot be compared.
2611pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2612    let (title, _) = crate::github_text::prepare(state, title, "");
2613    edit_pr_title(repo, url, &title).await
2614}
2615
2616/// [`set_pr_title`] for a title the owner explicitly chose to post unmodified
2617/// (`github_text::POST_ORIGINAL` or `github_text::USE_REWRITE`): the posting
2618/// gate is skipped on purpose.
2619pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2620    edit_pr_title(repo, url, title).await
2621}
2622
2623/// Post both fields of the owner's chosen rewrite to an adopted pull request,
2624/// unmodified and without the posting gate.
2625pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2626    let (ok, _) = gh(
2627        repo,
2628        &[
2629            "pr".to_owned(),
2630            "edit".to_owned(),
2631            url.to_owned(),
2632            "--title".to_owned(),
2633            title.to_owned(),
2634            "--body".to_owned(),
2635            body.to_owned(),
2636        ],
2637    )
2638    .await
2639    .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2640    if !ok {
2641        // Command output may echo the chosen text; keep it out of run events.
2642        bail!("gh pr edit failed while posting the chosen rewrite");
2643    }
2644    Ok(())
2645}
2646
2647async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2648    let (ok, out) = gh(
2649        repo,
2650        &[
2651            "pr".to_owned(),
2652            "edit".to_owned(),
2653            url.to_owned(),
2654            "--title".to_owned(),
2655            title.to_owned(),
2656        ],
2657    )
2658    .await?;
2659    if !ok {
2660        bail!("gh pr edit failed: {out}");
2661    }
2662    Ok(())
2663}
2664
2665/// Ask GitHub whether this run's winning candidate branch was actually merged
2666/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2667/// --merged` exists to close, minus the operator having to find the URL by
2668/// hand.
2669///
2670/// `Ok(None)` covers every case where nothing can be said with confidence: no
2671/// winner decided yet (nothing to check a branch for), no merged pull request
2672/// found, or [`pick_merged_pr`] found more than one candidate and would not
2673/// guess between them. Never wired to a weaker, URL-less signal like
2674/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2675/// separately, precisely because it cannot drive an automatic correction on
2676/// its own (see that function's own doc).
2677pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2678    let Some(winner) = state.winner() else {
2679        return Ok(None);
2680    };
2681    let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2682    let out = gh(
2683        &state.repo,
2684        &[
2685            "pr".to_owned(),
2686            "list".to_owned(),
2687            "--head".to_owned(),
2688            branch.clone(),
2689            "--state".to_owned(),
2690            "merged".to_owned(),
2691            "--json".to_owned(),
2692            "url,number,mergedAt,baseRefName".to_owned(),
2693        ],
2694    )
2695    .await?;
2696    if !out.0 {
2697        bail!("gh pr list --head {branch}: {}", out.1);
2698    }
2699    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2700}
2701
2702/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2703/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2704///
2705/// Deliberately never consulted by [`find_external_merge`]: a base branch
2706/// that has moved since the run started can make an old, abandoned branch
2707/// look like an ancestor of the *current* base for reasons that have nothing
2708/// to do with a merge (a later commit that happens to supersede it, an
2709/// unrelated squash), and there is no pull request URL here to confirm
2710/// against or to land through anyway. Its only honest use is a weaker
2711/// notice — "this looks merged, go check" — never an automatic rewrite of
2712/// `status`/`merge`.
2713pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2714    let out = tokio::process::Command::new("git")
2715        .args(["merge-base", "--is-ancestor", branch, base_branch])
2716        .current_dir(repo)
2717        .quiet()
2718        .stdin(std::process::Stdio::null())
2719        .output()
2720        .await
2721        .context("spawn git merge-base --is-ancestor")?;
2722    Ok(out.status.success())
2723}
2724
2725/// Parse `host/owner/repo` out of a forge URL, with no network access.
2726///
2727/// The host is part of the slug, not discarded: `owner/repo` alone would
2728/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2729/// repository, which is exactly the mix-up the same-repo guard exists to
2730/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2731/// shape at all.
2732fn forge_slug(url: &str) -> Option<(String, &str)> {
2733    let rest = url.rsplit("://").next()?;
2734    let (host, path) = rest.split_once('/')?;
2735    if host.is_empty() {
2736        return None;
2737    }
2738    Some((host.to_ascii_lowercase(), path))
2739}
2740
2741/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2742/// access - the first half of the same-repo guard [`correct_manual_merge`]
2743/// applies before it writes anything.
2744///
2745/// Returns `None` for anything that does not look like
2746/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2747/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2748pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2749    let (host, path) = forge_slug(url)?;
2750    let mut segments = path.split('/');
2751    let owner = segments.next()?;
2752    let repo = segments.next()?;
2753    let kind = segments.next()?;
2754    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2755        return None;
2756    }
2757    Some(format!("{host}/{owner}/{repo}"))
2758}
2759
2760/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2761/// suffix), the shape `gh repo view --json url` returns - the other half of
2762/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2763fn slug_of_repo_url(url: &str) -> Option<String> {
2764    let (host, path) = forge_slug(url)?;
2765    let mut segments = path.split('/');
2766    let owner = segments.next()?;
2767    let repo = segments.next()?;
2768    if owner.is_empty() || repo.is_empty() {
2769        return None;
2770    }
2771    Some(format!("{host}/{owner}/{repo}"))
2772}
2773
2774/// Refuse to correct a run against a pull request from a different
2775/// repository than the one it is recorded against.
2776///
2777/// This is the guard the shun/8c75 incident argued for: an operator ran
2778/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2779/// in a different repository, omitted the run id, and the id defaulted to
2780/// this machine's most recently created run - an unrelated, still-in-progress
2781/// run in a completely different repository - which then had its `status`
2782/// rewritten to `merged` from a pull request it had nothing to do with.
2783/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2784/// CLI help), but a mistyped or stale id could still name a run in a
2785/// different repository than the one the URL belongs to, so this checks that
2786/// independently rather than trusting the id alone.
2787///
2788/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2789/// mismatch names both slugs rather than just refusing, so an operator whose
2790/// local checkout's `origin` is a fork of the repository the pull request was
2791/// opened against (a legitimate setup this cannot tell apart from a genuine
2792/// mix-up) can judge for themselves rather than being blocked with no way to
2793/// see why.
2794pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2795    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2796        return Ok(());
2797    }
2798    bail!(
2799        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2800         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2801         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2802         verify by hand before treating this as a false positive)"
2803    );
2804}
2805
2806/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2807/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2808///
2809/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2810/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2811/// repository the same way GitHub itself would recognize it, so the
2812/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2813/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2814/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2815/// a same-named one on a GitHub Enterprise host.
2816async fn repo_slug(repo: &Path) -> Result<String> {
2817    let out = gh(
2818        repo,
2819        &[
2820            "repo".to_owned(),
2821            "view".to_owned(),
2822            "--json".to_owned(),
2823            "url".to_owned(),
2824        ],
2825    )
2826    .await?;
2827    if !out.0 {
2828        bail!("gh repo view --json url: {}", out.1);
2829    }
2830    #[derive(Debug, Deserialize)]
2831    struct GhRepo {
2832        url: String,
2833    }
2834    let parsed: GhRepo = serde_json::from_str(&out.1)
2835        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2836    slug_of_repo_url(&parsed.url)
2837        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2838}
2839
2840/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2841/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2842/// would have written them had magi opened and merged this pull request
2843/// itself.
2844///
2845/// This is `magi fold --merged`'s whole implementation, and also what the
2846/// web `fold-merged` route calls once it has a URL in hand — an operator
2847/// recovery path for a merge magi could not finish on its own: a PR title too
2848/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2849/// closed by hand with a pull request magi never opened and so never
2850/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2851/// directly keeps this one authoritative: a merged pull request decides
2852/// `Step::Done { merged: true }` on the very first read, before any of
2853/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2854/// safe to call here even though this pull request was never magi's own.
2855///
2856/// [`ensure_same_repo`] is checked before anything else: a pull request from
2857/// a different repository than the one `state` is recorded against is
2858/// refused outright, regardless of its lifecycle. This is the guard for a
2859/// URL an *operator* hands in - the CLI or the web route - where a stale or
2860/// mistyped run id could otherwise get corrected from an unrelated
2861/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2862/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2863/// goes through [`correct_confirmed_external_merge`] instead, which skips
2864/// this check: its `url` was never operator-supplied, it comes from
2865/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2866/// it is already guaranteed to name a pull request in that same repository -
2867/// re-deriving and re-checking the repository here would only be a second
2868/// `gh repo view` call that can fail for reasons that have nothing to do with
2869/// correctness (a rate limit, a network blip), turning a self-heal that would
2870/// otherwise have succeeded into a run left `Blocked` for another pass.
2871///
2872/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2873/// URL fails loudly without writing anything, rather than handing an open
2874/// pull request to the full autonomous loop by accident.
2875///
2876/// Correcting `status` this way does not run `bump::after_merge`
2877/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2878/// which this path never goes through. A release version bump the change
2879/// might have earned is therefore not filed automatically and has to be
2880/// requested by hand - recorded as an event on the run so the gap is visible
2881/// to whoever reads it later, not just wherever this was called from. Follow-up
2882/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2883///
2884/// Returns the status before and after, so every caller (CLI, janitor, web
2885/// route) can build its own log line or response from the same pair rather
2886/// than each re-deriving it.
2887pub async fn correct_manual_merge(
2888    state: &mut RunState,
2889    url: &str,
2890) -> Result<(RunStatus, RunStatus)> {
2891    let Some(pr_slug) = slug_of_pr_url(url) else {
2892        bail!(
2893            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2894             this pull request belongs to"
2895        );
2896    };
2897    let run_slug = repo_slug(&state.repo).await?;
2898    ensure_same_repo(&run_slug, &pr_slug)?;
2899    correct_merge(state, url).await
2900}
2901
2902/// The janitor's own entry point into the same correction
2903/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2904/// same-repo guard - see that function's own doc for why skipping it here is
2905/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2906/// calls this with a `url` [`find_external_merge`] already found by querying
2907/// `state.repo`'s own remote, so the guard could never do anything here but
2908/// fail on its own transient errors.
2909///
2910/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2911/// because it lives in a different module.
2912pub(crate) async fn correct_confirmed_external_merge(
2913    state: &mut RunState,
2914    url: &str,
2915) -> Result<(RunStatus, RunStatus)> {
2916    correct_merge(state, url).await
2917}
2918
2919/// Same pull request, same repository: the url, or the number within one repo.
2920fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2921    let Some(pr) = a.pr.as_ref() else {
2922        return false;
2923    };
2924    if !url.is_empty()
2925        && pr
2926            .url
2927            .trim_end_matches('/')
2928            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2929    {
2930        return true;
2931    }
2932    number > 0
2933        && pr.number == number
2934        && match (a.repo.canonicalize(), repo.canonicalize()) {
2935            (Ok(x), Ok(y)) => x == y,
2936            _ => a.repo == repo,
2937        }
2938}
2939
2940/// Rewrite `pr.state` to a final state on every run record under `home` that
2941/// `decide` picks, and report how many were rewritten.
2942///
2943/// Lifecycle writes through to other runs are deliberately narrow (task-head
2944/// ownership is recorded separately): only a **terminal** run (never one a
2945/// driver may still be writing), only a proven-stopped driver with no daemon
2946/// claim (the shared liveness judge), only a record
2947/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2948/// The record is read as folding reads it (no schema check: every bump so far
2949/// only added fields, and the whole struct round-trips) and written through
2950/// [`RunState::save_under`], the path every record uses, so nothing but
2951/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2952/// A run that cannot be read or written is skipped with a warning.
2953fn rewrite_open_prs(
2954    home: &Path,
2955    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2956) -> usize {
2957    let now = Timestamp::now();
2958    let mut changed = 0;
2959    for id in crate::run::list_ids_in(&home.join("runs")) {
2960        let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2961            continue;
2962        };
2963        let path = home.join("runs").join(&id).join("run.json");
2964        let Ok(body) = std::fs::read_to_string(&path) else {
2965            continue;
2966        };
2967        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2968            continue;
2969        };
2970        if !state.status.done()
2971            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2972            || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2973                != crate::run::Liveness::Dead
2974        {
2975            continue;
2976        }
2977        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2978            continue;
2979        };
2980        if let Some(pr) = state.pr.as_mut() {
2981            pr.state = to.as_str().to_owned();
2982        }
2983        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2984        state.event(
2985            "land",
2986            format!("recorded {url} as {}: another run settled it", to.as_str()),
2987        );
2988        match state.save_under(home) {
2989            Ok(()) => changed += 1,
2990            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2991        }
2992    }
2993    changed
2994}
2995
2996/// A run reached a final state for its pull request: tell every other terminal
2997/// run in the same repository that names the same pull request (handed-over
2998/// predecessors, blocked attempts, anything), so their records stop saying
2999/// `open`. Best effort; `run`'s own record is the caller's.
3000pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
3001    if to == PrLifecycle::Open {
3002        return;
3003    }
3004    let Some(home) = crate::run::try_home() else {
3005        return;
3006    };
3007    write_pr_state_through_in(&home, run, to);
3008}
3009
3010pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
3011    let Some(pr) = run.pr.as_ref() else {
3012        return 0;
3013    };
3014    let (url, number) = (pr.url.clone(), pr.number);
3015    rewrite_open_prs(home, &mut |other| {
3016        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
3017    })
3018}
3019
3020/// Terminal runs whose record still says their pull request is open, as
3021/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
3022pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
3023    let now = Timestamp::now();
3024    let mut out = Vec::new();
3025    for id in crate::run::list_ids_in(&home.join("runs")) {
3026        let path = home.join("runs").join(&id).join("run.json");
3027        let Ok(body) = std::fs::read_to_string(&path) else {
3028            continue;
3029        };
3030        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
3031            continue;
3032        };
3033        if let Some(pr) = state.pr.as_ref()
3034            && state.status.done()
3035            && pr.state == "open"
3036            && !pr.url.is_empty()
3037            && !crate::daemon::is_working_on(home, &id, now)
3038        {
3039            out.push((id, state.repo.clone(), pr.url.clone()));
3040        }
3041    }
3042    out
3043}
3044
3045/// Apply forge answers (`pr url -> state`) to every stale terminal record.
3046/// A url with no answer (the forge was unreadable) changes nothing.
3047pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
3048    rewrite_open_prs(home, &mut |s| {
3049        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
3050    })
3051}
3052
3053/// One-time (and idempotent) repair of records that froze an `open` pull
3054/// request: ask the forge about each distinct pull request that a terminal run
3055/// still calls open - at most `max_lookups` of them - and rewrite the merged
3056/// and closed ones. A genuinely open pull request is left alone, and a lookup
3057/// that fails is "unknown, change nothing". Returns `(records rewritten,
3058/// lookups that failed)`.
3059pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
3060    let mut known = BTreeMap::new();
3061    let mut failed = 0;
3062    let mut seen = BTreeSet::new();
3063    for (_, repo, url) in stale_open_prs(home) {
3064        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
3065            continue;
3066        }
3067        match lifecycle(&repo, &url).await {
3068            Ok(state) => {
3069                known.insert(url, state);
3070            }
3071            Err(e) => {
3072                tracing::warn!("repair pr state of {url}: {e:#}");
3073                failed += 1;
3074            }
3075        }
3076    }
3077    (apply_pr_states(home, &known), failed)
3078}
3079
3080async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
3081    match lifecycle(&state.repo, url).await? {
3082        PrLifecycle::Merged => {}
3083        other => bail!(
3084            "{url} is {}, not merged; refusing to record {} as merged on a guess",
3085            other.as_str(),
3086            state.id
3087        ),
3088    }
3089    let before = state.status;
3090    if let Err(e) = land(state, url).await {
3091        // `land` sets `status` to `Landing` and saves before its first read
3092        // of the pull request — see its own doc — so a failure here (a
3093        // transient `gh` hiccup between the two forge reads this function
3094        // makes) can leave the run stuck on that in-between value with
3095        // nothing left driving it. Land it on the same terminal shape an
3096        // automated `land` failure lands on instead of leaving it stuck.
3097        state.status = RunStatus::Blocked;
3098        state.event("fold", format!("manual-merge correction failed: {e:#}"));
3099        state.save()?;
3100        return Err(e).context(format!("confirming the merge of {url}"));
3101    }
3102    state.event(
3103        "fold",
3104        "operator recorded this pull request as a manual merge; this run never \
3105         re-entered `land`, so `bump::after_merge` did not run for it - a release \
3106         bump this change might warrant has to be filed by hand",
3107    );
3108    // Unlike the bump, the findings the merge left open are filed on this
3109    // path too: nothing else would ever carry them forward.
3110    if state.status == RunStatus::Merged {
3111        crate::followup::after_merge(state, url).await;
3112    }
3113    state.save()?;
3114    Ok((before, state.status))
3115}
3116
3117/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
3118/// comments. No I/O.
3119///
3120/// `gh pr view` does not surface inline comments, and inline is exactly where
3121/// both review bots put their findings - a landing loop that read only the
3122/// top-level thread would never see the thing it is supposed to fix.
3123pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3124    let raw: Vec<GhInline> =
3125        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3126    let mut out = Vec::new();
3127    for c in raw {
3128        push_if_outstanding(
3129            &mut out,
3130            ReviewComment {
3131                author: c.user.login,
3132                path: c.path,
3133                line: c.line,
3134                body: c.body,
3135            },
3136        );
3137    }
3138    Ok(out)
3139}
3140
3141/// Keep a comment only when it asks for something.
3142///
3143/// An inline comment always does: it names a file and a line. A top-level
3144/// comment is dropped when it is empty, when it is magi's own, or when it is
3145/// [noise](is_noise).
3146fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3147    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3148        return;
3149    }
3150    if comment.path.is_none() && is_noise(&comment.body) {
3151        return;
3152    }
3153    out.push(comment);
3154}
3155
3156/// Is this comment body machinery rather than a finding?
3157///
3158/// Two tests, both structural, because guessing from prose is how a "looks
3159/// good to me" turns into a fix round:
3160///
3161/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
3162///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
3163///    footer.
3164/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
3165///    horizontal rules, and the bot's own status banner are removed, every
3166///    remaining line is a task-list item. That is exactly the shape of the
3167///    comment the Claude review job posts while it is still working.
3168///
3169/// Anything else is input, including bot prose. A bot that writes a paragraph
3170/// has said something, and the fix prompt tells the fixer it may decline a
3171/// comment with an argument - a wasted sentence in a prompt is cheaper than a
3172/// missed finding.
3173pub fn is_noise(body: &str) -> bool {
3174    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3175        return true;
3176    }
3177    let mut content = false;
3178    for line in strip_blocks(body).lines() {
3179        let line = unquote(line);
3180        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3181            continue;
3182        }
3183        content = true;
3184        break;
3185    }
3186    !content
3187}
3188
3189/// Remove HTML comments and collapsed `<details>` blocks.
3190fn strip_blocks(body: &str) -> String {
3191    let mut out = String::with_capacity(body.len());
3192    let mut rest = body;
3193    loop {
3194        let open = ["<!--", "<details>"]
3195            .iter()
3196            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3197            .min_by_key(|(i, _)| *i);
3198        let Some((at, tag)) = open else {
3199            out.push_str(rest);
3200            return out;
3201        };
3202        out.push_str(&rest[..at]);
3203        let after = &rest[at + tag.len()..];
3204        let close = if tag == "<!--" { "-->" } else { "</details>" };
3205        match after.find(close) {
3206            Some(end) => rest = &after[end + close.len()..],
3207            // Unterminated: the rest of the body is inside the block.
3208            None => return out,
3209        }
3210    }
3211}
3212
3213/// Strip blockquote markers, which both bots wrap their callouts in.
3214fn unquote(line: &str) -> &str {
3215    let mut s = line.trim();
3216    while let Some(rest) = s.strip_prefix('>') {
3217        s = rest.trim_start();
3218    }
3219    s.trim()
3220}
3221
3222/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
3223fn is_checklist(line: &str) -> bool {
3224    let rest = line
3225        .strip_prefix("- ")
3226        .or_else(|| line.strip_prefix("* "))
3227        .unwrap_or("");
3228    let rest = rest.trim_start();
3229    matches!(
3230        rest.get(..3),
3231        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3232    )
3233}
3234
3235/// A heading, a horizontal rule, or a callout tag - shape, never content.
3236fn is_decoration(line: &str) -> bool {
3237    line.starts_with('#')
3238        || line.starts_with("[!")
3239        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3240}
3241
3242/// A line that is nothing but emphasis and links.
3243///
3244/// Both review jobs open with a status banner
3245/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
3246/// to a line-based test and asks for nothing, so it is measured the same way a
3247/// heading is: strip the markup, and if no word survives, it was decoration.
3248fn is_banner(line: &str) -> bool {
3249    let plain = drop_spans(line, "**", "**");
3250    let plain = if plain.contains("](") {
3251        drop_spans(&plain, "[", ")")
3252    } else {
3253        plain
3254    };
3255    !plain.chars().any(char::is_alphanumeric)
3256}
3257
3258/// Remove every `open` .. `close` span, including the delimiters. An
3259/// unterminated span swallows the rest of the input, which is what a reader
3260/// sees too.
3261fn drop_spans(s: &str, open: &str, close: &str) -> String {
3262    let mut out = String::with_capacity(s.len());
3263    let mut rest = s;
3264    while let Some(at) = rest.find(open) {
3265        out.push_str(&rest[..at]);
3266        let after = &rest[at + open.len()..];
3267        match after.find(close) {
3268            Some(end) => rest = &after[end + close.len()..],
3269            None => return out,
3270        }
3271    }
3272    out.push_str(rest);
3273    out
3274}
3275
3276/// The lock that keeps at most one run per repository actually moving the
3277/// base branch at a time: a rebase push, or `gh pr merge`.
3278///
3279/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
3280/// running a fix round in the winner's own worktree, waiting on the owner's
3281/// approval - touches nothing a *different* run in the same repository could
3282/// collide with, and holding a lock across any of that would serialise one
3283/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
3284/// resume, which is precisely the "must not wait on another task" property
3285/// the daemon's slot-freeing exists to give a resume. Only the two moments
3286/// that actually write to the shared base branch need mutual exclusion, and
3287/// both are brief.
3288///
3289/// One entry per repository, each its own `tokio::sync::Mutex`, so two
3290/// different repositories' runs never wait on each other. The outer
3291/// `std::sync::Mutex` guards only the map itself, held long enough to find or
3292/// insert an entry and clone its `Arc`, never across an `.await`.
3293fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3294    static LOCKS: std::sync::LazyLock<
3295        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3296    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3297    LOCKS
3298        .lock()
3299        .unwrap_or_else(std::sync::PoisonError::into_inner)
3300        .entry(repo.to_path_buf())
3301        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3302        .clone()
3303}
3304
3305/// `owner/repo` out of a pull request url, falling back to the checkout's
3306/// directory name when the url is not the usual `host/owner/repo/pull/N`.
3307fn repo_label(repo: &Path, pr_url: &str) -> String {
3308    let parts: Vec<&str> = pr_url.split('/').collect();
3309    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3310        && at >= 2
3311        && !parts[at - 1].is_empty()
3312        && !parts[at - 2].is_empty()
3313    {
3314        return format!("{}/{}", parts[at - 2], parts[at - 1]);
3315    }
3316    repo.file_name()
3317        .map(|n| n.to_string_lossy().into_owned())
3318        .unwrap_or_default()
3319}
3320
3321/// The operator-facing sentence for a merge that went ahead with red checks,
3322/// or `None` when the checks were not red. Judged on `checks`, not on
3323/// `failing`, which can be non-empty on a green observation.
3324fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3325    (pr.checks == Checks::Red).then(|| {
3326        let details = crate::notices::format_list(&pr.failing)
3327            .map(|names| format!(": {names}"))
3328            .unwrap_or_default();
3329        format!(
3330            "Merged {repo_name} PR #{} with red checks{details} ({})",
3331            pr.number, pr.url
3332        )
3333    })
3334}
3335
3336/// After a merge that succeeded: record which checks were red and tell the
3337/// operator. The decision to merge is already made; this only makes it audible.
3338/// Best-effort - a broken notifier never fails the run.
3339async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3340    let repo_name = repo_label(&state.repo, &pr.url);
3341    let Some(summary) = red_merge_summary(&repo_name, pr) else {
3342        return;
3343    };
3344    if let Some(rec) = state.pr.as_mut() {
3345        rec.red_at_merge = pr.failing.clone();
3346    }
3347    state.event("land", summary.clone());
3348    // The notice pages through `[notify]` itself, once, unless a question
3349    // already carries the cause.
3350    crate::notices::raise_with(
3351        crate::notices::merged_red(&state.id, &summary),
3352        &state.config.notify,
3353    );
3354}
3355
3356/// Run the loop against a real pull request until it merges or the budget runs
3357/// out.
3358///
3359/// The caller decides whether landing happens at all: this is only reached when
3360/// `graph.land` is on. Returns the last observation, so the caller can report
3361/// what magi was looking at when it stopped.
3362pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3363    land_with(state, pr_url, &GhForge).await
3364}
3365
3366/// The forge calls whose timing the loop's decisions depend on, behind a seam
3367/// so a test can script what the pull request looks like from one observation
3368/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
3369trait Forge {
3370    async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3371        let (ok, out) = gh(
3372            repo,
3373            &[
3374                "pr".into(),
3375                "close".into(),
3376                pr_url.into(),
3377                "--comment".into(),
3378                comment.into(),
3379            ],
3380        )
3381        .await?;
3382        if !ok {
3383            bail!("PR closure failed: {out}");
3384        }
3385        Ok(())
3386    }
3387    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3388    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3389    async fn poll(&self);
3390    /// Rerun the failed jobs of one workflow run.
3391    async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3392    /// Unix seconds, behind the seam so a test owns the clock.
3393    fn now(&self) -> i64;
3394    /// The check contexts the base branch requires, for a stop reason only.
3395    /// `None` when they could not be read, which is not the same as none.
3396    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3397    #[allow(clippy::too_many_arguments)]
3398    async fn fix(
3399        &self,
3400        state: &mut RunState,
3401        pr: &PrState,
3402        round: usize,
3403        budget: usize,
3404        reason: &str,
3405        logs: &str,
3406    ) -> Result<Fixed>;
3407}
3408
3409struct GhForge;
3410
3411impl Forge for GhForge {
3412    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3413        observe(repo, pr_url).await
3414    }
3415    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3416        gh(repo, argv).await
3417    }
3418    async fn poll(&self) {
3419        tokio::time::sleep(POLL).await;
3420    }
3421    async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3422        let args = [
3423            "run".to_owned(),
3424            "rerun".to_owned(),
3425            run.to_owned(),
3426            "--failed".to_owned(),
3427        ];
3428        let (ok, out) = gh(repo, &args).await?;
3429        if !ok {
3430            bail!("gh run rerun {run}: {out}");
3431        }
3432        Ok(())
3433    }
3434    fn now(&self) -> i64 {
3435        jiff::Timestamp::now().as_second()
3436    }
3437    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3438        required_contexts_of(repo, base).await
3439    }
3440    async fn fix(
3441        &self,
3442        state: &mut RunState,
3443        pr: &PrState,
3444        round: usize,
3445        budget: usize,
3446        reason: &str,
3447        logs: &str,
3448    ) -> Result<Fixed> {
3449        fix_round(state, pr, round, budget, reason, logs).await
3450    }
3451}
3452
3453/// Percent-encode a branch name for a URL path segment (`/` included).
3454fn encode_path_segment(s: &str) -> String {
3455    let mut out = String::new();
3456    for b in s.bytes() {
3457        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3458            out.push(b as char);
3459        } else {
3460            let _ = write!(out, "%{b:02X}");
3461        }
3462    }
3463    out
3464}
3465
3466/// Read the required contexts of `base` from classic branch protection and
3467/// from rulesets, once, for a stop reason. Organisation-level rulesets that
3468/// these two endpoints do not list are missed. Any unreadable source makes the
3469/// whole answer `None`: a partial set would read as a complete one.
3470async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3471    let enc = encode_path_segment(base);
3472    let mut all = BTreeSet::new();
3473    // Classic protection answers 404 for an unprotected branch, which is
3474    // "nothing required here", not a failure to read.
3475    let classic = gh(
3476        repo,
3477        &[
3478            "api".to_owned(),
3479            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3480        ],
3481    )
3482    .await
3483    .ok()?;
3484    if classic.0 {
3485        all.extend(parse_classic_required(&classic.1)?);
3486    } else if !classic.1.contains("404") {
3487        return None;
3488    }
3489    let rules = gh(
3490        repo,
3491        &[
3492            "api".to_owned(),
3493            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3494        ],
3495    )
3496    .await
3497    .ok()?;
3498    if !rules.0 {
3499        return None;
3500    }
3501    all.extend(parse_ruleset_required(&rules.1)?);
3502    Some(all)
3503}
3504
3505/// `required_status_checks` of classic protection: `contexts` plus the
3506/// `checks[].context` form.
3507fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3508    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3509    let mut out = BTreeSet::new();
3510    for c in v.get("contexts")?.as_array()? {
3511        out.insert(c.as_str()?.to_owned());
3512    }
3513    for c in v
3514        .get("checks")
3515        .and_then(|c| c.as_array())
3516        .into_iter()
3517        .flatten()
3518    {
3519        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3520            out.insert(name.to_owned());
3521        }
3522    }
3523    Some(out)
3524}
3525
3526/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
3527fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3528    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3529    let mut out = BTreeSet::new();
3530    for rule in v.as_array()? {
3531        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3532            continue;
3533        }
3534        let checks = rule
3535            .pointer("/parameters/required_status_checks")?
3536            .as_array()?;
3537        for c in checks {
3538            out.insert(c.get("context")?.as_str()?.to_owned());
3539        }
3540    }
3541    Some(out)
3542}
3543
3544/// Is the observation older than the commit a fix round pushed?
3545///
3546/// The forge takes a few seconds to move the pull request to a new head and
3547/// attach that head's check runs, and until it has, the rollup is the previous
3548/// head's - all green, which is exactly what a merge decision must not read.
3549/// An absent or different head counts as not yet: guessing "close enough"
3550/// would reopen the hole.
3551fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3552    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3553}
3554
3555/// The commit an observation may be decided on, or `None` while it cannot be
3556/// trusted to describe one.
3557///
3558/// `None` when a pushed commit is awaited and the pull request is not on it,
3559/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3560/// last commit's) is not the head's: that is the previous commit's checks. The
3561/// caller re-polls on `None`. A rollup that cannot be read (including one
3562/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3563/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3564fn bound_head<'a>(
3565    seen_head: &'a str,
3566    rollup_head: &str,
3567    awaiting: Option<&str>,
3568) -> Option<&'a str> {
3569    if seen_head.is_empty()
3570        || awaiting_new_head(awaiting, seen_head)
3571        || !rollup_head.eq_ignore_ascii_case(seen_head)
3572    {
3573        return None;
3574    }
3575    Some(seen_head)
3576}
3577
3578/// What a refused `gh pr merge` means.
3579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3580enum Refused {
3581    /// The branch policy is not satisfied *yet*: go back to waiting.
3582    Pending,
3583    /// Checks have settled and the merge state still says no, seen for the
3584    /// first time. The forge updates `mergeStateStatus` a moment after the last
3585    /// check finishes, so one more look is allowed before believing it.
3586    Recheck,
3587    /// Nothing is in flight and the policy still refuses: a person has to
3588    /// supply what it asks for (a review, say).
3589    Final,
3590}
3591
3592/// Judged from the pull request's state after the refusal, never from the
3593/// refusal's wording, which belongs to the forge and changes.
3594fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3595    let Some(after) = after else {
3596        // Unreadable is not evidence of anything; the next loop reads again.
3597        return Refused::Pending;
3598    };
3599    if after.pr.state != PrLifecycle::Open {
3600        return Refused::Final;
3601    }
3602    // The branch moved after it was observed (the forge refuses a merge pinned
3603    // to the old commit): not a verdict on anything, look again.
3604    if !after.head.eq_ignore_ascii_case(observed_head) {
3605        return Refused::Pending;
3606    }
3607    // The same binding the loop applies: checks of another commit say nothing.
3608    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3609        return Refused::Pending;
3610    }
3611    let state = after.merge_state.to_ascii_uppercase();
3612    if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3613        || state.is_empty()
3614        || state == "UNKNOWN"
3615    {
3616        return Refused::Pending;
3617    }
3618    if rechecked {
3619        Refused::Final
3620    } else {
3621        Refused::Recheck
3622    }
3623}
3624
3625/// Take auto-merge back from the forge and forget that it was armed.
3626///
3627/// `Err` carries the forge's message: the caller must not push or move on, as
3628/// an armed merge left behind could still fire for a commit nobody approved.
3629async fn disarm<F: Forge>(
3630    forge: &F,
3631    state: &mut RunState,
3632    repo: &Path,
3633    number: u64,
3634) -> std::result::Result<(), String> {
3635    let argv = disable_automerge_argv(number);
3636    let out = {
3637        let merge_lock = repo_merge_lock(repo);
3638        let _merge_slot = merge_lock.lock().await;
3639        forge.merge(repo, &argv).await
3640    };
3641    match out {
3642        Ok((true, _)) => {
3643            state.land_armed_head = None;
3644            state.event("land", "auto-merge disabled");
3645            state.save().map_err(|e| format!("{e:#}"))?;
3646            Ok(())
3647        }
3648        Ok((false, msg)) => Err(msg),
3649        Err(e) => Err(format!("{e:#}")),
3650    }
3651}
3652
3653/// [`stop`], first taking back an armed auto-merge so a pull request magi
3654/// gave up on does not merge on its own later. A failure to disarm is added
3655/// to the reason rather than hiding it.
3656async fn stop_disarmed<F: Forge>(
3657    forge: &F,
3658    state: &mut RunState,
3659    repo: &Path,
3660    pr: &PrState,
3661    why: &str,
3662) -> Result<()> {
3663    if state.land_armed_head.is_none() {
3664        return stop(state, repo, pr, why).await;
3665    }
3666    match disarm(forge, state, repo, pr.number).await {
3667        Ok(()) => stop(state, repo, pr, why).await,
3668        Err(e) => {
3669            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3670            stop(state, repo, pr, &why).await
3671        }
3672    }
3673}
3674
3675/// Check the persisted card again around awaits that can outlive an owner tap.
3676fn owner_requested_stop(state: &RunState) -> Result<bool> {
3677    let Some(binding) = &state.land_approval else {
3678        return Ok(false);
3679    };
3680    let q = ask::Questions::open().get(&binding.question)?;
3681    Ok(q.run == state.id
3682        && q.node == APPROVAL_NODE
3683        && q.resolution()
3684            .as_deref()
3685            .is_some_and(crate::merge_decision::stops))
3686}
3687
3688/// Read stop choices before checks, repair or rebase. These are about stopping
3689/// ownership, so a changed head never cancels them as it cancels merge consent.
3690async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3691    use crate::merge_decision as md;
3692    let Some(binding) = &state.land_approval else {
3693        return Ok(false);
3694    };
3695    let q = ask::Questions::open().get(&binding.question)?;
3696    let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3697        return Ok(false);
3698    };
3699    if q.run != state.id || q.node != APPROVAL_NODE {
3700        bail!("stop decision names another run");
3701    }
3702    let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3703        pr: pr.url.clone(),
3704        head: binding.head.clone(),
3705        language: state.config.graph.language.clone(),
3706    });
3707    if target.pr != pr.url {
3708        bail!("stop decision names another PR");
3709    }
3710    if state.merge_decision.is_none() {
3711        state.merge_decision = Some(md::Decision {
3712            question: q.id.clone(),
3713            target,
3714            choice: choice.clone(),
3715            closed: false,
3716            replacement_run: None,
3717            replacement_pr: None,
3718        });
3719        state.save()?;
3720    }
3721    let repo = state.repo.clone();
3722    // Always disable, including arms left by an older driver.
3723    if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3724        stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3725        return Ok(true);
3726    }
3727    if choice.eq_ignore_ascii_case(HOLD) {
3728        stop(
3729            state,
3730            &repo,
3731            pr,
3732            "owner held the decision: PR left open; no further repair or merge work",
3733        )
3734        .await?;
3735        return Ok(true);
3736    }
3737    if md::selected_agent(&choice).is_some()
3738        || state
3739            .origin
3740            .as_ref()
3741            .and_then(|o| o.task.as_ref())
3742            .is_some()
3743    {
3744        let owners: Vec<_> = crate::queue::Queue::open()
3745            .list()
3746            .into_iter()
3747            .filter(|task| task.runs.last() == Some(&state.id))
3748            .collect();
3749        let owner = (owners.len() == 1).then(|| &owners[0]);
3750        if !owner.is_some_and(|task| {
3751            task.runs.last() == Some(&state.id)
3752                && (!task
3753                    .operator_decided_at
3754                    .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3755                    || task.resumption.as_ref().is_some_and(|release| {
3756                        release.question_id.as_deref() == Some(q.id.as_str())
3757                            && task.operator_decided_at == Some(release.at)
3758                    }))
3759        }) {
3760            stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3761            return Ok(true);
3762        }
3763    }
3764    if pr.state == PrLifecycle::Merged {
3765        stop(
3766            state,
3767            &repo,
3768            pr,
3769            "PR already merged; withdrawal/replacement cannot be applied",
3770        )
3771        .await?;
3772        return Ok(true);
3773    }
3774    let comment = format!(
3775        "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3776        q.id, choice, state.id
3777    );
3778    if pr.state == PrLifecycle::Open {
3779        if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3780            stop(
3781                state,
3782                &repo,
3783                pr,
3784                &format!(
3785                    "owner requested closure but it failed: {e}; task held, no replacement started"
3786                ),
3787            )
3788            .await?;
3789            return Ok(true);
3790        }
3791    }
3792    let confirmed = forge.view(&repo, &pr.url).await?;
3793    if confirmed.pr.state != PrLifecycle::Closed {
3794        stop(
3795            state,
3796            &repo,
3797            pr,
3798            "PR closure was not confirmed; task held, no replacement started",
3799        )
3800        .await?;
3801        return Ok(true);
3802    }
3803    *pr = confirmed.pr;
3804    state.merge_decision.as_mut().unwrap().closed = true;
3805    if let Some(record) = &mut state.pr {
3806        record.state = "closed".into();
3807    }
3808    state.status = RunStatus::Superseded;
3809    state.merge = Some(MergeOutcome {
3810        mode: MergeMode::Pr,
3811        ok: false,
3812        detail: format!("owner decision: {choice}; PR closure confirmed"),
3813        empty: false,
3814    });
3815    state.event(
3816        "land",
3817        format!(
3818            "{} applied: PR closure confirmed; old execution retired",
3819            choice
3820        ),
3821    );
3822    state.save()?;
3823    Ok(true)
3824}
3825
3826/// Keep an unstarted verification resumable without charging a task attempt.
3827fn check_landing_resources(
3828    state: &mut RunState,
3829    node: &str,
3830    outcomes: &[crate::run::CommandOutcome],
3831) -> Result<()> {
3832    if let Some(blocked) = outcomes.iter().find(|outcome| outcome.resource_blocked) {
3833        let detail = format!("{}: {}", blocked.command, blocked.output_tail);
3834        state.resource_block = Some(crate::run::ResourceBlock {
3835            node: node.into(),
3836            kind: "shared build cache".into(),
3837            detail: detail.clone(),
3838            at: jiff::Timestamp::now(),
3839        });
3840        state.event("resource", &detail);
3841        state.save()?;
3842        bail!("landing verification could not start: {detail}");
3843    }
3844    Ok(())
3845}
3846
3847/// Gate the local winner and publish that exact SHA, retaining the saved lease.
3848/// A push completed before a crash is recoverable only when its SHA was gated.
3849async fn publish_landing_head<F: Forge>(
3850    state: &mut RunState,
3851    observed: &str,
3852    pr_url: &str,
3853    forge: &F,
3854) -> Result<String> {
3855    crate::pr_head::ensure_owner(state)?;
3856    let winner = state.winner().cloned().context("landing has no winner")?;
3857    let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3858    let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3859    anyhow::ensure!(
3860        observed == expected
3861            || (observed == head
3862                && state.gate_head.as_deref() == Some(&head)
3863                && state.gate_allows_progress()),
3864        "public head moved outside this run; refusing to overwrite it"
3865    );
3866    anyhow::ensure!(
3867        git::is_clean(&winner.worktree).await?,
3868        "landing worktree is dirty"
3869    );
3870    if state.gate_head.as_deref() != Some(&head) || !state.gate_ran || !state.gate_allows_progress()
3871    {
3872        state.gate.clear();
3873        state.gate_head = None;
3874        state.gate_ran = false;
3875        state.pre_gate.clear();
3876        state.pre_gate_commit = None;
3877        state.save()?;
3878        let inherited = crate::repo_lock::WaitPolicy::current();
3879        let wait = crate::repo_lock::WaitPolicy::new(
3880            Duration::from_secs(state.config.graph.timeout_resource_wait),
3881            move || inherited.cancelled(),
3882        );
3883        let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3884        loop {
3885            if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3886                state.gate = outcomes.clone();
3887                check_landing_resources(state, "gate", &outcomes)?;
3888            }
3889            if outcomes.iter().all(|o| o.ok()) {
3890                break;
3891            }
3892            match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3893                crate::graph::GateFix::Retry => {
3894                    outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3895                }
3896                crate::graph::GateFix::Stop => break,
3897                crate::graph::GateFix::Defer => {
3898                    // The repair checks e2e immediately, before the next gate.
3899                    // Preserve a cache wait here as well as below; otherwise
3900                    // the daemon charges an attempt for unstarted verification.
3901                    if let Some(e2e) = &state.gate_repair_e2e {
3902                        let outcomes = e2e.outcomes.clone();
3903                        check_landing_resources(state, "e2e", &outcomes)?;
3904                    }
3905                    bail!("landing gate repair deferred");
3906                }
3907            }
3908        }
3909        state.gate = outcomes;
3910        state.gate_ran = true;
3911        state.gate_head = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3912        state.save()?;
3913    }
3914    anyhow::ensure!(
3915        state.gate_allows_progress(),
3916        "landing gate failed; nothing published"
3917    );
3918    let checked = state
3919        .gate_head
3920        .clone()
3921        .context("landing gate has no checked SHA")?;
3922    anyhow::ensure!(
3923        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3924            && git::is_clean(&winner.worktree).await?,
3925        "landing tree changed after gate"
3926    );
3927    let inherited = crate::repo_lock::WaitPolicy::current();
3928    let wait = crate::repo_lock::WaitPolicy::new(
3929        Duration::from_secs(state.config.graph.timeout_resource_wait),
3930        move || inherited.cancelled(),
3931    );
3932    let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3933    if matches!(e2e, crate::graph::GateFix::Defer) {
3934        let outcomes = state
3935            .gate_repair_e2e
3936            .as_ref()
3937            .context("deferred landing e2e has no outcomes")?
3938            .outcomes
3939            .clone();
3940        check_landing_resources(state, "e2e", &outcomes)?;
3941    }
3942    anyhow::ensure!(
3943        matches!(e2e, crate::graph::GateFix::Retry),
3944        "post-repair e2e has not passed on the landing head; nothing published"
3945    );
3946    anyhow::ensure!(
3947        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3948            && git::is_clean(&winner.worktree).await?,
3949        "landing tree changed during post-repair e2e"
3950    );
3951    crate::pr_head::ensure_owner(state)?;
3952    if owner_requested_stop(state)? {
3953        bail!("owner requested stop before publication");
3954    }
3955    let current = forge.view(&state.repo, pr_url).await?;
3956    anyhow::ensure!(
3957        current.pr.state == PrLifecycle::Open,
3958        "PR closed or merged while gating; nothing published"
3959    );
3960    anyhow::ensure!(
3961        current.head == observed || current.head == checked,
3962        "PR head changed while gating; nothing published"
3963    );
3964    let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3965    let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3966    let refspec = format!("{checked}:refs/heads/{public}");
3967    let lock = repo_merge_lock(&state.repo);
3968    let _slot = lock.lock().await;
3969    let pushed = git::git_raw(
3970        &winner.worktree,
3971        &["push", &lease, &state.config.merge.remote, &refspec],
3972    )
3973    .await?;
3974    anyhow::ensure!(
3975        pushed.ok(),
3976        "publishing gated landing head failed: {}",
3977        pushed.stderr
3978    );
3979    state.pr_pushed_head = Some(checked.clone());
3980    state.land_sync_pending = false;
3981    state.save()?;
3982    Ok(checked)
3983}
3984
3985/// A scratch rebase moves the winner ref before its index and files. Recover
3986/// only a tree proven unchanged since the saved start; never discard edits.
3987async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
3988    let winner = state.winner().cloned().context("landing has no winner")?;
3989    let inherited = crate::repo_lock::WaitPolicy::current();
3990    let wait = crate::repo_lock::WaitPolicy::new(
3991        Duration::from_secs(state.config.graph.timeout_resource_wait),
3992        move || inherited.cancelled(),
3993    );
3994    let _coordination = crate::repo_lock::SeatLease::operation_with(
3995        &winner.worktree,
3996        state.config.coordination.as_ref(),
3997        &wait,
3998    )
3999    .await?;
4000    if !git::is_clean(&winner.worktree).await? {
4001        let from = state
4002            .land_rebase_from
4003            .as_deref()
4004            .context("landing worktree is dirty")?;
4005        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4006        anyhow::ensure!(head != from, "landing worktree is dirty");
4007        for args in [
4008            vec!["diff", "--cached", "--quiet", from],
4009            vec!["diff", "--quiet"],
4010        ] {
4011            anyhow::ensure!(
4012                git::git_raw(&winner.worktree, &args).await?.ok(),
4013                "landing worktree has edits beyond the interrupted rebase"
4014            );
4015        }
4016        // Preserve build artifacts and local files, including ignored ones.
4017        // read-tree can overwrite ignored paths, so reject collisions with
4018        // the new tree (including file/directory conflicts) before updating.
4019        let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
4020        let tracked = git::git(
4021            &winner.worktree,
4022            &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
4023        )
4024        .await?;
4025        for local in untracked.split('\0').filter(|p| !p.is_empty()) {
4026            anyhow::ensure!(
4027                !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
4028                    path == local
4029                        || path
4030                            .strip_prefix(local)
4031                            .is_some_and(|rest| rest.starts_with('/'))
4032                        || local
4033                            .strip_prefix(path)
4034                            .is_some_and(|rest| rest.starts_with('/'))
4035                }),
4036                "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
4037            );
4038        }
4039        git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
4040    }
4041    crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
4042    state.land_rebase_from = None;
4043    state.save()?;
4044    Ok(())
4045}
4046
4047async fn rebase_landing_worktree(
4048    state: &mut RunState,
4049    onto: &str,
4050) -> Result<crate::rebase::Rebased> {
4051    restore_landing_worktree(state).await?;
4052    let winner = state.winner().cloned().context("landing has no winner")?;
4053    state.land_sync_pending = true;
4054    state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
4055    state.save()?;
4056    let scratch = state.dir().join("rebase");
4057    let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
4058    if result == crate::rebase::Rebased::Applied {
4059        restore_landing_worktree(state).await?;
4060    }
4061    Ok(result)
4062}
4063
4064/// Reconcile a stopped landing before changing its branch. This also handles
4065/// old records whose graph status or merge detail no longer identifies land.
4066async fn resume_landing_head<F: Forge>(
4067    state: &mut RunState,
4068    observed: &str,
4069    pr_url: &str,
4070    forge: &F,
4071) -> Result<String> {
4072    crate::pr_head::ensure_owner(state)?;
4073    let winner = state.winner().cloned().context("landing has no winner")?;
4074    let local = git::rev_parse(&winner.worktree, "HEAD").await?;
4075    let expected = state
4076        .pr_pushed_head
4077        .as_deref()
4078        .or(state.gate_head.as_deref())
4079        .context("missing public head lease")?;
4080    anyhow::ensure!(
4081        observed == expected
4082            || (observed == local
4083                && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
4084                    || git::is_ancestor(&winner.worktree, expected, &local).await)),
4085        "public head moved outside this run; refusing to sync"
4086    );
4087    // An operator may have extended and published the winner while approval
4088    // was pending. Matching local/public history can be adopted as the lease,
4089    // but publish_landing_head must gate its new SHA before proceeding. A
4090    // remote-only or divergent update still cannot be overwritten.
4091    // Save reconciliation before rebasing again; a prior push may have reached
4092    // the remote immediately before the previous driver's last save.
4093    state.pr_pushed_head = Some(observed.to_owned());
4094    state.save()?;
4095    restore_landing_worktree(state).await?;
4096    let remote = state.config.merge.remote.clone();
4097    let base = state.base_branch.clone();
4098    let fetched = git::fetch(&state.repo, &remote, &base).await?;
4099    anyhow::ensure!(
4100        fetched.ok(),
4101        "cannot fetch landing base: {}",
4102        fetched.stderr
4103    );
4104    let onto = format!("{remote}/{base}");
4105    let tip = git::rev_parse(&state.repo, &onto).await?;
4106    if !git::is_ancestor(&state.repo, &tip, &local).await {
4107        match rebase_landing_worktree(state, &onto).await? {
4108            crate::rebase::Rebased::Applied => {}
4109            crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4110        }
4111    }
4112    publish_landing_head(state, observed, pr_url, forge).await
4113}
4114
4115async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4116    crate::pr_head::ensure_owner(state)?;
4117    let reopening_hold = crate::merge_decision::resumable_hold(state);
4118    let repo = state.repo.clone();
4119    let budget = state.config.graph.land_rounds;
4120    let mut round = 0usize;
4121    // Counted apart from `round`: a rebase is not a fix, and a base that
4122    // moved is not the change's fault.
4123    let mut rebases = 0usize;
4124    let mut waited = Duration::ZERO;
4125    // Comment bodies the fixer has already been shown. A comment is
4126    // outstanding until it has been handed over once; after that it is a
4127    // recorded decision, not an open question, and re-feeding it would loop the
4128    // budget away on a comment the fixer already declined with an argument.
4129    let mut shown: BTreeSet<String> = BTreeSet::new();
4130    // Await the last successfully published head, including across resumes.
4131    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4132    // Whether a settled-checks refusal has already been given its one re-look.
4133    let mut rechecked = false;
4134
4135    // Marks the run resumable through exactly this function, not through a
4136    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
4137    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
4138    // status specifically to know a resumed run belongs back in `land`
4139    // rather than at a second `gh pr create`. Set on every entry - fresh or
4140    // resumed - because a resume that parked here again must keep reading
4141    // `Landing`, not whatever a first pass through `merge` left behind.
4142    state.status = RunStatus::Landing;
4143    state.event("land", format!("watching {pr_url}"));
4144    state.save()?;
4145
4146    // An armed merge recorded by an earlier pass may or may not have reached
4147    // the forge (the record is written before the call). It is taken back on
4148    // the first observation, where a pull request is known to stop with.
4149    let mut resumed_armed = state.land_armed_head.is_some();
4150
4151    loop {
4152        let seen = forge.view(&repo, pr_url).await?;
4153        let mut pr = seen.pr.clone();
4154        pr.review_comments.retain(|c| !shown.contains(&c.body));
4155        state.pr = Some(crate::run::PrRecord {
4156            url: pr.url.clone(),
4157            number: pr.number,
4158            state: pr.state.as_str().to_owned(),
4159            checks: pr.checks.as_str().to_owned(),
4160            round,
4161            rounds: budget,
4162            red_at_merge: Vec::new(),
4163        });
4164        state.save()?;
4165
4166        if pr.state == PrLifecycle::Open {
4167            state.observe_branch_owner_holds(&seen.head).await;
4168            state.save()?;
4169        }
4170        crate::pr_head::ensure_owner(state)?;
4171        if reopening_hold {
4172            anyhow::ensure!(
4173                pr.state == PrLifecycle::Open,
4174                "held PR is no longer open; cannot request a fresh decision"
4175            );
4176            // An explicit resume authorizes a fresh question only. Preserve the
4177            // answered card, and never enter repair/rebase/merge on this pass.
4178            disarm(forge, state, &repo, pr.number)
4179                .await
4180                .map_err(anyhow::Error::msg)?;
4181            let previous = state.merge_decision.take();
4182            let binding = state.land_approval.take();
4183            state.config.graph.land_approval = true;
4184            let subject = merge_subject(
4185                crate::graph::landing_title(state, &seen.title),
4186                &crate::graph::landing_subject_source(state),
4187            );
4188            let contested = contested_to_ask(state);
4189            if let Err(error) =
4190                approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4191            {
4192                state.merge_decision = previous;
4193                state.land_approval = binding;
4194                return Err(error);
4195            }
4196            state.end_pass_parked(crate::run::ParkCause::Question);
4197            state.parked = true;
4198            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()));
4199            state.save()?;
4200            return Ok(pr);
4201        }
4202        if owner_stop(state, &mut pr, forge).await? {
4203            return Ok(pr);
4204        }
4205
4206        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4207            // An approval already given for the same head is reused, so
4208            // nothing is asked twice. A failed disable
4209            // stops the run with the record kept: pushing on could change the
4210            // head under an arm that is still live.
4211            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4212                let why = format!(
4213                    "a previous pass may have armed auto-merge and it could not be disabled \
4214                     on resume: {e}"
4215                );
4216                stop(state, &repo, &pr, &why).await?;
4217                return Ok(pr);
4218            }
4219        }
4220
4221        // The head moved away from the one auto-merge was armed on, by
4222        // someone other than this loop. The arm is pinned and would refuse to
4223        // merge the new commit, but the approval was for the old one: take it
4224        // back and go through the normal path again.
4225        if pr.state == PrLifecycle::Open
4226            && !seen.head.is_empty()
4227            && state
4228                .land_armed_head
4229                .as_deref()
4230                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4231        {
4232            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4233                let why = format!(
4234                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
4235                );
4236                stop(state, &repo, &pr, &why).await?;
4237                return Ok(pr);
4238            }
4239        }
4240
4241        if state.land_sync_pending && pr.state == PrLifecycle::Open {
4242            // Disarming can outlive an owner answer. Consume it before our
4243            // own rebase invalidates the head shown on the decision card.
4244            if owner_stop(state, &mut pr, forge).await? {
4245                return Ok(pr);
4246            }
4247            let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4248                binding.head.eq_ignore_ascii_case(&seen.head)
4249                    && ask::Questions::open()
4250                        .get(&binding.question)
4251                        .is_ok_and(|q| {
4252                            q.run == state.id
4253                                && q.node == APPROVAL_NODE
4254                                && q.status == ask::QuestionStatus::Answered
4255                                && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4256                        })
4257            });
4258            if repair_answer {
4259                let subject = merge_subject(
4260                    crate::graph::landing_title(state, &seen.title),
4261                    &crate::graph::landing_subject_source(state),
4262                );
4263                let contested = contested_to_ask(state);
4264                if matches!(
4265                    approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4266                    ApprovalGate::Repair
4267                ) {
4268                    if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4269                        state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4270                        state.save()?;
4271                    }
4272                    return Ok(pr);
4273                }
4274            }
4275            let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4276            // A stop detected before publication must apply its durable
4277            // decision, even when verification returned an error meanwhile.
4278            if owner_stop(state, &mut pr, forge).await? {
4279                return Ok(pr);
4280            }
4281            let head = synced?;
4282            awaiting_head = Some(head);
4283            waited = Duration::ZERO;
4284            rechecked = false;
4285            // Read CI and approvals afresh, never from the pre-push observation.
4286            continue;
4287        }
4288
4289        if pr.state == PrLifecycle::Open {
4290            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4291                if waited >= WAIT_CEILING {
4292                    let want = awaiting_head.as_deref().unwrap_or_default();
4293                    let why = format!(
4294                        "the pull request's checks were still not about one readable head after \
4295                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
4296                         someone may have pushed over it",
4297                        WAIT_CEILING.as_secs() / 60,
4298                        if want.is_empty() { "any" } else { want },
4299                        if seen.head.is_empty() {
4300                            "nothing readable"
4301                        } else {
4302                            &seen.head
4303                        },
4304                        if seen.rollup_head.is_empty() {
4305                            "nothing readable"
4306                        } else {
4307                            &seen.rollup_head
4308                        },
4309                    );
4310                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
4311                    return Ok(pr);
4312                }
4313                waited += POLL;
4314                forge.poll().await;
4315                continue;
4316            }
4317            // Reset once, when the awaited head first shows up; resetting on
4318            // every re-observation would let a standing refusal wait forever.
4319            if awaiting_head.take().is_some() {
4320                // The checks now being read belong to the new head; give them
4321                // the same grace a fresh pull request gets.
4322                waited = Duration::ZERO;
4323            }
4324        }
4325
4326        // A failed workflow run is rerun once, judged by its own run rather
4327        // than by the rollup's aggregate: another run still pending, or a fix
4328        // budget already spent, must not keep a flaky failure from its one
4329        // rerun. It spends no round and no attempt.
4330        if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
4331            let failing = rerunnable(&seen.contexts);
4332            let now = forge.now();
4333            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4334                RerunStep::Rerun(runs) => {
4335                    let rec = state.land_reruns.get_or_insert_with(Default::default);
4336                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
4337                        *rec = LandReruns {
4338                            head: seen.head.clone(),
4339                            runs: BTreeMap::new(),
4340                        };
4341                    }
4342                    for run in &runs {
4343                        rec.runs.insert(run.clone(), now);
4344                    }
4345                    // Durable before the call: a restart must not
4346                    // rerun the same run twice. A save that fails
4347                    // means no rerun.
4348                    state.save()?;
4349                    let mut any = false;
4350                    for run in &runs {
4351                        let names = failing
4352                            .iter()
4353                            .filter(|(_, r)| r.as_deref() == Some(run))
4354                            .map(|(l, _)| l.as_str())
4355                            .collect::<Vec<_>>()
4356                            .join(", ");
4357                        match forge.rerun(&repo, run).await {
4358                            Ok(()) => {
4359                                any = true;
4360                                state.event(
4361                                    "land",
4362                                    format!("reran failed check(s) {names} (run {run})"),
4363                                );
4364                            }
4365                            // Stays recorded: never a retry loop.
4366                            Err(e) => state.event(
4367                                "land",
4368                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4369                            ),
4370                        }
4371                    }
4372                    state.save()?;
4373                    if any {
4374                        waited = Duration::ZERO;
4375                        forge.poll().await;
4376                        continue;
4377                    }
4378                }
4379                RerunStep::Grace => {
4380                    if waited >= WAIT_CEILING {
4381                        let why = format!(
4382                            "checks were still failing after a rerun and {} minutes",
4383                            WAIT_CEILING.as_secs() / 60
4384                        );
4385                        stop(state, &repo, &pr, &why).await?;
4386                        return Ok(pr);
4387                    }
4388                    waited += POLL;
4389                    forge.poll().await;
4390                    continue;
4391                }
4392                RerunStep::Fix => {}
4393            }
4394        }
4395
4396        let step = decide(&pr, round, budget, waited);
4397        // Armed on exactly this head: the forge is doing the waiting. A
4398        // decision to merge (or keep waiting) is only watched, never re-armed
4399        // and never re-approved; anything else - a red check, a comment, a
4400        // conflict - falls through to its own arm, which disarms first.
4401        let armed_here = state
4402            .land_armed_head
4403            .as_deref()
4404            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4405        if armed_here && matches!(step, Step::Merge | Step::Wait) {
4406            if waited >= WAIT_CEILING {
4407                let required = if seen.base.is_empty() {
4408                    None
4409                } else {
4410                    forge.required_contexts(&repo, &seen.base).await
4411                };
4412                let why = format!(
4413                    "auto-merge was armed on {} but the pull request did not merge within {} \
4414                     minutes ({})",
4415                    seen.head,
4416                    WAIT_CEILING.as_secs() / 60,
4417                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4418                );
4419                stop_disarmed(forge, state, &repo, &pr, &why).await?;
4420                return Ok(pr);
4421            }
4422            waited += POLL;
4423            forge.poll().await;
4424            continue;
4425        }
4426        if armed_here && !matches!(step, Step::Done { .. }) {
4427            // Not merging or waiting any more: whatever is next may change the
4428            // head or give up, and neither may leave the arm standing.
4429            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4430                let why = format!("auto-merge could not be disabled: {e}");
4431                stop(state, &repo, &pr, &why).await?;
4432                return Ok(pr);
4433            }
4434        }
4435        match step {
4436            Step::Wait => {
4437                if waited >= WAIT_CEILING {
4438                    let why = format!(
4439                        "checks were still running after {} minutes",
4440                        WAIT_CEILING.as_secs() / 60
4441                    );
4442                    stop(state, &repo, &pr, &why).await?;
4443                    return Ok(pr);
4444                }
4445                waited += POLL;
4446                forge.poll().await;
4447            }
4448            Step::Done { merged } => {
4449                if merged {
4450                    state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4451                }
4452                // Auto-merge is pinned to the approved head, but the forge's
4453                // own handling of a later push is not something magi can
4454                // see: if the pull request merged on another commit, say so
4455                // loudly rather than record a clean landing.
4456                if let Some(armed) = state.land_armed_head.take() {
4457                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4458                        let msg = format!(
4459                            "{} merged on {} but the owner approved {armed}; review what landed",
4460                            pr.url, seen.head
4461                        );
4462                        tracing::warn!("{msg}");
4463                        state.event("land", msg);
4464                        // Only an arm left by an older build can get here.
4465                        // The wording is fixed so a repeat does not relight
4466                        // the notice.
4467                        crate::notices::raise_with(
4468                            crate::notices::Notice::warn(
4469                                &format!("merged-unapproved-head:{}", state.id),
4470                                "A pull request merged on a commit the owner did not approve; \
4471                                 review what landed",
4472                            )
4473                            .link(crate::notices::Link::Run {
4474                                id: state.id.clone(),
4475                            }),
4476                            &state.config.notify,
4477                        );
4478                    }
4479                }
4480                state.status = if merged {
4481                    RunStatus::Merged
4482                } else {
4483                    RunStatus::Ready
4484                };
4485                let detail = if merged {
4486                    format!("{} was merged", pr.url)
4487                } else {
4488                    format!("{} was closed without merging", pr.url)
4489                };
4490                state.merge = Some(MergeOutcome {
4491                    mode: MergeMode::Pr,
4492                    ok: merged,
4493                    detail: detail.clone(),
4494                    empty: false,
4495                });
4496                state.event("land", detail);
4497                state.save()?;
4498                write_pr_state_through(state, pr.state);
4499                return Ok(pr);
4500            }
4501            Step::Merge => {
4502                state.check_delivery_evidence(&seen.head)?;
4503                let subject = merge_subject(
4504                    crate::graph::landing_title(state, &seen.title),
4505                    &crate::graph::landing_subject_source(state),
4506                );
4507                // The owner sees the panel before the one irreversible step,
4508                // and an unanswered question is a hold: silence never merges.
4509                //
4510                // The dedicated policy includes existing consent and post-repair
4511                // decisions; repair choice availability never grants merge authority.
4512                let contested = contested_to_ask(state);
4513                if !approval_reasons(state).is_empty() {
4514                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4515                        .await?
4516                    {
4517                        ApprovalGate::Approved => {}
4518                        ApprovalGate::Repair => {
4519                            // The driver consumes the saved request under the existing
4520                            // run/task claim. No merge, no second run or task.
4521                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4522                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4523                                state.save()?;
4524                            }
4525                            return Ok(pr);
4526                        }
4527                        ApprovalGate::Held => {
4528                            // Another entry point may have saved a stop after
4529                            // the first read above. Apply its durable action.
4530                            if owner_stop(state, &mut pr, forge).await? {
4531                                return Ok(pr);
4532                            }
4533                            stop(
4534                                state,
4535                                &repo,
4536                                &pr,
4537                                "the owner did not approve the merge (held or unanswered)",
4538                            )
4539                            .await?;
4540                            return Ok(pr);
4541                        }
4542                        // Filed (or still standing from an earlier visit) and
4543                        // not yet answered. Park here rather than wait: the
4544                        // question survives on disk, the daemon hands this
4545                        // run's slot to something else, and a later resume
4546                        // re-enters `land`, finds the same question, and
4547                        // either merges or stops depending on what it says
4548                        // by then.
4549                        ApprovalGate::Pending => {
4550                            state.end_pass_parked(crate::run::ParkCause::Question);
4551                            state.parked = true;
4552                            state.event(
4553                                "land",
4554                                "parked awaiting merge approval - resumes once answered",
4555                            );
4556                            state.save()?;
4557                            return Ok(pr);
4558                        }
4559                    }
4560                }
4561                // Open and past the gate above, so `seen.head` is the commit
4562                // the checks were bound to and the owner approved.
4563                //
4564                // Merge directly, bound to that commit. Auto-merge is not
4565                // armed any more: the forge checks `--match-head-commit` only
4566                // when the request is made, so an arm outlives the head it
4567                // was made for, and a push of another commit whose
4568                // requirements are met first would merge without approval.
4569                // A direct merge is checked by the forge at the moment it
4570                // happens, so only the approved head can land.
4571                let observed_head = seen.head.clone();
4572                // Read the pull request again just before: the state seen
4573                // above can be a moment old.
4574                {
4575                    let fresh = forge.view(&repo, pr_url).await.ok();
4576                    if !direct_merge_is_safe(
4577                        fresh.as_ref(),
4578                        &observed_head,
4579                        &shown,
4580                        round,
4581                        budget,
4582                        waited,
4583                    ) {
4584                        if waited >= WAIT_CEILING {
4585                            let why = "the pull request did not settle on the approved head \
4586                                       before it could be merged";
4587                            stop(state, &repo, &pr, why).await?;
4588                            return Ok(pr);
4589                        }
4590                        state.event(
4591                            "land",
4592                            "the pull request changed before merging; looking again",
4593                        );
4594                        waited += POLL;
4595                        forge.poll().await;
4596                        continue;
4597                    }
4598                }
4599                let argv = merge_argv_at(pr.number, &subject, &observed_head);
4600                let out = {
4601                    let merge_lock = repo_merge_lock(&repo);
4602                    let _merge_slot = merge_lock.lock().await;
4603                    crate::pr_head::ensure_owner(state)?;
4604                    if let Some(task_id) = state
4605                        .origin
4606                        .as_ref()
4607                        .and_then(|origin| origin.task.as_ref())
4608                    {
4609                        let task = crate::queue::Queue::open()
4610                            .get(task_id)
4611                            .context("cannot verify the task owner immediately before merge")?;
4612                        if task.runs.last() != Some(&state.id)
4613                            || task.operator_held()
4614                            || task.status == crate::queue::TaskStatus::Done
4615                        {
4616                            bail!(
4617                                "task ownership or an operator hold superseded this merge approval"
4618                            );
4619                        }
4620                    }
4621                    if let Some(binding) = &state.land_approval {
4622                        let current = ask::Questions::open().get(&binding.question)?;
4623                        if merge_consent_superseded(state, &current)?
4624                            || current.run != state.id
4625                            || !binding.head.eq_ignore_ascii_case(&observed_head)
4626                            || approval(current.resolution().as_deref()) != Approval::Merge
4627                            || current.merge_target.as_ref().is_some_and(|t| {
4628                                t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4629                            })
4630                        {
4631                            bail!(
4632                                "merge decision is stale or no longer authorizes the reviewed head"
4633                            );
4634                        }
4635                    }
4636                    forge.merge(&repo, &argv).await?
4637                };
4638                if out.0 {
4639                    // Exit 0 is not proof of a merge: with a merge queue `gh`
4640                    // enqueues (or reports the pull request already queued)
4641                    // and succeeds while it is still open. Ask the forge; an
4642                    // unreadable answer is not a confirmation either, so it
4643                    // is looked at again rather than recorded as merged.
4644                    let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4645                        if c.pr.state == PrLifecycle::Merged {
4646                            state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4647                            true
4648                        } else {
4649                            false
4650                        }
4651                    });
4652                    if !confirmed {
4653                        if waited >= WAIT_CEILING {
4654                            let why = "the merge request succeeded but the pull request \
4655                                       could not be confirmed merged after waiting";
4656                            stop(state, &repo, &pr, why).await?;
4657                            return Ok(pr);
4658                        }
4659                        state.event(
4660                            "land",
4661                            "merge accepted but the pull request is not confirmed merged yet; waiting",
4662                        );
4663                        state.save()?;
4664                        waited += POLL;
4665                        forge.poll().await;
4666                        continue;
4667                    }
4668                    pr.state = PrLifecycle::Merged;
4669                    state.status = RunStatus::Merged;
4670                    state.merge = Some(MergeOutcome {
4671                        mode: MergeMode::Pr,
4672                        ok: true,
4673                        detail: format!("gh {}", argv.join(" ")),
4674                        empty: false,
4675                    });
4676                    // The last `state.pr` snapshot is whatever the poll before
4677                    // this merge observed - still `open` - and nothing below
4678                    // refreshes it from GitHub again, so the UI's round rail
4679                    // would otherwise keep animating a merged run forever.
4680                    if let Some(pr_record) = state.pr.as_mut() {
4681                        pr_record.state = pr.state.as_str().to_owned();
4682                    }
4683                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4684                    announce_red_merge(state, &pr).await;
4685                    state.save()?;
4686                    write_pr_state_through(state, pr.state);
4687                    return Ok(pr);
4688                }
4689                let after_seen = forge.view(&repo, pr_url).await.ok();
4690                let after = after_seen.as_ref().map(|s| s.pr.state);
4691                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4692                    state.pr_merged_head = after_seen
4693                        .as_ref()
4694                        .map(|s| s.head.clone())
4695                        .filter(|h| !h.is_empty());
4696                    pr.state = PrLifecycle::Merged;
4697                    state.status = RunStatus::Merged;
4698                    state.merge = Some(outcome);
4699                    if let Some(pr_record) = state.pr.as_mut() {
4700                        pr_record.state = pr.state.as_str().to_owned();
4701                    }
4702                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4703                    announce_red_merge(state, &pr).await;
4704                    state.save()?;
4705                    write_pr_state_through(state, pr.state);
4706                    return Ok(pr);
4707                }
4708                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4709                match verdict {
4710                    Refused::Final => {
4711                        let merge_state = after_seen
4712                            .as_ref()
4713                            .map(|s| s.merge_state.as_str())
4714                            .filter(|m| !m.is_empty())
4715                            .unwrap_or("unknown");
4716                        // Which refusal this was decides what a person has to
4717                        // do about it, so the two are worded apart; both carry
4718                        // the forge's own message.
4719                        let why = format!(
4720                            "the merge was refused: {} (merge state: {merge_state})",
4721                            out.1
4722                        );
4723                        stop(state, &repo, &pr, &why).await?;
4724                        return Ok(pr);
4725                    }
4726                    verdict => {
4727                        // Back to the top, which re-decides and passes the
4728                        // approval gate again, a poll later. `waited` is not
4729                        // reset here: only a pushed fix restarts it, or a
4730                        // standing refusal would wait forever.
4731                        if waited >= WAIT_CEILING {
4732                            let why = format!(
4733                                "the merge was still refused after {} minutes: {}",
4734                                WAIT_CEILING.as_secs() / 60,
4735                                out.1
4736                            );
4737                            stop(state, &repo, &pr, &why).await?;
4738                            return Ok(pr);
4739                        }
4740                        if verdict == Refused::Recheck {
4741                            rechecked = true;
4742                        }
4743                        state.event(
4744                            "land",
4745                            "merge refused while the branch policy is not satisfied yet; waiting",
4746                        );
4747                        state.save()?;
4748                        waited += POLL;
4749                        forge.poll().await;
4750                    }
4751                }
4752            }
4753            Step::Rebase => {
4754                // Bounded by the same budget as a fix, because a rebase that
4755                // keeps being needed means the base moves faster than this
4756                // run can land and a person should decide what to do. It
4757                // spends none of that budget: the change is not what is
4758                // wrong.
4759                if rebases >= budget {
4760                    let why = format!(
4761                        "the base moved under this branch {budget} time(s) and it still does \
4762                         not merge; rebasing again would only race it"
4763                    );
4764                    stop(state, &repo, &pr, &why).await?;
4765                    return Ok(pr);
4766                }
4767                rebases += 1;
4768                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4769                    stop(
4770                        state,
4771                        &repo,
4772                        &pr,
4773                        "the pull request conflicts and this run has no winning branch to rebase",
4774                    )
4775                    .await?;
4776                    return Ok(pr);
4777                };
4778                let base = state.base_branch.clone();
4779                state.event(
4780                    "land",
4781                    format!("{} no longer merges; rebasing onto {base}", pr.url),
4782                );
4783                state.save()?;
4784
4785                // Onto the base as the *remote* has it: the local ref may be
4786                // behind, and rebasing onto a stale base produces a branch
4787                // that conflicts all over again.
4788                let remote = state.config.merge.remote.clone();
4789                git::fetch(&repo, &remote, &base).await.ok();
4790                if owner_stop(state, &mut pr, forge).await? {
4791                    return Ok(pr);
4792                }
4793                let onto = format!("{remote}/{base}");
4794                let rebased = match rebase_landing_worktree(state, &onto).await {
4795                    Ok(crate::rebase::Rebased::Applied) => Ok(None),
4796                    Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4797                    Err(e) => Err(e),
4798                };
4799                if owner_stop(state, &mut pr, forge).await? {
4800                    return Ok(pr);
4801                }
4802                match rebased {
4803                    Ok(None) => {
4804                        let published =
4805                            publish_landing_head(state, &seen.head, pr_url, forge).await;
4806                        if owner_stop(state, &mut pr, forge).await? {
4807                            return Ok(pr);
4808                        }
4809                        let head = published?;
4810                        crate::graph::refresh_reviewed_commits(state, &branch).await;
4811                        state.pr_pushed_head = Some(head.clone());
4812                        awaiting_head = Some(head);
4813                        rechecked = false;
4814                        state.event("land", format!("rebased {branch} onto {base}"));
4815                        state.save()?;
4816                        // The forge has to re-run its checks against the
4817                        // rebased head before anything else can be decided.
4818                        waited = Duration::ZERO;
4819                        tokio::time::sleep(POLL).await;
4820                    }
4821                    // The fixer's rounds are spent (or it could not finish):
4822                    // that is a decision for a person.
4823                    Ok(Some(conflict)) => {
4824                        let why = format!(
4825                            "{} conflicts with {base} and the rebase did not apply: {}",
4826                            pr.url,
4827                            conflict.chars().take(600).collect::<String>()
4828                        );
4829                        stop(state, &repo, &pr, &why).await?;
4830                        return Ok(pr);
4831                    }
4832                    Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4833                    Err(e) => {
4834                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
4835                        stop(state, &repo, &pr, &why).await?;
4836                        return Ok(pr);
4837                    }
4838                }
4839            }
4840            Step::GiveUp { reason } => {
4841                stop(state, &repo, &pr, &reason).await?;
4842                return Ok(pr);
4843            }
4844            Step::Fix { reason } => {
4845                round += 1;
4846                waited = Duration::ZERO;
4847                for c in &pr.review_comments {
4848                    shown.insert(c.body.clone());
4849                }
4850                state.event("land", format!("round {round}: {reason}"));
4851                state.save()?;
4852
4853                let logs = failing_logs(&repo, &seen.failing_urls).await;
4854                let was_red = pr.checks == Checks::Red;
4855                if owner_stop(state, &mut pr, forge).await? {
4856                    return Ok(pr);
4857                }
4858                let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4859                if owner_stop(state, &mut pr, forge).await? {
4860                    return Ok(pr);
4861                }
4862                match fixed {
4863                    Fixed::Committed { head } => {
4864                        // Whatever the next look shows may still be the
4865                        // previous head; see `awaiting_new_head`.
4866                        state.pr_pushed_head = Some(head.clone());
4867                        awaiting_head = Some(head);
4868                        rechecked = false;
4869                        waited = Duration::ZERO;
4870                        forge.poll().await;
4871                    }
4872                    Fixed::Declined if was_red => {
4873                        let why = format!(
4874                            "the fixer produced no commit while {} check(s) were failing \
4875                             ({}); stopping instead of looping on an unchanged tree",
4876                            pr.failing.len(),
4877                            pr.failing.join(", ")
4878                        );
4879                        stop(state, &repo, &pr, &why).await?;
4880                        return Ok(pr);
4881                    }
4882                    // Comment-driven round with no commit: the fixer read the
4883                    // comments and changed nothing, which is a decision it is
4884                    // allowed to make. The comments are recorded as shown, so
4885                    // the next observation sees a clean pull request.
4886                    Fixed::Declined => state.event(
4887                        "land",
4888                        format!("round {round}: fixer declined the comments, nothing committed"),
4889                    ),
4890                    Fixed::Failed(why) => {
4891                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4892                        return Ok(pr);
4893                    }
4894                }
4895                state.save()?;
4896            }
4897        }
4898    }
4899}
4900
4901/// One observation, plus the two things [`PrState`] deliberately does not carry:
4902/// the title (needed for the squash subject) and where the failing checks'
4903/// logs live.
4904#[derive(Clone)]
4905struct Seen {
4906    pr: PrState,
4907    title: String,
4908    failing_urls: Vec<(String, String)>,
4909    /// `headRefOid`: the commit this observation, checks included, is about.
4910    head: String,
4911    /// The commit the checks belong to, read from the same GraphQL node as
4912    /// the checks themselves. Empty when unreadable.
4913    rollup_head: String,
4914    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
4915    /// BEHIND and DRAFT together, and a stop reason has to say which.
4916    merge_state: String,
4917    /// Every check of the rollup, for naming what an armed merge waits on.
4918    contexts: Vec<CheckInfo>,
4919    /// `baseRefName`, to look up which contexts the base requires.
4920    base: String,
4921}
4922
4923/// One check of the rollup as far as a stop reason needs it.
4924#[derive(Clone)]
4925struct CheckInfo {
4926    label: String,
4927    verdict: Verdict,
4928    required: Option<bool>,
4929    /// The workflow run the check belongs to; `None` for an external status.
4930    run: Option<String>,
4931}
4932
4933/// What to do with a red head before a fix round is spent on it.
4934#[derive(Debug, Clone, PartialEq, Eq)]
4935enum RerunStep {
4936    /// Rerun these workflow runs (each not yet rerun for this head).
4937    Rerun(Vec<String>),
4938    /// Reran already and the forge may still report the old failure: wait.
4939    Grace,
4940    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
4941    Fix,
4942}
4943
4944/// Failed checks a rerun may help: a failure of a check that blocks (`required`
4945/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
4946/// pending checks in another run do not delay this run's rerun.
4947fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4948    contexts
4949        .iter()
4950        .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4951        // `gh run rerun --failed` refuses a run with a job still going.
4952        .filter(|c| {
4953            c.run.as_ref().is_none_or(|r| {
4954                !contexts.iter().any(|o| {
4955                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4956                        && o.run.as_ref() == Some(r)
4957                })
4958            })
4959        })
4960        .map(|c| (c.label.clone(), c.run.clone()))
4961        .collect()
4962}
4963
4964/// Pure: rerun once per run id, wait out the grace, then hand over.
4965fn rerun_step(
4966    failing: &[(String, Option<String>)],
4967    record: Option<&LandReruns>,
4968    head: &str,
4969    now: i64,
4970) -> RerunStep {
4971    // A failure with no workflow run (an external status) cannot be rerun, and
4972    // the fixer's push re-runs everything anyway.
4973    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4974        return RerunStep::Fix;
4975    }
4976    // Reruns belong to the head they were made on.
4977    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4978    let mut fresh: Vec<String> = Vec::new();
4979    let mut recent = false;
4980    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4981        match record.and_then(|r| r.runs.get(run)) {
4982            None => {
4983                if !fresh.iter().any(|f| f == run) {
4984                    fresh.push(run.to_owned());
4985                }
4986            }
4987            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4988            Some(_) => {}
4989        }
4990    }
4991    if !fresh.is_empty() {
4992        RerunStep::Rerun(fresh)
4993    } else if recent {
4994        RerunStep::Grace
4995    } else {
4996        RerunStep::Fix
4997    }
4998}
4999
5000/// Read the pull request: `gh pr view` for the top-level thread and merge
5001/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
5002/// inline review comments `gh pr view` does not report.
5003async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
5004    let view = gh(
5005        repo,
5006        &[
5007            "pr".to_owned(),
5008            "view".to_owned(),
5009            pr_url.to_owned(),
5010            "--json".to_owned(),
5011            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
5012                .to_owned(),
5013        ],
5014    )
5015    .await?;
5016    if !view.0 {
5017        bail!("gh pr view {pr_url}: {}", view.1);
5018    }
5019    let number = parse_pr(&view.1)?.number;
5020    let node = last_commit_node(repo, number).await;
5021    let mut seen = seen_from(&view.1, node.as_deref())?;
5022
5023    let inline = gh(
5024        repo,
5025        &[
5026            "api".to_owned(),
5027            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
5028        ],
5029    )
5030    .await?;
5031    if inline.0 {
5032        match parse_inline_comments(&inline.1) {
5033            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5034            // An unreadable inline thread must not end a landing: the rollup
5035            // and the top-level thread are still real signal.
5036            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5037        }
5038    } else {
5039        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5040    }
5041    Ok(seen)
5042}
5043
5044/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
5045/// response. No I/O.
5046///
5047/// The commit oid and the checks are read from the *same* node, so the rollup
5048/// is bound to the commit it belongs to by construction; two reads that agree
5049/// before and after another response prove nothing about what that response
5050/// held. The view's own rollup is never used. A node that is missing,
5051/// unreadable, carries GraphQL errors, or whose checks run past the page
5052/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
5053/// as "look again" and never as a reason to merge.
5054fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5055    let mut pr = parse_pr(view_json)?;
5056    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5057
5058    let mut rollup_head = String::new();
5059    let mut failing_urls = Vec::new();
5060    let mut contexts = Vec::new();
5061    let mut checks = Checks::Unknown;
5062    let mut failing = Vec::new();
5063    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5064        (checks, failing) = rollup_verdict(&rollup);
5065        failing_urls = rollup
5066            .iter()
5067            .filter(|c| c.verdict() == Verdict::Fail)
5068            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5069            .collect();
5070        contexts = rollup
5071            .iter()
5072            .map(|c| CheckInfo {
5073                label: c.label(),
5074                verdict: c.verdict(),
5075                required: c.is_required,
5076                run: c.url().and_then(run_of),
5077            })
5078            .collect();
5079        rollup_head = oid;
5080    }
5081    pr.checks = checks;
5082    pr.failing = failing;
5083
5084    Ok(Seen {
5085        pr,
5086        title: raw.title,
5087        failing_urls,
5088        head: raw.head_ref_oid,
5089        rollup_head,
5090        merge_state: raw.merge_state_status,
5091        contexts,
5092        base: raw.base_ref_name,
5093    })
5094}
5095
5096/// The last commit's oid and its checks from one GraphQL response, `None`
5097/// when anything about it cannot be trusted.
5098fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5099    let v: serde_json::Value = serde_json::from_str(json).ok()?;
5100    if v.get("errors").is_some_and(|e| !e.is_null()) {
5101        return None;
5102    }
5103    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5104    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5105    let contexts = commit.pointer("/statusCheckRollup/contexts");
5106    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5107        // No rollup at all: the commit has no checks.
5108        return Some((oid.to_owned(), Vec::new()));
5109    };
5110    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5111        return None;
5112    }
5113    let nodes = contexts.get("nodes")?.as_array()?;
5114    let rollup = nodes
5115        .iter()
5116        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5117        .collect::<Result<Vec<_>, _>>()
5118        .ok()?;
5119    Some((oid.to_owned(), rollup))
5120}
5121
5122/// The pull request's last commit and its checks, in one response. `None`
5123/// when the forge could not be asked.
5124async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5125    let out = gh(
5126        repo,
5127        &[
5128            "api".to_owned(),
5129            "graphql".to_owned(),
5130            "-F".to_owned(),
5131            "owner={owner}".to_owned(),
5132            "-F".to_owned(),
5133            "repo={repo}".to_owned(),
5134            "-F".to_owned(),
5135            format!("number={number}"),
5136            "-f".to_owned(),
5137            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5138             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5139             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5140             ... on CheckRun{name status conclusion detailsUrl \
5141             isRequired(pullRequestNumber:$number)} \
5142             ... on StatusContext{context state targetUrl \
5143             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5144                .to_owned(),
5145        ],
5146    )
5147    .await
5148    .ok()?;
5149    out.0.then_some(out.1)
5150}
5151
5152/// What a fix round did.
5153#[doc(hidden)]
5154#[derive(Debug, PartialEq)]
5155pub enum Fixed {
5156    /// The fixer committed something, and this is the head that was pushed.
5157    Committed {
5158        /// The commit now at the tip of the pushed branch.
5159        head: String,
5160    },
5161    /// The fixer ran and chose to change nothing.
5162    Declined,
5163    /// The fixer could not run, or said nothing usable.
5164    Failed(String),
5165}
5166
5167/// Hand the failures and the comments to the fixer, then commit and push.
5168///
5169/// The fixer works in the winner's own worktree so its commits land on the
5170/// branch the pull request is built from, and it runs with `allow_write` for
5171/// the same reason.
5172#[doc(hidden)]
5173pub async fn fix_round(
5174    state: &mut RunState,
5175    pr: &PrState,
5176    round: usize,
5177    budget: usize,
5178    reason: &str,
5179    logs: &str,
5180) -> Result<Fixed> {
5181    let winner = state
5182        .winner()
5183        .cloned()
5184        .context("landing needs a winning candidate; none is recorded on this run")?;
5185    let roles = state
5186        .config
5187        .resolve_roles()
5188        .context("resolve the roster for the fix round")?;
5189    // Same chain as the review loop (see `crate::fixer`): the configured
5190    // fixers in order, otherwise the winner's own author continuing its own
5191    // conversation - the competition is over, so its context is pure benefit.
5192    let attempts = crate::fixer::attempts(state, &roles, &winner);
5193    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5194
5195    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5196    let mut artifacts = agent::artifacts_dir(&state.dir());
5197    if let Some(request) = state
5198        .merge_repairs
5199        .last()
5200        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5201    {
5202        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5203    }
5204    let prompt = if state.config.cache_dir().is_some() {
5205        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5206    } else {
5207        prompt
5208    };
5209    // Read before the fixer runs: it normally commits for itself, so HEAD has
5210    // already moved by the time it returns and a later read would see no
5211    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
5212    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5213    // Each id at most once, forward only: the next one is asked only when the
5214    // call advances, and only the last attempt's result is judged below, so an
5215    // exhausted chain ends as a single failed fixer does.
5216    let mut last = None;
5217    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5218        if owner_requested_stop(state)? {
5219            return Ok(Fixed::Declined);
5220        }
5221        let mut seat = seat_of(state, &seat_key, &spec.id);
5222        let stem = if i == 0 {
5223            format!("land-{round}")
5224        } else {
5225            format!("land-{round}-{}", spec.id)
5226        };
5227        let out = agent::invoke(
5228            &spec,
5229            &mut seat,
5230            &Invocation {
5231                scope: crate::agent::InvocationScope::Repository,
5232                coordination: state.config.coordination.as_ref(),
5233                fetched_base: state.fetched_base.as_ref(),
5234                cwd: &winner.worktree,
5235                prompt: &prompt,
5236                timeout: Duration::from_secs(state.config.graph.timeout_fix),
5237                allow_write: true,
5238                unsandboxed: false,
5239                sessions: state.config.graph.sessions,
5240                artifacts: &artifacts,
5241                stem: &stem,
5242                run: &state.id,
5243                node: "land",
5244                cache_dir: state.config.cache_dir().as_deref(),
5245                attachments: &[],
5246                writable: &[],
5247            },
5248        )
5249        .await;
5250        state.seats.insert(seat.key.clone(), seat);
5251        if let Some(next) = ids.get(i + 1)
5252            && agent::chain_advances(&out)
5253        {
5254            let (class, why) = crate::fixer::failure_of(&out);
5255            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5256            continue;
5257        }
5258        last = Some(out);
5259        break;
5260    }
5261    let out = last.expect("the fixer chain always has an entry");
5262
5263    match out {
5264        Ok(o) if o.quota_exhausted() => {
5265            return Ok(Fixed::Failed(
5266                "rate limited (quota); the fixer could not run".to_owned(),
5267            ));
5268        }
5269        Ok(o) if !o.usable() => {
5270            return Ok(Fixed::Failed(format!(
5271                "the fixer produced nothing usable ({})",
5272                crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5273            )));
5274        }
5275        Ok(_) => {}
5276        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5277        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5278    }
5279
5280    if owner_requested_stop(state)? {
5281        return Ok(Fixed::Declined);
5282    }
5283
5284    // An agent that edited files but never committed would otherwise push
5285    // nothing and look like a refusal.
5286    let wait = crate::repo_lock::WaitPolicy::new(
5287        Duration::from_secs(state.config.graph.timeout_resource_wait),
5288        || false,
5289    );
5290    let coordination = crate::repo_lock::SeatLease::operation_with(
5291        &winner.worktree,
5292        state.config.coordination.as_ref(),
5293        &wait,
5294    )
5295    .await?;
5296    match git::rescue_commit(
5297        &winner.worktree,
5298        &format!("magi: land round {round} fixes (uncommitted work)"),
5299    )
5300    .await
5301    {
5302        Ok(r) => state.note_withheld("land", &r.withheld),
5303        Err(e)
5304            if state
5305                .merge_repairs
5306                .last()
5307                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5308        {
5309            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5310        }
5311        Err(_) => {}
5312    }
5313    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5314    drop(coordination);
5315    if after == before {
5316        return Ok(Fixed::Declined);
5317    }
5318
5319    if owner_requested_stop(state)? {
5320        return Ok(Fixed::Declined);
5321    }
5322    crate::pr_head::ensure_owner(state)?;
5323    let remote = state.config.merge.remote.clone();
5324    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5325    let push = if state.pr_head.is_some() {
5326        let expected = state
5327            .pr_pushed_head
5328            .as_deref()
5329            .context("missing public head lease")?;
5330        git::push_as(
5331            &winner.worktree,
5332            &remote,
5333            &winner.branch,
5334            &public_head,
5335            expected,
5336        )
5337        .await?
5338    } else {
5339        git::push(&winner.worktree, &remote, &winner.branch).await?
5340    };
5341    if !push.ok() {
5342        return Ok(Fixed::Failed(format!(
5343            "pushing {} to {remote} failed: {}",
5344            public_head, push.stderr
5345        )));
5346    }
5347    state.event(
5348        "land",
5349        format!("round {round}: pushed a fix to {public_head}"),
5350    );
5351    state.pr_pushed_head = Some(after.clone());
5352    state.save()?;
5353    Ok(Fixed::Committed { head: after })
5354}
5355
5356/// Fetch or create a seat, keeping its conversation across nodes.
5357pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5358    crate::fixer::seat_for(state, key, agent)
5359}
5360
5361/// Recheck the live PR and local worktree before spending the requested fix.
5362/// A moved/closed PR is held, never repaired using obsolete evidence.
5363pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5364    let seen = observe(&state.repo, url).await?;
5365    let winner = state.winner().context("no winning branch to repair")?;
5366    if seen.pr.state != PrLifecycle::Open || seen.head != head {
5367        anyhow::bail!(
5368            "PR is closed or its head changed; review the current head before requesting repair again"
5369        );
5370    }
5371    if git::rev_parse(&winner.worktree, "HEAD").await? != head
5372        || !git::is_clean(&winner.worktree).await?
5373    {
5374        anyhow::bail!(
5375            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5376        );
5377    }
5378    // Always disable any forge-side reservation, including one not recorded
5379    // by this run. A repair request is never merge authorization.
5380    let view = gh(
5381        &state.repo,
5382        &[
5383            "pr".into(),
5384            "view".into(),
5385            url.into(),
5386            "--json".into(),
5387            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5388        ],
5389    )
5390    .await?;
5391    if !view.0 {
5392        bail!("cannot check auto-merge: {}", view.1);
5393    }
5394    let live: serde_json::Value = serde_json::from_str(&view.1)?;
5395    let public = state.pr_branch().unwrap_or(&winner.branch);
5396    if live["state"] != "OPEN"
5397        || live["headRefOid"] != head
5398        || live["headRefName"] != public
5399        || live["baseRefName"] != state.base_branch
5400        || live["isCrossRepository"] != false
5401    {
5402        bail!("PR changed during repair preflight; review its current head explicitly");
5403    }
5404    let auto_merge = live
5405        .get("autoMergeRequest")
5406        .context("cannot read the PR auto-merge reservation; repair held")?;
5407    if !auto_merge.is_null() {
5408        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5409        if !out.0 {
5410            bail!("cannot disable auto-merge; repair held: {}", out.1);
5411        }
5412    }
5413    Ok(seen.pr)
5414}
5415
5416/// What the fixer is told.
5417fn fix_prompt(
5418    state: &RunState,
5419    pr: &PrState,
5420    round: usize,
5421    budget: usize,
5422    reason: &str,
5423    logs: &str,
5424) -> String {
5425    let mut s = format!(
5426        "Your patch is open as a pull request and it is not landing. Land round \
5427         {round} of {budget}.\n\n\
5428         Pull request: {}\n\n\
5429         What is holding it: {reason}\n\n\
5430         # The task\n\n{}\n",
5431        pr.url, state.instruction
5432    );
5433
5434    if pr.failing.is_empty() {
5435        s.push_str("\n# Failing checks\n\n(none)\n");
5436    } else {
5437        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5438        if logs.trim().is_empty() {
5439            s.push_str(
5440                "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5441            );
5442        } else {
5443            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5444        }
5445    }
5446
5447    if pr.review_comments.is_empty() {
5448        s.push_str("\n# Review comments\n\n(none)\n");
5449    } else {
5450        s.push_str("\n# Review comments\n");
5451        for c in &pr.review_comments {
5452            let where_ = match (&c.path, c.line) {
5453                (Some(p), Some(l)) => format!(" ({p}:{l})"),
5454                (Some(p), None) => format!(" ({p})"),
5455                _ => String::new(),
5456            };
5457            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5458        }
5459    }
5460
5461    s.push_str(
5462        "\n# Rules\n\n\
5463         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5464            failing test; do not silence a lint with an allow attribute; do not \
5465            stretch a timeout to hide a race. If the check is right, the code is \
5466            wrong.\n\
5467         2. Change nothing the checks and the comments did not raise. A \
5468            drive-by refactor turns a one-line fix into a pull request that \
5469            needs reviewing again.\n\
5470         3. If a comment is wrong, say so with a checkable argument and change \
5471            nothing for it. A declined comment with a reason is a correct \
5472            outcome; a change made to appease a reviewer is not.\n\
5473         4. Commit in this worktree. magi pushes to the pull request's branch \
5474            for you; do not push, merge, or close anything yourself.\n\
5475         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5476         # Output\n\n\
5477         Say what you changed and why, and what you declined and why.",
5478    );
5479
5480    let language = &state.config.graph.language;
5481    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5482        let _ = write!(s, "\n\nWrite all prose in {language}.");
5483    }
5484    // After the language line, so the exception is the last word on it.
5485    s.push_str(&crate::prompt::github_rule(
5486        language,
5487        &state.config.graph.github_language,
5488    ));
5489    if let Some(overlay) = state.config.prompts.overlay("fix") {
5490        let _ = write!(s, "\n\n{overlay}");
5491    }
5492    s.push('\n');
5493    s.push_str(crate::prompt::child_run_ownership());
5494    s
5495}
5496
5497/// Failing log tails, the way the operator collects them by hand:
5498/// `gh run view --log-failed`.
5499async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5500    let mut out = String::new();
5501    for (name, url) in failing.iter().take(MAX_LOGS) {
5502        let args = match (job_of(url), run_of(url)) {
5503            (Some(job), _) => vec![
5504                "run".to_owned(),
5505                "view".to_owned(),
5506                "--log-failed".to_owned(),
5507                "--job".to_owned(),
5508                job,
5509            ],
5510            (None, Some(run)) => vec![
5511                "run".to_owned(),
5512                "view".to_owned(),
5513                run,
5514                "--log-failed".to_owned(),
5515            ],
5516            // Not a GitHub Actions check - an external status has no log here.
5517            (None, None) => continue,
5518        };
5519        let (ok, body) = match gh(repo, &args).await {
5520            Ok(v) => v,
5521            Err(e) => (false, format!("{e:#}")),
5522        };
5523        if !ok && body.trim().is_empty() {
5524            continue;
5525        }
5526        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5527    }
5528    out
5529}
5530
5531/// Job id out of a check's `detailsUrl`
5532/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
5533fn job_of(details_url: &str) -> Option<String> {
5534    let after = details_url.split("/job/").nth(1)?;
5535    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5536    (!id.is_empty()).then_some(id)
5537}
5538
5539/// Workflow run id out of a check's `detailsUrl`.
5540pub(crate) fn run_of(details_url: &str) -> Option<String> {
5541    let after = details_url.split("/actions/runs/").nth(1)?;
5542    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5543    (!id.is_empty()).then_some(id)
5544}
5545
5546/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
5547/// it lands on GitHub, not in front of the operator. Pure so a test can hold
5548/// it to that.
5549fn stop_comment(run_id: &str, why: &str) -> String {
5550    format!(
5551        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5552         The branch is untouched and the run is `{run_id}`. Nothing was merged."
5553    )
5554}
5555
5556/// Leave the pull request open, say why on it, and mark the run blocked.
5557///
5558/// The comment is what makes an unattended stop actionable: the operator wakes
5559/// up to a pull request that explains itself rather than to a silent queue.
5560async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5561    let body = stop_comment(&state.id, why);
5562    let (_, mut body) = crate::github_text::prepare(state, "", &body);
5563    if !body.contains(MARKER) {
5564        body = format!("{MARKER}\n{body}");
5565    }
5566    let posted = gh(
5567        repo,
5568        &[
5569            "pr".to_owned(),
5570            "comment".to_owned(),
5571            pr.number.to_string(),
5572            "--body".to_owned(),
5573            body,
5574        ],
5575    )
5576    .await;
5577    match posted {
5578        Ok((true, _)) => {}
5579        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5580        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5581    }
5582    state.status = RunStatus::Blocked;
5583    state.merge = Some(MergeOutcome {
5584        mode: MergeMode::Pr,
5585        ok: false,
5586        detail: why.to_owned(),
5587        empty: false,
5588    });
5589    state.event("land", format!("stopped: {why}"));
5590    state.save()?;
5591    Ok(())
5592}
5593
5594/// Run `gh` in `repo`, returning success and the combined output.
5595///
5596/// Combined because `gh` reports a refused merge on stderr and the pull request
5597/// json on stdout, and both are evidence.
5598///
5599/// `GH_REPO` is stripped from the child's environment: every call site here
5600/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
5601/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
5602/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
5603/// `GH_REPO` the operator happens to have exported for an unrelated script
5604/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
5605/// below) to a different repository than the one actually on disk - which
5606/// for the same-repo guard in [`correct_manual_merge`] would mean the check
5607/// could be made to agree with whatever repository a forged `--merged` URL
5608/// claims, defeating it entirely.
5609pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5610    let out = tokio::process::Command::new("gh")
5611        .args(args)
5612        .current_dir(cwd)
5613        .env_remove("GH_REPO")
5614        .quiet()
5615        .stdin(std::process::Stdio::null())
5616        .output()
5617        .await
5618        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5619    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5620    let err = String::from_utf8_lossy(&out.stderr);
5621    if body.trim().is_empty() {
5622        body = err.into_owned();
5623    } else if !err.trim().is_empty() {
5624        body.push_str(&err);
5625    }
5626    Ok((out.status.success(), body.trim().to_owned()))
5627}
5628
5629/// Verdict of one entry in the status rollup.
5630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5631pub(crate) enum Verdict {
5632    Pass,
5633    Fail,
5634    Pending,
5635    Unknown,
5636}
5637
5638impl Verdict {
5639    /// Short operator-facing check result.
5640    pub(crate) fn label(self) -> &'static str {
5641        match self {
5642            Self::Pass => "passed",
5643            Self::Fail => "failed",
5644            Self::Pending => "pending",
5645            Self::Unknown => "unknown",
5646        }
5647    }
5648}
5649
5650#[derive(Debug, Deserialize)]
5651#[serde(rename_all = "camelCase")]
5652struct GhPr {
5653    #[serde(default)]
5654    url: String,
5655    #[serde(default)]
5656    number: u64,
5657    #[serde(default)]
5658    state: String,
5659    #[serde(default)]
5660    title: String,
5661    #[serde(default)]
5662    status_check_rollup: Vec<GhCheck>,
5663    /// GitHub's own verdict on whether the pull request can be merged.
5664    ///
5665    /// Worth asking for because it is the only place the *required* check set
5666    /// is applied: the rollup lists every check equally, so a repository that
5667    /// deliberately does not require `coverage` still looks red here. See
5668    /// [`Blocking`].
5669    #[serde(default)]
5670    merge_state_status: String,
5671    /// The commit the pull request currently points at. Compared with the
5672    /// commit a fix round pushed, it is how the loop knows the forge has moved
5673    /// on and the rollup belongs to the new head.
5674    #[serde(default)]
5675    head_ref_oid: String,
5676    #[serde(default)]
5677    base_ref_name: String,
5678    #[serde(default)]
5679    reviews: Vec<GhReview>,
5680    #[serde(default)]
5681    comments: Vec<GhComment>,
5682}
5683
5684/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
5685/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
5686/// status API, which is how CodeRabbit reports - has `context`/`state` and no
5687/// conclusion at all.
5688#[derive(Debug, Deserialize)]
5689#[serde(rename_all = "camelCase")]
5690struct GhCheck {
5691    #[serde(default)]
5692    name: Option<String>,
5693    #[serde(default)]
5694    context: Option<String>,
5695    #[serde(default)]
5696    status: Option<String>,
5697    #[serde(default)]
5698    conclusion: Option<String>,
5699    #[serde(default)]
5700    state: Option<String>,
5701    #[serde(default)]
5702    details_url: Option<String>,
5703    #[serde(default)]
5704    target_url: Option<String>,
5705    /// Whether the base branch requires this check. Only the GraphQL node
5706    /// carries it; `None` is "not read", never "not required".
5707    #[serde(default)]
5708    is_required: Option<bool>,
5709}
5710
5711impl GhCheck {
5712    /// Name to show a human and hand to the fixer.
5713    fn label(&self) -> String {
5714        self.name
5715            .clone()
5716            .or_else(|| self.context.clone())
5717            .unwrap_or_else(|| "(unnamed check)".to_owned())
5718    }
5719
5720    /// Where this check's logs live, when it has any.
5721    fn url(&self) -> Option<&str> {
5722        self.details_url
5723            .as_deref()
5724            .or(self.target_url.as_deref())
5725            .filter(|u| !u.is_empty())
5726    }
5727
5728    /// Did it pass?
5729    ///
5730    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
5731    /// skips release and bot pull requests by design, and a skip that blocked
5732    /// landing would block exactly the pull requests that need no review.
5733    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
5734    /// merging over one is merging over a check that never ran.
5735    fn verdict(&self) -> Verdict {
5736        if let Some(status) = self.status.as_deref() {
5737            if !status.eq_ignore_ascii_case("COMPLETED") {
5738                return Verdict::Pending;
5739            }
5740        }
5741        let outcome = self
5742            .conclusion
5743            .as_deref()
5744            .or(self.state.as_deref())
5745            .unwrap_or("");
5746        match outcome.to_ascii_uppercase().as_str() {
5747            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5748            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5749            | "ACTION_REQUIRED" => Verdict::Fail,
5750            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5751                Verdict::Pending
5752            }
5753            _ => Verdict::Unknown,
5754        }
5755    }
5756}
5757
5758#[derive(Debug, Deserialize)]
5759struct GhAuthor {
5760    #[serde(default)]
5761    login: String,
5762}
5763
5764#[derive(Debug, Deserialize)]
5765struct GhReview {
5766    #[serde(default)]
5767    author: GhAuthor,
5768    #[serde(default)]
5769    body: String,
5770}
5771
5772#[derive(Debug, Deserialize)]
5773struct GhComment {
5774    #[serde(default)]
5775    author: GhAuthor,
5776    #[serde(default)]
5777    body: String,
5778}
5779
5780#[derive(Debug, Deserialize)]
5781struct GhUser {
5782    #[serde(default)]
5783    login: String,
5784}
5785
5786#[derive(Debug, Deserialize)]
5787struct GhInline {
5788    #[serde(default)]
5789    user: GhUser,
5790    #[serde(default)]
5791    path: Option<String>,
5792    #[serde(default)]
5793    line: Option<u64>,
5794    #[serde(default)]
5795    body: String,
5796}
5797
5798impl Default for GhAuthor {
5799    fn default() -> Self {
5800        Self {
5801            login: "(unknown)".to_owned(),
5802        }
5803    }
5804}
5805
5806impl Default for GhUser {
5807    fn default() -> Self {
5808        Self {
5809            login: "(unknown)".to_owned(),
5810        }
5811    }
5812}
5813
5814#[cfg(test)]
5815pub(crate) mod tests {
5816    use super::*;
5817    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5818
5819    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5820        format!(
5821            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5822        )
5823    }
5824
5825    #[test]
5826    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5827        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5828        assert_eq!(
5829            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5830            Ok(())
5831        );
5832        for (json, why) in [
5833            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5834            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5835            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5836            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5837            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5838            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5839        ] {
5840            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5841                .unwrap_err()
5842                .why;
5843            assert!(err.contains(why), "{json}: {err}");
5844        }
5845        // A head that moved on past what was verified is left alone.
5846        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5847        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5848        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5849        assert!(
5850            !closable(
5851                &head_json("x", "main", "OPEN", false),
5852                "magi/27b2/A",
5853                "main",
5854                &[]
5855            )
5856            .unwrap_err()
5857            .retry
5858        );
5859        assert!(is_forge_url("https://github.com/o/r.git"));
5860        assert!(is_forge_url("git@github.com:o/r.git"));
5861        assert!(!is_forge_url("/tmp/origin.git"));
5862        assert!(!is_forge_url("C:\\work\\origin.git"));
5863        assert!(!is_forge_url("file:///tmp/origin.git"));
5864        assert!(forge_unavailable(
5865            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5866        ));
5867        assert!(!forge_unavailable(
5868            "gh pr list failed: error connecting to api.github.com"
5869        ));
5870        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5871        // A record that does not say whether it is a fork is not trusted.
5872        assert!(
5873            closable(
5874                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5875                "b",
5876                "main",
5877                &["aaa".to_owned()]
5878            )
5879            .is_err()
5880        );
5881    }
5882
5883    #[test]
5884    fn the_close_comment_names_the_commit_on_the_base() {
5885        let e = crate::already::Evidence {
5886            proof: crate::already::Proof::PatchId,
5887            tip: "1234567890".to_owned(),
5888            commits: vec!["0e368de0000".to_owned()],
5889        };
5890        let c = superseded_comment("main", &e);
5891        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5892    }
5893
5894    /// 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.
5895    const GREEN_OPEN: &str = r####"{
5896  "url": "https://github.com/yukimemi/magi/pull/10",
5897  "number": 10,
5898  "state": "OPEN",
5899  "mergeStateStatus": "CLEAN",
5900  "statusCheckRollup": [
5901    {
5902      "__typename": "CheckRun",
5903      "conclusion": "SKIPPED",
5904      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5905      "name": "review",
5906      "status": "COMPLETED",
5907      "workflowName": "claude-review"
5908    },
5909    {
5910      "__typename": "CheckRun",
5911      "conclusion": "SUCCESS",
5912      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5913      "name": "check (ubuntu-latest)",
5914      "status": "COMPLETED",
5915      "workflowName": "CI"
5916    },
5917    {
5918      "__typename": "CheckRun",
5919      "conclusion": "SUCCESS",
5920      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5921      "name": "rustfmt",
5922      "status": "COMPLETED",
5923      "workflowName": "CI"
5924    },
5925    {
5926      "__typename": "StatusContext",
5927      "context": "CodeRabbit",
5928      "state": "SUCCESS",
5929      "targetUrl": ""
5930    }
5931  ],
5932  "reviews": [],
5933  "comments": [
5934    {
5935      "author": {
5936        "login": "coderabbitai"
5937      },
5938      "authorAssociation": "NONE",
5939      "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"
5940    }
5941  ]
5942}"####;
5943
5944    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
5945    const RED_OPEN: &str = r####"{
5946  "url": "https://github.com/yukimemi/magi/pull/9",
5947  "number": 9,
5948  "state": "OPEN",
5949  "mergeStateStatus": "UNSTABLE",
5950  "statusCheckRollup": [
5951    {
5952      "__typename": "CheckRun",
5953      "conclusion": "SUCCESS",
5954      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5955      "name": "check (ubuntu-latest)",
5956      "status": "COMPLETED",
5957      "workflowName": "CI"
5958    },
5959    {
5960      "__typename": "CheckRun",
5961      "conclusion": "SUCCESS",
5962      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5963      "name": "rustfmt",
5964      "status": "COMPLETED",
5965      "workflowName": "CI"
5966    },
5967    {
5968      "__typename": "CheckRun",
5969      "conclusion": "FAILURE",
5970      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5971      "name": "editorconfig",
5972      "status": "COMPLETED",
5973      "workflowName": "CI"
5974    },
5975    {
5976      "__typename": "StatusContext",
5977      "context": "CodeRabbit",
5978      "state": "SUCCESS",
5979      "targetUrl": ""
5980    }
5981  ],
5982  "reviews": [],
5983  "comments": [
5984    {
5985      "author": {
5986        "login": "coderabbitai"
5987      },
5988      "authorAssociation": "NONE",
5989      "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"
5990    }
5991  ]
5992}"####;
5993
5994    /// 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.
5995    const PENDING_OPEN: &str = r####"{
5996  "url": "https://github.com/yukimemi/magi/pull/9",
5997  "number": 9,
5998  "state": "OPEN",
5999  "statusCheckRollup": [
6000    {
6001      "__typename": "CheckRun",
6002      "conclusion": "SUCCESS",
6003      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6004      "name": "check (ubuntu-latest)",
6005      "status": "COMPLETED",
6006      "workflowName": "CI"
6007    },
6008    {
6009      "__typename": "CheckRun",
6010      "conclusion": "SUCCESS",
6011      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6012      "name": "rustfmt",
6013      "status": "COMPLETED",
6014      "workflowName": "CI"
6015    },
6016    {
6017      "__typename": "CheckRun",
6018      "conclusion": null,
6019      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6020      "name": "editorconfig",
6021      "status": "IN_PROGRESS",
6022      "workflowName": "CI"
6023    },
6024    {
6025      "__typename": "StatusContext",
6026      "context": "CodeRabbit",
6027      "state": "SUCCESS",
6028      "targetUrl": ""
6029    }
6030  ],
6031  "reviews": [],
6032  "comments": []
6033}"####;
6034
6035    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
6036    const MERGED: &str = r####"{
6037  "url": "https://github.com/yukimemi/magi/pull/16",
6038  "number": 16,
6039  "state": "MERGED",
6040  "statusCheckRollup": [
6041    {
6042      "__typename": "CheckRun",
6043      "conclusion": "SUCCESS",
6044      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6045      "name": "check (ubuntu-latest)",
6046      "status": "COMPLETED",
6047      "workflowName": "CI"
6048    },
6049    {
6050      "__typename": "CheckRun",
6051      "conclusion": "SUCCESS",
6052      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6053      "name": "review",
6054      "status": "COMPLETED",
6055      "workflowName": "claude-review"
6056    }
6057  ],
6058  "reviews": [],
6059  "comments": []
6060}"####;
6061
6062    /// 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.
6063    const REVIEWED_OPEN: &str = r####"{
6064  "url": "https://github.com/yukimemi/magi/pull/12",
6065  "number": 12,
6066  "state": "OPEN",
6067  "statusCheckRollup": [
6068    {
6069      "__typename": "CheckRun",
6070      "conclusion": "SUCCESS",
6071      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6072      "name": "check (ubuntu-latest)",
6073      "status": "COMPLETED",
6074      "workflowName": "CI"
6075    },
6076    {
6077      "__typename": "CheckRun",
6078      "conclusion": "SUCCESS",
6079      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6080      "name": "review",
6081      "status": "COMPLETED",
6082      "workflowName": "claude-review"
6083    }
6084  ],
6085  "reviews": [
6086    {
6087      "author": {
6088        "login": "claude"
6089      },
6090      "state": "COMMENTED",
6091      "body": ""
6092    }
6093  ],
6094  "comments": [
6095    {
6096      "author": {
6097        "login": "coderabbitai"
6098      },
6099      "authorAssociation": "NONE",
6100      "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"
6101    },
6102    {
6103      "author": {
6104        "login": "claude"
6105      },
6106      "authorAssociation": "NONE",
6107      "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"
6108    }
6109  ]
6110}"####;
6111
6112    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
6113    const INLINE: &str = r####"[
6114  {
6115    "user": {
6116      "login": "claude[bot]"
6117    },
6118    "path": "src/graph.rs",
6119    "line": 231,
6120    "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"
6121  }
6122]"####;
6123
6124    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
6125    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6126<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6127
6128> [!IMPORTANT]
6129> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6130> 
6131> This repository does not receive automatic reviews because it has fewer than 10 stars.
6132> 
6133> <details>
6134> <summary>⚙️ Run configuration</summary>
6135> 
6136> **Configuration used**: defaults
6137> 
6138> **Review profile**: CHILL
6139> 
6140> **Plan**: Team
6141> 
6142> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6143> 
6144> </details>
6145
6146<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6147
6148<!-- tips_start -->
6149
6150---
6151
6152Thanks 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.
6153
6154<details>
6155<summary>❤️ Share</summary>
6156
6157- [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"####;
6158
6159    /// 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.
6160    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6161
6162---
6163### Reviewing PR #16
6164
6165- [x] Read AGENTS.md conventions
6166- [x] Review `src/daemon.rs` changes
6167- [x] Review `src/main.rs` changes (new `doctor` reporting)
6168- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6169- [x] Check test coverage for new behavior
6170- [x] Run verification commands (blocked — see note)
6171- [x] Post findings"####;
6172
6173    /// The same job's real comment on pull request #12 once it had something to say.
6174    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6175
6176---
6177### Review: `magi review <branch>` — cheap-half-only graph
6178
6179Read 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.
6180
6181**Correctness**
6182
6183- 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"####;
6184
6185    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6186        PrState {
6187            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6188            number: 16,
6189            state: PrLifecycle::Open,
6190            checks,
6191            // These tests are about red-means-fix, so a red here is one the
6192            // forge gates on. Without saying so they would assert the new
6193            // "merge past a check nobody requires" path by accident.
6194            blocking: if matches!(checks, Checks::Red) {
6195                Blocking::Yes
6196            } else {
6197                Blocking::No
6198            },
6199            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6200            review_comments: (0..comments)
6201                .map(|i| ReviewComment {
6202                    author: "coderabbitai".to_owned(),
6203                    path: Some("src/graph.rs".to_owned()),
6204                    line: Some(231),
6205                    body: format!("finding {i}"),
6206                })
6207                .collect(),
6208        }
6209    }
6210
6211    #[test]
6212    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6213        use CiExpectation::{Absent, Expected};
6214        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6215            let p = pr(checks, &["x"], 0);
6216            for waited in [Duration::ZERO, CHECKS_GRACE] {
6217                assert_eq!(
6218                    decide_with(&p, 0, 4, waited, Expected),
6219                    decide(&p, 0, 4, waited)
6220                );
6221            }
6222        }
6223        // Nothing will ever report: no wait, no give-up, no fix round.
6224        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6225            let p = pr(checks, &["x"], 0);
6226            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6227            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6228        }
6229        // A conflict is not a check: it still wants the rebase.
6230        let mut p = pr(Checks::Unknown, &[], 0);
6231        p.blocking = Blocking::Conflict;
6232        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6233        // And a pull request that left our hands is still done.
6234        p.state = PrLifecycle::Merged;
6235        assert_eq!(
6236            decide_with(&p, 0, 4, Duration::ZERO, Absent),
6237            Step::Done { merged: true }
6238        );
6239    }
6240
6241    #[test]
6242    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6243        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6244        assert_eq!(state.number, 10);
6245        assert_eq!(state.state, PrLifecycle::Open);
6246        assert_eq!(state.checks, Checks::Green);
6247        assert!(state.failing.is_empty());
6248        assert!(
6249            state.review_comments.is_empty(),
6250            "the only comment is CodeRabbit's trigger notice: {:?}",
6251            state.review_comments
6252        );
6253        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6254    }
6255
6256    #[test]
6257    fn a_failing_check_parses_as_red_and_is_named() {
6258        let state = parse_pr(RED_OPEN).expect("red fixture parses");
6259        assert_eq!(state.checks, Checks::Red);
6260        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6261        // The captured payload says `UNSTABLE` - mergeable, with a check
6262        // nobody requires red - which is exactly the shape that had to be
6263        // merged by hand. Asserted separately, in
6264        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
6265        // test is about is that a red check is *named*, so the reason a fixer
6266        // is handed says which one; so it asks the blocking question here.
6267        let mut blocking = state.clone();
6268        blocking.blocking = Blocking::Yes;
6269        match decide(&blocking, 0, 4, Duration::ZERO) {
6270            Step::Fix { reason } => {
6271                assert!(reason.contains("editorconfig"), "reason: {reason}");
6272                assert!(reason.contains("failing"), "reason: {reason}");
6273            }
6274            other => panic!("expected a fix round, got {other:?}"),
6275        }
6276    }
6277
6278    #[test]
6279    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6280        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6281        assert_eq!(state.checks, Checks::Pending);
6282        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6283    }
6284
6285    #[test]
6286    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6287        let state = parse_pr(MERGED).expect("merged fixture parses");
6288        assert_eq!(state.state, PrLifecycle::Merged);
6289        assert_eq!(
6290            decide(&state, 0, 4, Duration::ZERO),
6291            Step::Done { merged: true }
6292        );
6293    }
6294
6295    #[test]
6296    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6297        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6298        assert_eq!(state.checks, Checks::Green);
6299        let authors: Vec<&str> = state
6300            .review_comments
6301            .iter()
6302            .map(|c| c.author.as_str())
6303            .collect();
6304        assert_eq!(
6305            authors,
6306            vec!["claude"],
6307            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6308        );
6309        match decide(&state, 0, 4, Duration::ZERO) {
6310            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6311            other => panic!("expected a fix round, got {other:?}"),
6312        }
6313    }
6314
6315    #[test]
6316    fn inline_review_comments_keep_their_file_and_line() {
6317        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6318        assert_eq!(comments.len(), 1);
6319        assert_eq!(comments[0].author, "claude[bot]");
6320        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6321        assert_eq!(comments[0].line, Some(231));
6322        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6323    }
6324
6325    #[test]
6326    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6327        assert!(
6328            is_noise(CODERABBIT_TRIGGER),
6329            "CodeRabbit's trigger notice declares itself not a review"
6330        );
6331        assert!(
6332            is_noise(CLAUDE_CHECKLIST),
6333            "a progress checklist asks for nothing"
6334        );
6335        assert!(
6336            !is_noise(CLAUDE_FINDING),
6337            "a review that names a bug is input, not noise"
6338        );
6339
6340        let mut clean = pr(Checks::Green, &[], 0);
6341        clean.review_comments.push(ReviewComment {
6342            author: "coderabbitai".to_owned(),
6343            path: None,
6344            line: None,
6345            body: CODERABBIT_TRIGGER.to_owned(),
6346        });
6347        clean.review_comments.retain(|c| !is_noise(&c.body));
6348        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6349
6350        let mut found = pr(Checks::Green, &[], 0);
6351        found.review_comments.push(ReviewComment {
6352            author: "claude".to_owned(),
6353            path: None,
6354            line: None,
6355            body: CLAUDE_FINDING.to_owned(),
6356        });
6357        found.review_comments.retain(|c| !is_noise(&c.body));
6358        assert!(matches!(
6359            decide(&found, 0, 4, Duration::ZERO),
6360            Step::Fix { .. }
6361        ));
6362    }
6363
6364    #[test]
6365    fn the_policy_table_holds_for_every_combination_that_matters() {
6366        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6367            (
6368                "pending checks are waited for, even on the last round",
6369                pr(Checks::Pending, &[], 0),
6370                4,
6371                4,
6372                Duration::ZERO,
6373                Step::Wait,
6374            ),
6375            (
6376                "red checks are fixed",
6377                pr(Checks::Red, &["editorconfig"], 0),
6378                0,
6379                4,
6380                Duration::ZERO,
6381                Step::Fix {
6382                    reason: "1 check(s) failing: editorconfig".to_owned(),
6383                },
6384            ),
6385            (
6386                "green with comments is fixed, not merged",
6387                pr(Checks::Green, &[], 2),
6388                1,
6389                4,
6390                Duration::ZERO,
6391                Step::Fix {
6392                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6393                        .to_owned(),
6394                },
6395            ),
6396            (
6397                "green and clean merges",
6398                pr(Checks::Green, &[], 0),
6399                3,
6400                4,
6401                Duration::ZERO,
6402                Step::Merge,
6403            ),
6404            (
6405                "an unreadable rollup is waited on while the grace lasts",
6406                pr(Checks::Unknown, &[], 0),
6407                0,
6408                4,
6409                Duration::ZERO,
6410                Step::Wait,
6411            ),
6412            (
6413                "an unreadable rollup is never merged once the grace is spent",
6414                pr(Checks::Unknown, &[], 0),
6415                0,
6416                4,
6417                CHECKS_GRACE,
6418                Step::GiveUp {
6419                    reason: "no check status is readable on the pull request after 3 minute(s); \
6420                             refusing to merge on a guess"
6421                        .to_owned(),
6422                },
6423            ),
6424        ];
6425        for (what, state, round, budget, waited, want) in cases {
6426            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6427        }
6428    }
6429
6430    #[test]
6431    fn the_forge_verdict_survives_the_round_trip_from_gh() {
6432        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
6433        // requested but never parsed is the kind of thing that looks wired up
6434        // and answers `Unsaid` forever.
6435        let green = parse_pr(GREEN_OPEN).expect("parse");
6436        assert_eq!(green.blocking, Blocking::No);
6437        let red = parse_pr(RED_OPEN).expect("parse");
6438        assert_eq!(
6439            red.blocking,
6440            Blocking::No,
6441            "`UNSTABLE` is mergeable: the red check is one nobody requires"
6442        );
6443        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6444        // A payload from an older `gh` has no such field at all.
6445        let quiet =
6446            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6447        assert_eq!(quiet.blocking, Blocking::Unsaid);
6448    }
6449
6450    #[test]
6451    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6452        // Pull request 37's only red check was `editorconfig`, failing
6453        // because the action could not fetch its own binary after
6454        // editorconfig-checker v4 renamed its release assets. The repository
6455        // does not require it. magi answered by asking a fixer to repair a
6456        // change that was fine, and the pull request had to be merged by hand.
6457        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6458        nonblocking.blocking = Blocking::No;
6459        assert_eq!(
6460            decide(&nonblocking, 0, 4, Duration::ZERO),
6461            Step::Merge,
6462            "the forge says nothing is in the way, so nothing is"
6463        );
6464
6465        // The same red, gated on: that is a fix round, as before.
6466        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6467        blocking.blocking = Blocking::Yes;
6468        assert!(matches!(
6469            decide(&blocking, 0, 4, Duration::ZERO),
6470            Step::Fix { .. }
6471        ));
6472
6473        // A review comment still outranks green-enough: a non-required red
6474        // must not become a way to merge past an unanswered reviewer.
6475        let mut commented = pr(Checks::Red, &["coverage"], 1);
6476        commented.blocking = Blocking::No;
6477        assert!(matches!(
6478            decide(&commented, 0, 4, Duration::ZERO),
6479            Step::Fix { .. }
6480        ));
6481
6482        // And silence from the forge is not consent.
6483        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6484        unsaid.blocking = Blocking::Unsaid;
6485        assert!(matches!(
6486            decide(&unsaid, 0, 4, Duration::ZERO),
6487            Step::Fix { .. }
6488        ));
6489    }
6490
6491    #[test]
6492    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6493        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6494        red.blocking = Blocking::No;
6495        assert_eq!(
6496            decide(&red, 0, 4, Duration::ZERO),
6497            Step::Merge,
6498            "announcing must not change the decision"
6499        );
6500        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6501        assert!(said.contains("yukimemi/magi"), "{said}");
6502        assert!(said.contains("#16"), "{said}");
6503        assert!(
6504            said.contains("https://github.com/yukimemi/magi/pull/16"),
6505            "{said}"
6506        );
6507        assert!(
6508            said.contains("test (windows-latest)") && said.contains("coverage"),
6509            "{said}"
6510        );
6511
6512        // `failing` can be left over on a green observation; only `checks` counts.
6513        let green = pr(Checks::Green, &["stale"], 0);
6514        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6515    }
6516
6517    #[test]
6518    fn red_merge_notices_omit_empty_check_details() {
6519        for checks in [vec![], vec!["", "  "]] {
6520            let red = pr(Checks::Red, &checks, 0);
6521            let summary = red_merge_summary("owner/repo", &red).unwrap();
6522            let notice = crate::notices::merged_red("run-1", &summary);
6523            assert_eq!(
6524                notice.message,
6525                format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6526            );
6527            assert_eq!(notice.severity, crate::notices::Severity::Warn);
6528        }
6529        let red = pr(Checks::Red, &["", " test ", " "], 0);
6530        let summary = red_merge_summary("owner/repo", &red).unwrap();
6531        assert!(summary.contains("with red checks: test ("), "{summary}");
6532    }
6533
6534    #[test]
6535    fn the_repo_label_comes_from_the_pull_request_url() {
6536        let p = Path::new("/tmp/checkout");
6537        assert_eq!(
6538            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6539            "yukimemi/magi"
6540        );
6541        assert_eq!(repo_label(p, "not a url"), "checkout");
6542    }
6543
6544    #[test]
6545    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6546        // Pull requests 35 and 37 were both rebased by hand: a competition
6547        // that runs for two hours against a repository merging pull requests
6548        // all day conflicts on the way in, and that is arithmetic rather
6549        // than a defect in the change.
6550        let mut conflicted = pr(Checks::Green, &[], 0);
6551        conflicted.blocking = Blocking::Conflict;
6552        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6553
6554        // Decided before the checks, and even with the rounds spent: every
6555        // check on a branch that cannot land is an answer about a state that
6556        // cannot land, and a conflict is not the change's fault.
6557        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6558        red.blocking = Blocking::Conflict;
6559        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6560
6561        // The lifecycle still wins over everything, conflict included.
6562        let mut merged = pr(Checks::Red, &[], 0);
6563        merged.blocking = Blocking::Conflict;
6564        merged.state = PrLifecycle::Merged;
6565        assert_eq!(
6566            decide(&merged, 0, 4, Duration::ZERO),
6567            Step::Done { merged: true }
6568        );
6569    }
6570
6571    #[test]
6572    fn the_forge_verdict_is_read_off_merge_state_status() {
6573        // The spellings that mean "mergeable". `UNSTABLE` is the one that
6574        // matters: mergeable, with a non-required check red or still running.
6575        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6576            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6577            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6578        }
6579        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6580        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6581        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6582        // An older `gh`, or a token without the scope, says nothing - and
6583        // refusing to guess is the rule everywhere else in this module.
6584        for quiet in ["", "UNKNOWN"] {
6585            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6586            assert!(Blocking::of(quiet).stops_a_merge());
6587        }
6588    }
6589
6590    #[test]
6591    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6592        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6593        // The exact stderr from run ec12, in a jj-colocated repository.
6594        let jj = "could not determine current branch: failed to run git: not on any branch";
6595
6596        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6597            .expect("the forge says merged, so it merged");
6598        assert!(landed.ok);
6599        assert!(
6600            landed.detail.contains("but the pull request is merged"),
6601            "the record must not read as a clean success: {}",
6602            landed.detail
6603        );
6604        assert!(
6605            landed.detail.contains("not on any branch"),
6606            "and it must keep what the command actually said: {}",
6607            landed.detail
6608        );
6609
6610        // A pull request still open means the merge really failed.
6611        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6612        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6613        // And an unreadable answer is not evidence of success.
6614        assert!(merged_after_all(&argv, jj, None).is_none());
6615    }
6616
6617    #[test]
6618    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6619        let mut state = pr(Checks::Red, &["editorconfig"], 3);
6620        state.state = PrLifecycle::Closed;
6621        assert_eq!(
6622            decide(&state, 0, 4, Duration::ZERO),
6623            Step::Done { merged: false },
6624            "a human closing the pull request ends the loop, whatever CI says"
6625        );
6626    }
6627
6628    #[test]
6629    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6630        let red = decide(
6631            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6632            4,
6633            4,
6634            Duration::ZERO,
6635        );
6636        match red {
6637            Step::GiveUp { reason } => {
6638                assert!(reason.contains("editorconfig"), "reason: {reason}");
6639                assert!(reason.contains("test (macos)"), "reason: {reason}");
6640                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6641            }
6642            other => panic!("expected a give-up, got {other:?}"),
6643        }
6644
6645        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6646        match commented {
6647            Step::GiveUp { reason } => {
6648                assert!(reason.contains("unresolved"), "reason: {reason}");
6649                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6650            }
6651            other => panic!("expected a give-up, got {other:?}"),
6652        }
6653    }
6654
6655    #[test]
6656    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6657        let candidate_commit = "magi: candidate A (uncommitted work)";
6658        let subject = merge_subject(candidate_commit, "add retries to the uploader");
6659        let argv = merge_argv(16, &subject);
6660
6661        assert!(argv.contains(&"--squash".to_owned()));
6662        assert!(argv.contains(&"--delete-branch".to_owned()));
6663        assert!(argv.contains(&"--subject".to_owned()));
6664        assert_eq!(
6665            argv.last().map(String::as_str),
6666            Some("add retries to the uploader"),
6667            "the subject must not be the candidate commit message"
6668        );
6669        assert_ne!(subject, candidate_commit);
6670    }
6671
6672    #[test]
6673    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6674        assert_eq!(
6675            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6676            "feat: a queue, an unattended loop, and a phone UI"
6677        );
6678        assert_eq!(
6679            merge_subject("", "# port the retry logic\n\ndetails"),
6680            "port the retry logic",
6681            "an empty title falls back to the task's first line, heading marks stripped"
6682        );
6683    }
6684
6685    #[test]
6686    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6687        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6688        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6689        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6690        assert_eq!(job_of("https://coderabbit.ai/status"), None);
6691        assert_eq!(run_of(""), None);
6692    }
6693
6694    #[test]
6695    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6696        let mut out = Vec::new();
6697        push_if_outstanding(
6698            &mut out,
6699            ReviewComment {
6700                author: "yukimemi".to_owned(),
6701                path: None,
6702                line: None,
6703                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6704            },
6705        );
6706        assert!(out.is_empty());
6707    }
6708
6709    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
6710    /// falls back to the repository - which keeps these tests free of a
6711    /// worktree, a `git` invocation and a network.
6712    fn run_state() -> RunState {
6713        let mut state = RunState::new(
6714            std::path::PathBuf::from("/repo/magi"),
6715            "main".to_owned(),
6716            "abcdef1234".to_owned(),
6717            "add retries to the uploader".to_owned(),
6718            crate::config::Config::default(),
6719        );
6720        // Tests run in parallel against one persistent home and the ids
6721        // `RunState::new` draws from the clock can repeat, so two tests would
6722        // share a run's questions. The home also outlives the process, so the
6723        // counter alone would reuse an earlier run's ids.
6724        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6725        let nanos = std::time::SystemTime::now()
6726            .duration_since(std::time::UNIX_EPOCH)
6727            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6728        state.id = format!(
6729            "20261004-{nanos:06}-{:04x}",
6730            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6731        );
6732        state
6733    }
6734
6735    fn green_pr() -> PrState {
6736        PrState {
6737            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6738            number: 42,
6739            state: PrLifecycle::Open,
6740            checks: Checks::Green,
6741            // The forge sees nothing in the way unless a test says otherwise.
6742            blocking: Blocking::No,
6743            failing: Vec::new(),
6744            review_comments: vec![ReviewComment {
6745                author: "coderabbitai".to_owned(),
6746                path: Some("src/land.rs".to_owned()),
6747                line: Some(212),
6748                body: "this branch never checks the exit code".to_owned(),
6749            }],
6750        }
6751    }
6752
6753    #[test]
6754    fn land_fixer_defers_review_and_verification_without_a_cache() {
6755        let state = run_state();
6756        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6757        assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
6758        assert!(p.contains("Commit in this worktree"), "{p}");
6759    }
6760
6761    #[test]
6762    fn github_facing_land_text_is_english_whatever_the_language() {
6763        let mut state = run_state();
6764        state.config.graph.language = "ja".to_owned();
6765        let comment = stop_comment(&state.id, "checks are still red");
6766        assert!(crate::github_text::check("", &comment).is_empty());
6767        assert!(comment.is_ascii(), "{comment}");
6768        assert!(comment.starts_with(MARKER));
6769
6770        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6771        let ja_at = p.find("Write all prose in ja").unwrap();
6772        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6773        assert!(ja_at < rule_at, "{p}");
6774        assert!(p.contains("stays in Japanese"), "{p}");
6775
6776        state.config.graph.language = "en".to_owned();
6777        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6778        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6779        assert!(!p.contains("does not apply"), "{p}");
6780    }
6781
6782    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6783
6784    fn panel() -> String {
6785        approval_panel(
6786            &run_state(),
6787            &green_pr(),
6788            NUMSTAT,
6789            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6790            &[
6791                "land: ask before merging".to_owned(),
6792                "land: colour the diff".to_owned(),
6793            ],
6794            "feat: merge approval from the phone",
6795        )
6796    }
6797
6798    #[test]
6799    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6800        let html = panel();
6801        for needle in [
6802            "42",
6803            "main",
6804            "src/land.rs",
6805            "src/web.rs",
6806            "assets/logo.png",
6807            "feat: merge approval from the phone",
6808            "land: ask before merging",
6809            "land: colour the diff",
6810            "coderabbitai",
6811            "this branch never checks the exit code",
6812            "green",
6813        ] {
6814            assert!(html.contains(needle), "the panel must state `{needle}`");
6815        }
6816    }
6817
6818    async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
6819        crate::run::pin_test_home();
6820        let tmp = tempfile::tempdir().unwrap();
6821        let repo = tmp.path().join("repo");
6822        let remote = tmp.path().join("remote.git");
6823        std::fs::create_dir(&repo).unwrap();
6824        git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
6825            .await
6826            .unwrap();
6827        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
6828        git::git(&repo, &["config", "user.name", "Test"])
6829            .await
6830            .unwrap();
6831        git::git(&repo, &["config", "user.email", "test@example.test"])
6832            .await
6833            .unwrap();
6834        std::fs::write(repo.join("base"), "base").unwrap();
6835        git::git(&repo, &["add", "."]).await.unwrap();
6836        git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
6837        git::git(
6838            &repo,
6839            &["remote", "add", "origin", remote.to_str().unwrap()],
6840        )
6841        .await
6842        .unwrap();
6843        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
6844        git::git(&repo, &["checkout", "-b", "winner"])
6845            .await
6846            .unwrap();
6847        std::fs::write(repo.join("change"), "change").unwrap();
6848        git::git(&repo, &["add", "."]).await.unwrap();
6849        git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
6850        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
6851        git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
6852            .await
6853            .unwrap();
6854        let mut state = run_state();
6855        state.repo = repo.clone();
6856        state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
6857        state.config.graph.land = true;
6858        state.config.graph.land_approval = true;
6859        state.pr_head = Some("task-head".into());
6860        state.pr_pushed_head = Some(head.clone());
6861        state.candidates = vec![winning_candidate("change")];
6862        state.candidates[0].worktree = repo;
6863        state.candidates[0].branch = "winner".into();
6864        state.tally = Some(uncontested_tally());
6865        state.gate_ran = true;
6866        state.gate_head = Some(head.clone());
6867        state.land_sync_pending = true;
6868        state.save().unwrap();
6869        (tmp, state, head)
6870    }
6871
6872    async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
6873        let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
6874        resume_landing_head(state, head, &green_pr().url, &forge).await
6875    }
6876
6877    async fn advance_landing_base(state: &RunState) {
6878        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
6879        std::fs::write(state.repo.join("advance"), "advance").unwrap();
6880        git::git(&state.repo, &["add", "."]).await.unwrap();
6881        git::git(&state.repo, &["commit", "-m", "advance base"])
6882            .await
6883            .unwrap();
6884        git::git(&state.repo, &["push", "origin", "main"])
6885            .await
6886            .unwrap();
6887        git::git(&state.repo, &["checkout", "winner"])
6888            .await
6889            .unwrap();
6890    }
6891
6892    #[tokio::test]
6893    async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
6894        let (_tmp, mut state, old) = landing_git_fixture().await;
6895        advance_landing_base(&state).await;
6896        state.config.verify.gate = vec!["test -f advance".into()];
6897        let head = resume_observed_head(&mut state, &old).await.unwrap();
6898        assert_ne!(head, old);
6899        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
6900        assert!(state.gate_allows_progress());
6901        assert_eq!(
6902            git::remote_head(&state.repo, "origin", "task-head")
6903                .await
6904                .unwrap(),
6905            Some(head.clone())
6906        );
6907        assert_eq!(state.pr_pushed_head, Some(head));
6908        assert!(!state.land_sync_pending);
6909        assert!(state.config.graph.land_approval);
6910    }
6911
6912    fn landing_gate_fixer(state: &mut RunState, script: &str) {
6913        state.config.disk.min_free_bytes = 0;
6914        state.config.graph.gate_fix_rounds = 1;
6915        state.config.agents = vec![crate::config::AgentSpec {
6916            id: "mock".into(),
6917            kind: crate::config::AgentKind::Command,
6918            model: None,
6919            command: vec!["sh".into(), "-c".into(), script.into()],
6920            extra_args: Vec::new(),
6921            env: Default::default(),
6922            prompt_delivery: None,
6923            max_turn_secs: None,
6924        }];
6925        state.candidates[0].agent = "mock".into();
6926    }
6927
6928    #[tokio::test]
6929    async fn landing_resume_revalidates_an_operator_published_winner_extension() {
6930        let (_tmp, mut state, old) = landing_git_fixture().await;
6931        std::fs::write(state.repo.join("change"), "operator update").unwrap();
6932        git::commit_all(&state.repo, "extend winner").await.unwrap();
6933        let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
6934        git::git(
6935            &state.repo,
6936            &["push", "origin", "HEAD:refs/heads/task-head"],
6937        )
6938        .await
6939        .unwrap();
6940        state.config.verify.gate = vec!["exit 1".into()];
6941        state.config.graph.gate_fix_rounds = 0;
6942        assert!(resume_observed_head(&mut state, &updated).await.is_err());
6943        assert_ne!(old, updated);
6944        assert_eq!(state.gate_head.as_ref(), Some(&updated));
6945        assert!(!state.gate_allows_progress());
6946        assert!(state.land_sync_pending);
6947        state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
6948        let head = resume_observed_head(&mut state, &updated).await.unwrap();
6949        assert_eq!(head, updated);
6950        assert!(state.gate_allows_progress());
6951        assert!(!state.land_sync_pending);
6952        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
6953        land_with(&mut state, &green_pr().url, &forge)
6954            .await
6955            .unwrap();
6956        assert!(state.parked);
6957        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
6958        assert!(forge.argvs().is_empty());
6959    }
6960
6961    #[tokio::test]
6962    async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
6963        for divergent in [false, true] {
6964            let (_tmp, mut state, old) = landing_git_fixture().await;
6965            if divergent {
6966                git::git(&state.repo, &["reset", "--hard", "origin/main"])
6967                    .await
6968                    .unwrap();
6969            }
6970            std::fs::write(state.repo.join("external"), "external").unwrap();
6971            git::commit_all(&state.repo, "external update")
6972                .await
6973                .unwrap();
6974            let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
6975            git::git(
6976                &state.repo,
6977                &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
6978            )
6979            .await
6980            .unwrap();
6981            if !divergent {
6982                git::git(&state.repo, &["reset", "--hard", &old])
6983                    .await
6984                    .unwrap();
6985            }
6986            let error = resume_observed_head(&mut state, &external)
6987                .await
6988                .unwrap_err();
6989            assert!(
6990                error
6991                    .to_string()
6992                    .contains("public head moved outside this run")
6993            );
6994            assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
6995            assert_eq!(
6996                git::remote_head(&state.repo, "origin", "task-head")
6997                    .await
6998                    .unwrap(),
6999                Some(external)
7000            );
7001        }
7002    }
7003
7004    #[tokio::test]
7005    async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
7006        let (_tmp, mut state, old) = landing_git_fixture().await;
7007        advance_landing_base(&state).await;
7008        landing_gate_fixer(
7009            &mut state,
7010            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7011        );
7012        state.config.verify.gate =
7013            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7014        state.config.verify.e2e = vec!["test -f advance".into()];
7015        // First save a red gate without spending a fixer round. Resume must
7016        // still offer the remaining budget for this exact same failed head.
7017        state.config.graph.gate_fix_rounds = 0;
7018        assert!(resume_observed_head(&mut state, &old).await.is_err());
7019        assert_eq!(
7020            git::remote_head(&state.repo, "origin", "task-head")
7021                .await
7022                .unwrap(),
7023            Some(old.clone())
7024        );
7025        state.config.graph.gate_fix_rounds = 1;
7026        let head = resume_observed_head(&mut state, &old).await.unwrap();
7027        assert_eq!(state.gate_fixes.len(), 1);
7028        assert!(state.gate_fixes[0].committed);
7029        assert!(state.gate_allows_progress());
7030        assert_eq!(state.gate_head.as_ref(), Some(&head));
7031        assert_eq!(
7032            git::remote_head(&state.repo, "origin", "task-head")
7033                .await
7034                .unwrap(),
7035            Some(head.clone())
7036        );
7037        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7038        land_with(&mut state, &green_pr().url, &forge)
7039            .await
7040            .unwrap();
7041        assert!(state.parked);
7042        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7043        assert!(forge.argvs().is_empty());
7044    }
7045
7046    #[tokio::test]
7047    async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7048        let (_tmp, mut state, old) = landing_git_fixture().await;
7049        advance_landing_base(&state).await;
7050        landing_gate_fixer(
7051            &mut state,
7052            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7053        );
7054        state.config.verify.gate = vec!["echo still red; exit 1".into()];
7055        for _ in 0..2 {
7056            assert!(resume_observed_head(&mut state, &old).await.is_err());
7057            assert_eq!(state.gate_fixes.len(), 1);
7058            assert_eq!(
7059                git::remote_head(&state.repo, "origin", "task-head")
7060                    .await
7061                    .unwrap(),
7062                Some(old.clone())
7063            );
7064        }
7065    }
7066
7067    #[tokio::test]
7068    async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7069        let (_tmp, mut state, old) = landing_git_fixture().await;
7070        advance_landing_base(&state).await;
7071        landing_gate_fixer(
7072            &mut state,
7073            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7074        );
7075        state.config.verify.gate =
7076            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7077        // The repair makes the gate green while breaking an e2e invariant.
7078        state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7079        for resume in 0..3 {
7080            assert!(resume_observed_head(&mut state, &old).await.is_err());
7081            state = RunState::load(&state.id).unwrap();
7082            let evidence = state.gate_repair_e2e.as_ref().unwrap();
7083            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7084            assert_eq!(evidence.head, head);
7085            assert!(!evidence.passed);
7086            assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7087            assert_eq!(state.gate_fixes.len(), 1);
7088            assert_eq!(
7089                git::remote_head(&state.repo, "origin", "task-head")
7090                    .await
7091                    .unwrap(),
7092                Some(old.clone())
7093            );
7094            if resume > 0 {
7095                assert!(
7096                    state.gate_allows_progress(),
7097                    "a green gate must not bypass red e2e"
7098                );
7099            }
7100        }
7101        // Once e2e genuinely succeeds on the same SHA, the saved requirement
7102        // can be satisfied without another fixer or approval bypass.
7103        state.config.verify.e2e = vec!["grep -q fixed change".into()];
7104        let head = resume_observed_head(&mut state, &old).await.unwrap();
7105        assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7106        assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7107        assert_eq!(state.gate_fixes.len(), 1);
7108        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7109        land_with(&mut state, &green_pr().url, &forge)
7110            .await
7111            .unwrap();
7112        assert!(state.parked);
7113        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7114        assert!(forge.argvs().is_empty());
7115    }
7116
7117    #[tokio::test]
7118    async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7119        for phase in 0..3 {
7120            let (_tmp, mut state, old) = landing_git_fixture().await;
7121            advance_landing_base(&state).await;
7122            let head = resume_observed_head(&mut state, &old).await.unwrap();
7123            // Persist the shapes left by interruption, resource deferral,
7124            // and a successful check for a different (older) SHA.
7125            state.land_sync_pending = true;
7126            state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7127            state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7128                head: if phase == 2 {
7129                    old.clone()
7130                } else {
7131                    head.clone()
7132                },
7133                passed: phase == 2,
7134                outcomes: if phase == 1 {
7135                    vec![crate::run::CommandOutcome {
7136                        command: state.config.verify.e2e[0].clone(),
7137                        code: None,
7138                        output_tail: "verification resource unavailable".into(),
7139                        duration_ms: 0,
7140                        resource_blocked: true,
7141                    }]
7142                } else {
7143                    Vec::new()
7144                },
7145            });
7146            state.save().unwrap();
7147            for _ in 0..2 {
7148                state = RunState::load(&state.id).unwrap();
7149                assert!(resume_observed_head(&mut state, &head).await.is_err());
7150                let evidence = state.gate_repair_e2e.as_ref().unwrap();
7151                assert_eq!(evidence.head, head);
7152                assert!(!evidence.passed);
7153                assert!(!evidence.outcomes.is_empty());
7154                assert_eq!(
7155                    git::remote_head(&state.repo, "origin", "task-head")
7156                        .await
7157                        .unwrap(),
7158                    Some(head.clone())
7159                );
7160                assert!(state.land_sync_pending);
7161            }
7162        }
7163    }
7164
7165    async fn interrupt_landing_rebase(state: &mut RunState) {
7166        state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7167        state.save().unwrap();
7168        let scratch = state.dir().join("rebase");
7169        assert_eq!(
7170            crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7171                .await
7172                .unwrap(),
7173            crate::rebase::Rebased::Applied
7174        );
7175        assert!(!git::is_clean(&state.repo).await.unwrap());
7176    }
7177
7178    async fn landing_setup(state: &mut RunState) {
7179        git::git(
7180            &state.repo,
7181            &["config", "extensions.worktreeConfig", "true"],
7182        )
7183        .await
7184        .unwrap();
7185        state.config.worktree.setup =
7186            vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7187        crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7188            .await
7189            .unwrap();
7190        state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7191    }
7192
7193    #[tokio::test]
7194    async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7195        for phase in 0..3 {
7196            let (_tmp, mut state, old) = landing_git_fixture().await;
7197            advance_landing_base(&state).await;
7198            landing_setup(&mut state).await;
7199            if phase > 0 {
7200                interrupt_landing_rebase(&mut state).await;
7201                if phase == 2 {
7202                    // Crash after clean removed setup products, before setup.
7203                    git::sync_to_head(&state.repo).await.unwrap();
7204                    assert!(!state.repo.join(".env").exists());
7205                }
7206                state = RunState::load(&state.id).unwrap();
7207            }
7208            let head = resume_observed_head(&mut state, &old).await.unwrap();
7209            assert_ne!(head, old);
7210            assert!(state.gate_allows_progress());
7211            assert!(state.land_rebase_from.is_none());
7212            assert_eq!(
7213                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7214                "secret"
7215            );
7216            assert!(git::is_clean(&state.repo).await.unwrap());
7217            assert!(
7218                !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7219                    .await
7220                    .unwrap()
7221                    .contains(".env")
7222            );
7223        }
7224    }
7225
7226    #[tokio::test]
7227    async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7228        let (_tmp, mut state, _) = landing_git_fixture().await;
7229        advance_landing_base(&state).await;
7230        landing_setup(&mut state).await;
7231        interrupt_landing_rebase(&mut state).await;
7232        // Exercise unusual names without asking Windows to create an invalid
7233        // control-character filename. Unix also covers embedded newlines.
7234        let locals = [
7235            "local notes",
7236            #[cfg(unix)]
7237            "local\nnotes",
7238        ];
7239        let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7240            .await
7241            .unwrap();
7242        std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7243        for local in locals {
7244            std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7245            assert!(
7246                git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7247                    .await
7248                    .unwrap()
7249                    .ok()
7250            );
7251        }
7252        restore_landing_worktree(&mut state).await.unwrap();
7253        for local in locals {
7254            assert_eq!(
7255                std::fs::read_to_string(state.repo.join(local)).unwrap(),
7256                "keep these notes"
7257            );
7258        }
7259        assert_eq!(
7260            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7261            "secret"
7262        );
7263        assert!(git::is_clean(&state.repo).await.unwrap());
7264        assert!(state.land_rebase_from.is_none());
7265    }
7266
7267    #[tokio::test]
7268    async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7269        for local in ["advance", "advance/notes", "new-directory"] {
7270            let (_tmp, mut state, _) = landing_git_fixture().await;
7271            advance_landing_base(&state).await;
7272            // Also cover a local file blocking a new tracked directory.
7273            git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7274            std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7275            std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7276            git::git(&state.repo, &["add", "."]).await.unwrap();
7277            git::git(&state.repo, &["commit", "-m", "add directory"])
7278                .await
7279                .unwrap();
7280            git::git(&state.repo, &["push", "origin", "main"])
7281                .await
7282                .unwrap();
7283            git::git(&state.repo, &["checkout", "winner"])
7284                .await
7285                .unwrap();
7286            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7287                .await
7288                .unwrap();
7289            std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7290            let path = state.repo.join(local);
7291            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7292            std::fs::write(&path, "local notes").unwrap();
7293            interrupt_landing_rebase(&mut state).await;
7294            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7295            let checkpoint = state.land_rebase_from.clone();
7296            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7297            assert!(
7298                error.to_string().contains("conflicting with HEAD"),
7299                "{error:#}"
7300            );
7301            assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7302            assert_eq!(
7303                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7304                before
7305            );
7306            assert_eq!(state.land_rebase_from, checkpoint);
7307        }
7308    }
7309
7310    #[tokio::test]
7311    async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7312        for interrupted in [false, true] {
7313            let (_tmp, mut state, old) = landing_git_fixture().await;
7314            assert!(state.config.worktree.setup.is_empty());
7315            // A gate product exists before the base moves, without setup metadata.
7316            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7317                .await
7318                .unwrap();
7319            std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7320            state.config.verify.gate =
7321                vec!["mkdir -p target && printf artifact > target/probe".into()];
7322            state.gate_ran = false;
7323            resume_observed_head(&mut state, &old).await.unwrap();
7324            assert_eq!(
7325                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7326                "artifact"
7327            );
7328            advance_landing_base(&state).await;
7329            if interrupted {
7330                interrupt_landing_rebase(&mut state).await;
7331                state = RunState::load(&state.id).unwrap();
7332            }
7333            state.config.verify.gate =
7334                vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7335            let head = resume_observed_head(&mut state, &old).await.unwrap();
7336            assert_ne!(head, old);
7337            assert!(state.gate_allows_progress());
7338            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7339            assert_eq!(
7340                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7341                "artifact"
7342            );
7343            assert!(state.land_rebase_from.is_none());
7344            assert_eq!(
7345                git::remote_head(&state.repo, "origin", "task-head")
7346                    .await
7347                    .unwrap(),
7348                Some(head.clone())
7349            );
7350            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7351            land_with(&mut state, &green_pr().url, &forge)
7352                .await
7353                .unwrap();
7354            assert!(state.parked);
7355            assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7356            assert!(forge.argvs().is_empty());
7357        }
7358    }
7359
7360    #[tokio::test]
7361    #[cfg(unix)]
7362    async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7363        use std::os::fd::AsRawFd as _;
7364
7365        for interrupted in [false, true] {
7366            let (_tmp, mut state, _) = landing_git_fixture().await;
7367            advance_landing_base(&state).await;
7368            landing_setup(&mut state).await;
7369            if interrupted {
7370                interrupt_landing_rebase(&mut state).await;
7371            }
7372            state.config.worktree.setup =
7373                vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7374            let common = crate::repo_lock::common_dir(&state.repo)
7375                .await
7376                .unwrap()
7377                .unwrap();
7378            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7379            state.config.coordination = crate::config::test_coordination();
7380            state.config.graph.timeout_resource_wait = 0;
7381            let external = std::fs::OpenOptions::new()
7382                .read(true)
7383                .write(true)
7384                .create(true)
7385                .truncate(false)
7386                .open(common.join("updater.lock"))
7387                .unwrap();
7388            // SAFETY: the descriptor belongs to `external` and remains open.
7389            let locked =
7390                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7391            assert_eq!(locked, 0);
7392            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7393            let checkpoint = state.land_rebase_from.clone();
7394            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7395            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7396            assert_eq!(
7397                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7398                before
7399            );
7400            assert_eq!(state.land_rebase_from, checkpoint);
7401            assert_eq!(
7402                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7403                "secret"
7404            );
7405
7406            drop(external);
7407            state.config.graph.timeout_resource_wait = 2;
7408            restore_landing_worktree(&mut state).await.unwrap();
7409            assert!(git::is_clean(&state.repo).await.unwrap());
7410            assert!(state.land_rebase_from.is_none());
7411            assert_eq!(
7412                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7413                "restored"
7414            );
7415        }
7416    }
7417
7418    #[tokio::test]
7419    #[cfg(unix)]
7420    async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7421        use std::sync::atomic::{AtomicBool, Ordering};
7422
7423        let (_tmp, mut state, _) = landing_git_fixture().await;
7424        advance_landing_base(&state).await;
7425        landing_setup(&mut state).await;
7426        interrupt_landing_rebase(&mut state).await;
7427        let common = crate::repo_lock::common_dir(&state.repo)
7428            .await
7429            .unwrap()
7430            .unwrap();
7431        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7432        state.config.coordination = crate::config::test_coordination();
7433        let holder = crate::repo_lock::SeatLease::for_checkout(
7434            &state.repo,
7435            true,
7436            state.config.coordination.as_ref(),
7437        )
7438        .await
7439        .unwrap();
7440        assert!(holder.is_some());
7441        let cancelled = Arc::new(AtomicBool::new(false));
7442        let signal = cancelled.clone();
7443        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7444            signal.load(Ordering::SeqCst)
7445        });
7446        let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7447        let checkpoint = state.land_rebase_from.clone();
7448        let (result, ()) = tokio::join!(
7449            tokio::time::timeout(
7450                Duration::from_secs(2),
7451                wait.scope(restore_landing_worktree(&mut state)),
7452            ),
7453            async {
7454                tokio::time::sleep(Duration::from_millis(100)).await;
7455                cancelled.store(true, Ordering::SeqCst);
7456            }
7457        );
7458        let error = result
7459            .expect("cancellation must release the waiting driver")
7460            .unwrap_err();
7461        assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7462        assert_eq!(
7463            git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7464            before
7465        );
7466        assert_eq!(state.land_rebase_from, checkpoint);
7467        assert_eq!(
7468            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7469            "secret"
7470        );
7471        drop(holder);
7472        restore_landing_worktree(&mut state).await.unwrap();
7473        assert!(state.land_rebase_from.is_none());
7474        assert!(git::is_clean(&state.repo).await.unwrap());
7475    }
7476
7477    #[tokio::test]
7478    #[cfg(unix)]
7479    async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
7480        use std::sync::atomic::{AtomicBool, Ordering};
7481
7482        for e2e in [false, true] {
7483            let (_tmp, mut state, head) = landing_git_fixture().await;
7484            let common = crate::repo_lock::common_dir(&state.repo)
7485                .await
7486                .unwrap()
7487                .unwrap();
7488            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7489            state.config.coordination = crate::config::test_coordination();
7490            if e2e {
7491                state.config.verify.e2e = vec!["true".into()];
7492                state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7493                    head: head.clone(),
7494                    passed: false,
7495                    outcomes: Vec::new(),
7496                });
7497            } else {
7498                state.config.verify.gate = vec!["true".into()];
7499                state.gate_ran = false;
7500            }
7501            let holder = crate::repo_lock::SeatLease::for_checkout(
7502                &state.repo,
7503                true,
7504                state.config.coordination.as_ref(),
7505            )
7506            .await
7507            .unwrap();
7508            assert!(holder.is_some());
7509            let cancelled = Arc::new(AtomicBool::new(false));
7510            let signal = cancelled.clone();
7511            let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7512                signal.load(Ordering::SeqCst)
7513            });
7514            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7515            let url = green_pr().url;
7516            let (result, ()) = tokio::join!(
7517                tokio::time::timeout(
7518                    Duration::from_secs(2),
7519                    wait.scope(publish_landing_head(&mut state, &head, &url, &forge)),
7520                ),
7521                async {
7522                    tokio::time::sleep(Duration::from_millis(100)).await;
7523                    cancelled.store(true, Ordering::SeqCst);
7524                }
7525            );
7526            let error = result
7527                .expect("cancellation must release the verification waiter")
7528                .unwrap_err();
7529            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7530            assert!(forge.argvs().is_empty());
7531            assert_eq!(
7532                git::remote_head(&state.repo, "origin", "task-head")
7533                    .await
7534                    .unwrap(),
7535                Some(head.clone())
7536            );
7537            drop(holder);
7538            // Cancellation must release its reservation so another attempt can verify.
7539            assert_eq!(
7540                publish_landing_head(&mut state, &head, &url, &forge)
7541                    .await
7542                    .unwrap(),
7543                head
7544            );
7545        }
7546    }
7547
7548    #[tokio::test]
7549    async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
7550        for staged in [false, true] {
7551            let (_tmp, mut state, old) = landing_git_fixture().await;
7552            advance_landing_base(&state).await;
7553            interrupt_landing_rebase(&mut state).await;
7554            std::fs::write(state.repo.join("change"), "user edit").unwrap();
7555            if staged {
7556                git::git(&state.repo, &["add", "change"]).await.unwrap();
7557            }
7558            assert!(resume_observed_head(&mut state, &old).await.is_err());
7559            assert_eq!(
7560                std::fs::read_to_string(state.repo.join("change")).unwrap(),
7561                "user edit"
7562            );
7563            assert_eq!(
7564                git::remote_head(&state.repo, "origin", "task-head")
7565                    .await
7566                    .unwrap(),
7567                Some(old)
7568            );
7569        }
7570    }
7571
7572    #[tokio::test]
7573    async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
7574        let (_tmp, mut state, head) = landing_git_fixture().await;
7575        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7576        let id = state.id.clone();
7577        let pr = land_with(&mut state, &green_pr().url, &forge)
7578            .await
7579            .unwrap();
7580        assert_eq!(pr.state, PrLifecycle::Open);
7581        assert_eq!(state.id, id);
7582        assert!(state.parked);
7583        assert_eq!(state.status, RunStatus::Landing);
7584        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7585        assert!(forge.argvs().is_empty());
7586
7587        state.land_sync_pending = true;
7588        std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
7589        let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
7590        closed.pr.state = PrLifecycle::Closed;
7591        let forge = Scripted::new(vec![closed], vec![]);
7592        assert_eq!(
7593            land_with(&mut state, &green_pr().url, &forge)
7594                .await
7595                .unwrap()
7596                .state,
7597            PrLifecycle::Closed
7598        );
7599        assert_eq!(
7600            std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
7601            "do not touch"
7602        );
7603        assert!(forge.argvs().is_empty());
7604    }
7605
7606    #[tokio::test]
7607    async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
7608        let (_tmp, mut state, old) = landing_git_fixture().await;
7609        advance_landing_base(&state).await;
7610        state.config.verify.gate = vec!["exit 1".into()];
7611        assert!(resume_observed_head(&mut state, &old).await.is_err());
7612        assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
7613        assert!(!state.gate_allows_progress());
7614        assert_eq!(
7615            git::remote_head(&state.repo, "origin", "task-head")
7616                .await
7617                .unwrap(),
7618            Some(old)
7619        );
7620        assert!(state.land_sync_pending);
7621    }
7622
7623    #[tokio::test]
7624    async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
7625        let (_tmp, mut state, old) = landing_git_fixture().await;
7626        advance_landing_base(&state).await;
7627        let pushed = resume_observed_head(&mut state, &old).await.unwrap();
7628        // Model a crash after the push but before saving its published SHA.
7629        state.pr_pushed_head = Some(old.clone());
7630        state.land_sync_pending = true;
7631        assert_eq!(
7632            resume_observed_head(&mut state, &pushed).await.unwrap(),
7633            pushed
7634        );
7635        // A writer races the observed PR snapshot: the explicit remote lease
7636        // must reject the push even though preflight saw our expected head.
7637        git::git(
7638            &state.repo,
7639            &["push", "--force", "origin", "main:refs/heads/task-head"],
7640        )
7641        .await
7642        .unwrap();
7643        assert!(
7644            publish_landing_head(
7645                &mut state,
7646                &pushed,
7647                &green_pr().url,
7648                &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
7649            )
7650            .await
7651            .unwrap_err()
7652            .to_string()
7653            .contains("publishing gated")
7654        );
7655        assert!(
7656            resume_observed_head(&mut state, "foreign-head")
7657                .await
7658                .is_err()
7659        );
7660    }
7661    #[tokio::test]
7662    async fn landing_publication_refuses_a_pr_closed_during_gate() {
7663        let (_tmp, mut state, head) = landing_git_fixture().await;
7664        let mut closed = seen(&head, Checks::Green, "CLEAN", false);
7665        closed.pr.state = PrLifecycle::Closed;
7666        let forge = Scripted::new(vec![closed], vec![]);
7667        assert!(
7668            publish_landing_head(&mut state, &head, &green_pr().url, &forge)
7669                .await
7670                .unwrap_err()
7671                .to_string()
7672                .contains("PR closed or merged")
7673        );
7674    }
7675
7676    /// A candidate selected by the uncontested tally.
7677    fn winning_candidate(summary: &str) -> Candidate {
7678        Candidate {
7679            index: 0,
7680            label: 'A',
7681            agent: "opus".to_owned(),
7682            branch: "magi/x/A".to_owned(),
7683            worktree: PathBuf::from("/wt/A"),
7684            summary: summary.to_owned(),
7685            stat: String::new(),
7686            files: 1,
7687            commits: 1,
7688            empty: false,
7689            failed: None,
7690            verified_noop: None,
7691            duration_ms: 0,
7692            folded: false,
7693        }
7694    }
7695
7696    fn uncontested_tally() -> Tally {
7697        Tally {
7698            first_choice: BTreeMap::from([('A', 1)]),
7699            borda: BTreeMap::new(),
7700            winner: 'A',
7701            rankings: 1,
7702            unanimous_initial: true,
7703            deliberated: false,
7704            changed_votes: 0,
7705            unanimous_final: true,
7706            tie_break: None,
7707            judges: 1,
7708            present: 1,
7709            quorum: 1,
7710            met_quorum: true,
7711            uncontested: None,
7712        }
7713    }
7714
7715    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
7716        ReviewRecord {
7717            attempts: 0,
7718            reviewer,
7719            agent: agent.to_owned(),
7720            summary: summary.to_owned(),
7721            findings: Vec::new(),
7722            vote: None,
7723            failed: None,
7724            duration_ms: 0,
7725        }
7726    }
7727
7728    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
7729        let answered = reviews.len();
7730        ReviewRound {
7731            round,
7732            head: "abc1234".to_owned(),
7733            verified_head: None,
7734            verified_at: None,
7735            reviews,
7736            e2e: Vec::new(),
7737            verify_retried: false,
7738            e2e_deferred: false,
7739            e2e_defer_reason: None,
7740            fix: None,
7741            blocking: 0,
7742            answered,
7743            expected: answered,
7744            clean: true,
7745            progressed: false,
7746            vote_split: false,
7747            reconsideration: Vec::new(),
7748            verdict: None,
7749        }
7750    }
7751
7752    #[test]
7753    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
7754        let en = panel();
7755        assert!(en.contains("Task"), "{en}");
7756        assert!(en.contains("add retries to the uploader"), "{en}");
7757
7758        let mut state = run_state();
7759        state.config.graph.language = "ja".to_owned();
7760        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7761        assert!(ja.contains("タスク"), "{ja}");
7762        assert!(
7763            ja.contains("add retries to the uploader"),
7764            "the task itself is not translated: {ja}"
7765        );
7766    }
7767
7768    #[test]
7769    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
7770        // `run_state()` has no candidates, no tally and no reviews - exactly
7771        // the shape a run has before anything has judged or reviewed it, and
7772        // the panel must not print an empty box for either.
7773        let html = panel();
7774        assert!(!html.contains("What changed"), "{html}");
7775        assert!(!html.contains("Review verdict"), "{html}");
7776    }
7777
7778    #[test]
7779    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
7780        let mut state = run_state();
7781        state.candidates = vec![winning_candidate("")];
7782        state.tally = Some(uncontested_tally());
7783        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7784        assert!(
7785            !html.contains("What changed"),
7786            "an empty summary must not render an empty box: {html}"
7787        );
7788    }
7789
7790    #[test]
7791    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
7792        let mut state = run_state();
7793        state.candidates = vec![winning_candidate(
7794            "Added a retry loop around the uploader PUT call.",
7795        )];
7796        state.tally = Some(uncontested_tally());
7797        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7798        assert!(en.contains("What changed"), "{en}");
7799        assert!(
7800            en.contains("Added a retry loop around the uploader PUT call."),
7801            "{en}"
7802        );
7803
7804        state.config.graph.language = "ja".to_owned();
7805        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7806        assert!(ja.contains("変更内容"), "{ja}");
7807        assert!(
7808            ja.contains("Added a retry loop around the uploader PUT call."),
7809            "{ja}"
7810        );
7811    }
7812
7813    #[test]
7814    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
7815        let mut state = run_state();
7816        state.reviews = vec![
7817            review_round(
7818                1,
7819                vec![review_record(1, "alpha", "found a race, sent back")],
7820            ),
7821            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
7822        ];
7823        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7824        assert!(en.contains("Review verdict"), "{en}");
7825        assert!(en.contains("race is fixed, clean"), "{en}");
7826        assert!(
7827            !en.contains("found a race, sent back"),
7828            "only the round that actually cleared the merge should show: {en}"
7829        );
7830
7831        state.config.graph.language = "ja".to_owned();
7832        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7833        assert!(ja.contains("レビューの結論"), "{ja}");
7834        assert!(ja.contains("レビュアー"), "{ja}");
7835        assert!(ja.contains("race is fixed, clean"), "{ja}");
7836    }
7837
7838    /// The `incomplete_review = "warn"` policy (see
7839    /// `graph::Runner::review_loop`) can push a `clean` round to
7840    /// `state.reviews` while one seat's own record still has `failed: Some`
7841    /// and an empty `summary` - a seat that never answered, not one that
7842    /// answered with nothing to say.
7843    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
7844        ReviewRecord {
7845            attempts: 0,
7846            reviewer,
7847            agent: agent.to_owned(),
7848            summary: String::new(),
7849            findings: Vec::new(),
7850            vote: None,
7851            failed: Some(reason.to_owned()),
7852            duration_ms: 0,
7853        }
7854    }
7855
7856    #[test]
7857    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
7858        let mut state = run_state();
7859        state.reviews = vec![review_round(
7860            1,
7861            vec![
7862                review_record(1, "alpha", "clean, nothing to add"),
7863                unanswered_review_record(2, "beta", "timed out"),
7864            ],
7865        )];
7866        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7867        assert!(en.contains("clean, nothing to add"), "{en}");
7868        assert!(
7869            en.contains("produced no answer: timed out"),
7870            "a seat that never answered must say so, not render a blank box: {en}"
7871        );
7872        assert!(
7873            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
7874            "no reviewer box may be left empty: {en}"
7875        );
7876
7877        state.config.graph.language = "ja".to_owned();
7878        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7879        assert!(ja.contains("回答なし: timed out"), "{ja}");
7880    }
7881
7882    #[test]
7883    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
7884        let html = panel();
7885        assert!(!html.contains("<script"), "no script survives the csp");
7886        assert!(!html.contains("<form"), "form-action is 'none'");
7887        let pr = green_pr();
7888        assert_eq!(
7889            html.matches("http").count(),
7890            html.matches(pr.url.as_str()).count(),
7891            "the only http url in the panel is the pull request's own link"
7892        );
7893        assert!(
7894            html.contains("target=\"_blank\""),
7895            "the PR link must open in a new tab so it escapes the iframe"
7896        );
7897        assert!(
7898            html.contains("rel=\"noopener noreferrer\""),
7899            "external links opening in new tabs must carry noopener noreferrer"
7900        );
7901    }
7902
7903    #[test]
7904    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
7905        let html = panel();
7906        assert!(
7907            html.contains(">+</span>"),
7908            "an added line carries a `+` in the gutter, not only a background"
7909        );
7910        assert!(
7911            html.contains(">-</span>"),
7912            "a removed line carries a `-` in the gutter, not only a background"
7913        );
7914        assert!(
7915            html.contains(">new line</span>"),
7916            "the marker is moved to the gutter, so the body is printed once without it"
7917        );
7918    }
7919
7920    #[test]
7921    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
7922        let total = DIFF_MAX_LINES + 100;
7923        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
7924        let html = approval_panel(
7925            &run_state(),
7926            &green_pr(),
7927            NUMSTAT,
7928            &diff,
7929            &[],
7930            "feat: something long",
7931        );
7932        assert!(
7933            html.contains(&format!("100 of {total} diff lines omitted")),
7934            "the note must say exactly how much was cut"
7935        );
7936        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
7937        assert!(
7938            !html.contains(&format!("line {DIFF_MAX_LINES}")),
7939            "nothing past the threshold is rendered"
7940        );
7941        assert!(
7942            html.contains("/repo/magi"),
7943            "the note says where the rest is"
7944        );
7945    }
7946
7947    #[test]
7948    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
7949        let html = approval_panel(
7950            &run_state(),
7951            &green_pr(),
7952            "1\t2\tsrc/<b>&\"x\"'.rs",
7953            "",
7954            &[],
7955            "subject",
7956        );
7957        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
7958        assert!(
7959            !html.contains("<b>"),
7960            "an agent-influenced path must never become markup"
7961        );
7962    }
7963
7964    #[tokio::test]
7965    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
7966        let a = std::path::PathBuf::from("/repo/a");
7967        let b = std::path::PathBuf::from("/repo/b");
7968
7969        let held = repo_merge_lock(&a).lock_owned().await;
7970
7971        // A second, concurrent land run against the *same* repository must
7972        // wait - `try_lock` fails while `held` is alive.
7973        assert!(
7974            repo_merge_lock(&a).try_lock().is_err(),
7975            "a second merge into the same repository must not proceed concurrently"
7976        );
7977
7978        // A run against a *different* repository must not be blocked by it -
7979        // this is what keeps a slow rebase or `gh pr merge` in one
7980        // repository from also stalling a land-approval resume in another.
7981        assert!(
7982            repo_merge_lock(&b).try_lock().is_ok(),
7983            "a different repository's merge lock must be independent"
7984        );
7985
7986        drop(held);
7987        assert!(
7988            repo_merge_lock(&a).try_lock().is_ok(),
7989            "the lock is released once the holder is done"
7990        );
7991    }
7992
7993    pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
7994        let mut state = landing_state();
7995        let mut rec = review_record(1, "alpha", "precise rejection reason");
7996        rec.vote = Some(if reject {
7997            crate::verdict::ReviewVote::Reject
7998        } else {
7999            crate::verdict::ReviewVote::Approve
8000        });
8001        if major {
8002            rec.findings = contested().findings;
8003        }
8004        let mut round = review_round(1, vec![rec]);
8005        round.clean = false;
8006        round.blocking = usize::from(major);
8007        state.reviews.push(round);
8008        state.config.graph.review_rounds = 1;
8009        state
8010    }
8011
8012    #[test]
8013    fn approval_policy_controls_are_independent_and_keep_all_reasons() {
8014        for every in [false, true] {
8015            for contested in [false, true] {
8016                for major in [false, true] {
8017                    for reject in [false, true] {
8018                        let mut state = repair_state(major, reject);
8019                        state.config.graph.land_approval = every;
8020                        state.config.graph.hold_contested_merge = contested;
8021                        let reasons = approval_reasons(&state);
8022                        assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
8023                        assert_eq!(
8024                            reasons.contains(&ApprovalReason::OpenBlockingFindings),
8025                            contested && major
8026                        );
8027                        assert_eq!(
8028                            reasons.contains(&ApprovalReason::FinalReject),
8029                            contested && reject
8030                        );
8031                        assert_eq!(
8032                            reasons.len(),
8033                            usize::from(every)
8034                                + usize::from(contested && major)
8035                                + usize::from(contested && reject)
8036                        );
8037                    }
8038                }
8039            }
8040        }
8041    }
8042
8043    #[test]
8044    fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8045        use crate::verdict::ReviewVote::{Approve, Reject};
8046        for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8047            let mut state = repair_state(false, initial_reject);
8048            state.reviews[0]
8049                .reconsideration
8050                .push(crate::run::ReviewRevoteRecord {
8051                    reviewer: 1,
8052                    agent: "alpha".into(),
8053                    vote: Some(final_vote),
8054                    reason: "changed after reading the panel <evidence>".into(),
8055                    failed: None,
8056                });
8057            assert_eq!(
8058                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8059                final_vote == Reject
8060            );
8061            for language in ["en", "ja"] {
8062                state.config.graph.language = language.into();
8063                let evidence = approval_evidence(&state);
8064                assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8065                assert!(evidence.contains("changed after reading the panel <evidence>"));
8066                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8067                assert!(html.contains("changed after reading the panel &lt;evidence&gt;"));
8068                assert!(!html.contains("<evidence>"));
8069            }
8070            state.reviews[0].reconsideration[0].vote = None;
8071            state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8072            assert_eq!(
8073                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8074                initial_reject
8075            );
8076            assert!(approval_evidence(&state).contains("timed out"));
8077        }
8078    }
8079
8080    #[tokio::test]
8081    async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8082        for every in [false, true] {
8083            for contested in [false, true] {
8084                for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8085                {
8086                    let mut state = repair_state(major, reject);
8087                    state.config.graph.land_approval = every;
8088                    state.config.graph.hold_contested_merge = contested;
8089                    let forge = Scripted::new(
8090                        vec![seen("abc", Checks::Green, "CLEAN", false)],
8091                        vec![(true, "")],
8092                    );
8093                    land_with(&mut state, URL, &forge).await.unwrap();
8094                    let expected = every || (contested && (major || reject));
8095                    assert_eq!(state.land_approval.is_some(), expected);
8096                    assert_eq!(forge.calls().contains(&"merge"), !expected);
8097                    if expected {
8098                        assert!(state.parked);
8099                    }
8100                }
8101            }
8102        }
8103    }
8104
8105    #[tokio::test]
8106    async fn reviewed_branch_hold_is_read_before_landing_decision() {
8107        for (base, control) in [
8108            (false, "land_approval"),
8109            (true, "land_approval"),
8110            (false, "hold_contested_merge"),
8111        ] {
8112            let (_tmp, mut state, _) = landing_git_fixture().await;
8113            state.config.graph.land_approval = base;
8114            state.config.graph.hold_contested_merge = false;
8115            state.reviewed_commits = Some(Vec::new());
8116            if control == "hold_contested_merge" {
8117                state.reviews = repair_state(false, true).reviews;
8118            }
8119            std::fs::write(
8120                state.repo.join("magi.toml"),
8121                format!("[graph]\n{control} = {}\n", !base),
8122            )
8123            .unwrap();
8124            git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8125            git::git(&state.repo, &["commit", "-m", "approval policy"])
8126                .await
8127                .unwrap();
8128            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8129            state.land_sync_pending = false;
8130            state.pr_pushed_head = Some(head.clone());
8131            state.gate_head = Some(head.clone());
8132            let forge = Scripted::new(
8133                vec![seen(&head, Checks::Green, "CLEAN", false)],
8134                vec![(true, "")],
8135            );
8136            land_with(&mut state, URL, &forge).await.unwrap();
8137            assert!(
8138                state.land_approval.is_some(),
8139                "base={base}, control={control}"
8140            );
8141            assert!(!forge.calls().contains(&"merge"));
8142            if !base {
8143                let question = ask::Questions::open()
8144                    .get(&state.land_approval.as_ref().unwrap().question)
8145                    .unwrap();
8146                assert!(question.detail.contains(&format!(
8147                    "{control}=true from the reviewed branch's magi.toml"
8148                )));
8149            }
8150        }
8151    }
8152
8153    #[tokio::test]
8154    async fn reviewed_branch_hold_asks_and_survives_removal_and_resume() {
8155        let (_tmp, mut state, _) = landing_git_fixture().await;
8156        state.config.graph.land_approval = false;
8157        state.config.graph.hold_contested_merge = false;
8158        state.reviewed_commits = Some(Vec::new());
8159        std::fs::write(
8160            state.repo.join("magi.toml"),
8161            "[graph]\nland_approval = true\n[verify]\ngate = ['untrusted']\n",
8162        )
8163        .unwrap();
8164        git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8165        git::git(&state.repo, &["commit", "-m", "require approval"])
8166            .await
8167            .unwrap();
8168        let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8169        let gate = state.config.verify.gate.clone();
8170        state.observe_branch_owner_holds(&head).await;
8171        assert_eq!(state.config.verify.gate, gate);
8172        assert_eq!(approval_reasons(&state), [ApprovalReason::EveryMerge]);
8173        git::git(&state.repo, &["rm", "magi.toml"]).await.unwrap();
8174        git::git(&state.repo, &["commit", "-m", "remove config"])
8175            .await
8176            .unwrap();
8177        let later = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8178        state = serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
8179        state.observe_branch_owner_holds(&later).await;
8180        assert_eq!(state.branch_owner_holds[0].head, head);
8181        state.land_sync_pending = false;
8182        state.pr_pushed_head = Some(later.clone());
8183        state.gate_head = Some(later.clone());
8184        let forge = Scripted::new(
8185            vec![seen(&later, Checks::Green, "CLEAN", false)],
8186            vec![(true, "")],
8187        );
8188        land_with(&mut state, URL, &forge).await.unwrap();
8189        assert!(!forge.calls().contains(&"merge"));
8190        let question = ask::Questions::open()
8191            .get(&state.land_approval.as_ref().unwrap().question)
8192            .unwrap();
8193        assert!(question.detail.contains("Approval required because:"));
8194        assert!(
8195            question
8196                .detail
8197                .contains("land_approval=true from the reviewed branch's magi.toml")
8198        );
8199    }
8200
8201    #[tokio::test]
8202    async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8203        let mut state = repair_state(false, true);
8204        state.reviews[0].clean = true;
8205        let mut finding = contested().findings.remove(0);
8206        finding.severity = crate::verdict::Severity::Minor;
8207        state.reviews[0].reviews[0].findings.push(finding);
8208        assert!(state.open_findings().is_empty());
8209        let mut pr = green_pr();
8210        pr.review_comments.clear();
8211        for language in ["en", "ja"] {
8212            state.config.graph.language = language.into();
8213            let html = approval_panel(&state, &pr, "", "", &[], "subject");
8214            assert!(html.contains("[R3-1-1] minor"));
8215            assert!(html.contains(if language == "ja" {
8216                "内部指摘の解決を意味するものではありません"
8217            } else {
8218                "This does not mean internal findings are resolved"
8219            }));
8220            assert!(
8221                html.find(if language == "ja" {
8222                    "承認が必要な理由:"
8223                } else {
8224                    "Approval required because:"
8225                })
8226                .unwrap()
8227                    < html.find("[R3-1-1]").unwrap()
8228            );
8229        }
8230        state.config.graph.language = "en".into();
8231        let observed = seen("abc", Checks::Green, "CLEAN", false);
8232        assert_eq!(
8233            approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8234                .await
8235                .unwrap(),
8236            ApprovalGate::Pending
8237        );
8238        let id = state.land_approval.as_ref().unwrap().question.clone();
8239        let q = ask::Questions::open().get(&id).unwrap();
8240        assert!(q.detail.starts_with("Approval required because:"));
8241        assert!(q.detail.contains("final reviewer reject"));
8242        assert!(q.detail.contains("[R3-1-1] minor"));
8243        assert!(
8244            q.detail
8245                .contains("No adoption or resolution evidence recorded")
8246        );
8247        state.config.graph.land_approval = false;
8248        state.config.graph.hold_contested_merge = false;
8249        state.reviews.clear();
8250        assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8251        assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8252        assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8253        let forge = Scripted::new(vec![observed], vec![]);
8254        land_with(&mut state, URL, &forge).await.unwrap();
8255        assert!(!forge.calls().contains(&"merge"));
8256        assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8257        assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8258    }
8259
8260    #[tokio::test]
8261    async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8262        let mut state = repair_state(false, true);
8263        state.config.graph.land_approval = false;
8264        let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8265        let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8266        let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8267        state.reviews[0].reviews[0].summary = long_reason.clone();
8268        for i in 1..=10 {
8269            let mut finding = contested().findings.remove(0);
8270            finding.id = format!("R1-1-{i}");
8271            finding.severity = if i == 9 {
8272                crate::verdict::Severity::Major
8273            } else {
8274                crate::verdict::Severity::Minor
8275            };
8276            finding.title = long_title.clone();
8277            finding.detail = long_detail.clone();
8278            state.reviews[0].reviews[0].findings.push(finding);
8279        }
8280        state.reviews[0].fix = Some(crate::run::FixRecord {
8281            agent: "fixer".into(),
8282            addressed: vec!["R1-1-9".into()],
8283            rejected: vec![crate::verdict::Rejection {
8284                id: "R1-1-10".into(),
8285                why: long_reason.clone(),
8286            }],
8287            notes: long_detail.clone(),
8288            committed: false,
8289            failed: None,
8290            duration_ms: 0,
8291            continuation: None,
8292        });
8293        assert_eq!(
8294            approval_reasons(&state),
8295            [
8296                ApprovalReason::OpenBlockingFindings,
8297                ApprovalReason::FinalReject
8298            ]
8299        );
8300        let excerpt = approval_excerpt(&long_reason, 300);
8301        assert_eq!(excerpt.chars().count(), 300);
8302        assert!(excerpt.ends_with('…'));
8303        let boundary = "拒".repeat(300);
8304        assert_eq!(approval_excerpt(&boundary, 300), boundary);
8305        for language in ["en", "ja"] {
8306            state.config.graph.language = language.into();
8307            for revote in [
8308                None,
8309                Some(crate::verdict::ReviewVote::Reject),
8310                Some(crate::verdict::ReviewVote::Approve),
8311            ] {
8312                state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8313                state.reviews[0].reconsideration.clear();
8314                if let Some(vote) = revote {
8315                    state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8316                    state.reviews[0]
8317                        .reconsideration
8318                        .push(crate::run::ReviewRevoteRecord {
8319                            reviewer: 1,
8320                            agent: "alpha".into(),
8321                            vote: Some(vote),
8322                            reason: long_reason.clone(),
8323                            failed: None,
8324                        });
8325                }
8326                let evidence = approval_evidence(&state);
8327                let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8328                assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8329                assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8330                assert_eq!(
8331                    approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8332                    final_vote == crate::verdict::ReviewVote::Reject
8333                );
8334                assert!(evidence.contains(&excerpt));
8335                assert!(evidence.contains("[R1-1-8] minor"));
8336                assert!(evidence.contains("[R1-1-9] major"));
8337                assert!(evidence.contains("[R1-1-10] minor"));
8338                for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8339                    assert!(!evidence.contains(tail));
8340                }
8341                assert!(evidence.contains(if language == "ja" {
8342                    "採用申告(解決未確認)"
8343                } else {
8344                    "claimed addressed (resolution unconfirmed)"
8345                }));
8346                assert!(evidence.contains(if language == "ja" {
8347                    "不採用理由"
8348                } else {
8349                    "declined"
8350                }));
8351                let major_evidence = evidence
8352                    .split("[R1-1-9] major")
8353                    .nth(1)
8354                    .unwrap()
8355                    .split("[R1-1-10]")
8356                    .next()
8357                    .unwrap();
8358                assert!(major_evidence.contains(if language == "ja" {
8359                    "採用申告(解決未確認)"
8360                } else {
8361                    "claimed addressed (resolution unconfirmed)"
8362                }));
8363                let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8364                assert!(last_evidence.contains(if language == "ja" {
8365                    "不採用理由"
8366                } else {
8367                    "declined"
8368                }));
8369                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8370                assert!(html.contains(&esc(&evidence)));
8371                assert_eq!(
8372                    approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8373                        .await
8374                        .unwrap(),
8375                    ApprovalGate::Pending
8376                );
8377                let id = &state.land_approval.take().unwrap().question;
8378                let q = ask::Questions::open().get(id).unwrap();
8379                assert!(q.detail.starts_with(&evidence));
8380            }
8381            state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8382        }
8383    }
8384
8385    #[test]
8386    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8387        for (major, reject) in [(true, false), (false, true), (true, true)] {
8388            let state = repair_state(major, reject);
8389            assert!(repair_available(&state));
8390            assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8391        }
8392        assert!(!repair_available(&repair_state(false, false)));
8393        let mut clean = repair_state(true, false);
8394        clean.reviews[0].clean = true;
8395        assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8396        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8397    }
8398
8399    #[tokio::test]
8400    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8401        let mut state = repair_state(true, false);
8402        let pr = green_pr();
8403        approval_gate(&mut state, &pr, "feat: x", None, "abc")
8404            .await
8405            .unwrap();
8406        let store = ask::Questions::open();
8407        let id = state.land_approval.as_ref().unwrap().question.clone();
8408        // Simulate an unanswered two-choice question created by an older build.
8409        store
8410            .update(&id, |q| {
8411                q.choices = vec![APPROVE.into(), HOLD.into()];
8412                Ok(())
8413            })
8414            .unwrap();
8415        let upgraded = store.get(&id).unwrap();
8416        assert_eq!(upgraded.choices, approval_choices(&state));
8417        assert!(!upgraded.actions.contains_key(HOLD));
8418        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
8419        assert!(
8420            store
8421                .list()
8422                .iter()
8423                .any(|q| q.id == id && q.choices == upgraded.choices)
8424        );
8425        assert!(
8426            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
8427        );
8428        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
8429        store
8430            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
8431            .unwrap();
8432        // An earlier build may have recorded the repair answer and stopped
8433        // before any driver consumed it. Reconcile that durable decision too.
8434        store
8435            .update(&id, |q| {
8436                q.actions.clear();
8437                Ok(())
8438            })
8439            .unwrap();
8440        assert_eq!(
8441            store.get(&id).unwrap().chosen_action(),
8442            Some(&ask::ChoiceAction::Resume {
8443                run: state.id.clone()
8444            })
8445        );
8446        let binding = state.land_approval.clone();
8447        assert_eq!(
8448            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8449                .await
8450                .unwrap(),
8451            ApprovalGate::Repair
8452        );
8453        assert!(state.land_approval.is_none());
8454        assert_eq!(state.merge_repairs.len(), 1);
8455        let request = &state.merge_repairs[0];
8456        assert_eq!(request.question, id);
8457        assert_eq!(request.head, "abc");
8458        assert_eq!(request.pr, pr.url);
8459        assert_eq!(
8460            request.reviews[0].reviews[0].summary,
8461            "precise rejection reason"
8462        );
8463        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
8464        assert_eq!(
8465            request.reviews[0].reviews[0].findings[0].detail,
8466            state.reviews[0].reviews[0].findings[0].detail
8467        );
8468        // A repeated consumption of the same answer never dispatches another repair.
8469        state.land_approval = binding;
8470        assert_eq!(
8471            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8472                .await
8473                .unwrap(),
8474            ApprovalGate::Repair
8475        );
8476        assert_eq!(state.merge_repairs.len(), 1);
8477        state.land_approval = None;
8478        state.config.graph.land_approval = false;
8479        state.config.graph.hold_contested_merge = false;
8480        state.reviews.clear();
8481        assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
8482        assert_eq!(
8483            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8484                .await
8485                .unwrap(),
8486            ApprovalGate::Pending
8487        );
8488        let fresh_id = &state.land_approval.as_ref().unwrap().question;
8489        assert_ne!(fresh_id, &id);
8490        let fresh = store.get(fresh_id).unwrap();
8491        assert!(
8492            fresh
8493                .detail
8494                .contains("fresh owner approval after merge repair")
8495        );
8496        assert!(fresh.detail.contains("No review recorded"));
8497        assert!(!fresh.detail.contains("precise rejection reason"));
8498    }
8499
8500    #[tokio::test]
8501    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
8502        for choice in [REPAIR, HOLD] {
8503            let mut state = repair_state(false, true);
8504            let observed = seen("abc", Checks::Green, "CLEAN", false);
8505            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
8506                .await
8507                .unwrap();
8508            let id = state.land_approval.as_ref().unwrap().question.clone();
8509            ask::Questions::open()
8510                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
8511                .unwrap();
8512            let forge = Scripted::new(vec![observed], vec![(true, "")]);
8513            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8514                .await
8515                .unwrap();
8516            assert!(
8517                forge
8518                    .argvs()
8519                    .iter()
8520                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
8521            );
8522            assert!(!forge.calls().contains(&"fix"));
8523            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
8524            assert_ne!(state.status, RunStatus::Merged);
8525        }
8526    }
8527
8528    #[test]
8529    fn only_the_merge_choice_merges_and_silence_holds() {
8530        let table = [
8531            (None, Approval::Hold),
8532            (Some("merge"), Approval::Merge),
8533            (Some(" merge\n"), Approval::Merge),
8534            (Some("hold"), Approval::Hold),
8535            (Some(""), Approval::Hold),
8536            (Some("yes"), Approval::Hold),
8537        ];
8538        for (answer, want) in table {
8539            assert_eq!(
8540                approval(answer),
8541                want,
8542                "answer {answer:?} must resolve to {want:?}"
8543            );
8544        }
8545    }
8546
8547    #[tokio::test]
8548    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
8549        let mut state = landing_state();
8550        state.config.graph.land_approval = true;
8551        let pr = green_pr();
8552
8553        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8554            .await
8555            .unwrap();
8556        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
8557        assert!(
8558            !state.parked,
8559            "approval_gate itself never sets `parked`; only its caller does"
8560        );
8561
8562        let store = ask::Questions::open();
8563        let filed: Vec<_> = store
8564            .list()
8565            .into_iter()
8566            .filter(|q| q.run == state.id)
8567            .collect();
8568        assert_eq!(filed.len(), 1, "exactly one question is filed");
8569        assert_eq!(filed[0].node, APPROVAL_NODE);
8570        assert_eq!(filed[0].choices, approval_choices(&state));
8571        assert!(filed[0].status.open());
8572
8573        // A second visit - standing in for a resumed run whose slot the
8574        // daemon handed to something else while nobody had answered - must
8575        // find the same question rather than filing a second one.
8576        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8577            .await
8578            .unwrap();
8579        assert_eq!(again, ApprovalGate::Pending);
8580        let still_one = store
8581            .list()
8582            .into_iter()
8583            .filter(|q| q.run == state.id)
8584            .count();
8585        assert_eq!(
8586            still_one, 1,
8587            "asking twice must not double-file the question"
8588        );
8589    }
8590
8591    #[tokio::test]
8592    async fn approving_the_existing_question_is_read_back_as_approved() {
8593        crate::run::pin_test_home();
8594        let mut state = run_state();
8595        state.config.graph.land_approval = true;
8596        let pr = green_pr();
8597        assert_eq!(
8598            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8599                .await
8600                .unwrap(),
8601            ApprovalGate::Pending
8602        );
8603
8604        let store = ask::Questions::open();
8605        let mut q = store
8606            .list()
8607            .into_iter()
8608            .find(|q| q.run == state.id)
8609            .expect("filed above");
8610        let talks = crate::talk::Talks::open();
8611        let cfg = crate::config::Config {
8612            agents: vec![crate::config::AgentSpec {
8613                id: "mock".into(),
8614                kind: crate::config::AgentKind::Command,
8615                model: None,
8616                command: vec!["true".into()],
8617                extra_args: Vec::new(),
8618                env: Default::default(),
8619                prompt_delivery: None,
8620                max_turn_secs: None,
8621            }],
8622            ..crate::config::Config::default()
8623        };
8624        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
8625        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
8626        crate::talk::drain(&mut talk, &talks).unwrap();
8627        assert_eq!(
8628            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8629                .await
8630                .unwrap(),
8631            ApprovalGate::Pending,
8632            "consultation cannot approve or hold the merge"
8633        );
8634        q = store.get(&q.id).unwrap();
8635        assert!(q.answer.is_none());
8636        talk.turns.push(crate::talk::Turn {
8637            sender: None,
8638            breaks: Some(Vec::new()),
8639            messages: Vec::new(),
8640            posted_by: None,
8641            who: crate::talk::Who::Operator,
8642            body: "merge abc".into(),
8643            at: Timestamp::now(),
8644            attachments: Vec::new(),
8645            usage: None,
8646        });
8647        talks.put(&mut talk).unwrap();
8648        crate::consult::validate_answer(
8649            &q,
8650            &talks,
8651            &talk.id,
8652            crate::queue::CHAT_NODE,
8653            APPROVE,
8654            Some("merge abc"),
8655        )
8656        .unwrap();
8657        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8658        store.put(&mut q).unwrap();
8659
8660        assert_eq!(
8661            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8662                .await
8663                .unwrap(),
8664            ApprovalGate::Approved
8665        );
8666    }
8667
8668    #[tokio::test]
8669    async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
8670        crate::run::pin_test_home();
8671        let store = ask::Questions::open();
8672        for via_list in [false, true] {
8673            let mut state = repair_state(false, true);
8674            let pr = green_pr();
8675            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8676                .await
8677                .unwrap();
8678            let id = state.land_approval.as_ref().unwrap().question.clone();
8679            store
8680                .update(&id, |q| {
8681                    q.actions.insert(
8682                        HOLD.to_owned(),
8683                        ask::ChoiceAction::Resume {
8684                            run: state.id.clone(),
8685                        },
8686                    );
8687                    Ok(())
8688                })
8689                .unwrap();
8690            let q = if via_list {
8691                store.list().into_iter().find(|q| q.id == id).unwrap()
8692            } else {
8693                store.get(&id).unwrap()
8694            };
8695            assert!(!q.actions.contains_key(HOLD));
8696            store
8697                .update(&id, |q| {
8698                    // Check the persisted migration, without another read upgrade.
8699                    assert!(!q.actions.contains_key(HOLD));
8700                    assert!(!upgrade_repair_choice(q, &state));
8701                    q.answer(ask::Answer::Choice(HOLD.to_owned()))
8702                })
8703                .unwrap();
8704            assert!(store.get(&id).unwrap().chosen_action().is_none());
8705            assert_eq!(
8706                approval_gate(&mut state, &pr, "feat: x", None, "abc")
8707                    .await
8708                    .unwrap(),
8709                ApprovalGate::Held
8710            );
8711        }
8712    }
8713
8714    #[tokio::test]
8715    async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
8716        crate::run::pin_test_home();
8717        let store = ask::Questions::open();
8718
8719        let mut held_state = run_state();
8720        held_state.config.graph.land_approval = true;
8721        let pr = green_pr();
8722        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
8723            .await
8724            .unwrap();
8725        let mut q = store
8726            .list()
8727            .into_iter()
8728            .find(|q| q.run == held_state.id)
8729            .expect("filed above");
8730        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
8731        store.put(&mut q).unwrap();
8732        assert_eq!(
8733            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
8734                .await
8735                .unwrap(),
8736            ApprovalGate::Held
8737        );
8738
8739        let mut abandoned_state = run_state();
8740        abandoned_state.config.graph.land_approval = true;
8741        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
8742            .await
8743            .unwrap();
8744        let mut q = store
8745            .list()
8746            .into_iter()
8747            .find(|q| q.run == abandoned_state.id)
8748            .expect("filed above");
8749        q.abandon("no answer within the timeout");
8750        store.put(&mut q).unwrap();
8751        assert_eq!(
8752            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
8753                .await
8754                .unwrap(),
8755            ApprovalGate::Pending,
8756            "an expired approval must ask again without merging"
8757        );
8758        assert_eq!(
8759            store.get(&q.id).unwrap().status,
8760            ask::QuestionStatus::Abandoned
8761        );
8762        assert_ne!(
8763            abandoned_state.land_approval.as_ref().unwrap().question,
8764            q.id
8765        );
8766    }
8767
8768    fn contested() -> ContestedHandoff {
8769        let finding = |id: &str, n: u32| crate::verdict::Finding {
8770            id: id.to_owned(),
8771            severity: crate::verdict::Severity::Major,
8772            file: Some("src/a.rs".to_owned()),
8773            line: Some(n),
8774            title: format!("problem {id}"),
8775            detail: String::new(),
8776        };
8777        ContestedHandoff {
8778            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
8779            rejecters: vec![(1, "alpha".to_owned())],
8780        }
8781    }
8782
8783    #[test]
8784    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
8785        let mut state = run_state();
8786        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
8787        state.contested_handoff = Some(contested());
8788        assert!(contested_to_ask(&state).is_some());
8789        state.config.graph.hold_contested_merge = false;
8790        assert!(
8791            contested_to_ask(&state).is_none(),
8792            "the switch restores today"
8793        );
8794    }
8795
8796    #[test]
8797    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
8798        let q = ask::Question::new(
8799            "run-1".to_owned(),
8800            APPROVAL_NODE.to_owned(),
8801            "land".to_owned(),
8802            "Merge?".to_owned(),
8803            String::new(),
8804            vec![APPROVE.to_owned(), HOLD.to_owned()],
8805        );
8806        let none = deputy_brief(&q, None);
8807        assert!(none.contains("could not be read"), "{none}");
8808        assert!(none.contains("Silence is a hold"), "{none}");
8809
8810        let mut state = run_state();
8811        state.pr = Some(crate::run::PrRecord {
8812            url: "https://example.test/pull/7".to_owned(),
8813            number: 7,
8814            state: "open".to_owned(),
8815            checks: "green".to_owned(),
8816            round: 0,
8817            rounds: 3,
8818            red_at_merge: Vec::new(),
8819        });
8820        state.contested_handoff = Some(contested());
8821        let b = deputy_brief(&q, Some(&state));
8822        assert!(b.contains("https://example.test/pull/7"), "{b}");
8823        assert!(b.contains("not recorded in this legacy question"), "{b}");
8824        assert!(!b.contains("This question was filed although"));
8825        state.contested_handoff = None;
8826        assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
8827    }
8828
8829    #[test]
8830    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
8831        for lang in ["en", "ja"] {
8832            let mut cfg = crate::config::Config::default();
8833            cfg.graph.language = lang.to_owned();
8834            let w = words(&cfg.graph.language);
8835            let text = w.approval_detail(
8836                "https://github.com/yukimemi/magi/pull/42",
8837                "main",
8838                "feat: x",
8839                Some(&contested()),
8840            );
8841            assert!(text.contains("pull/42"), "{text}");
8842            assert!(
8843                text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
8844                "{text}"
8845            );
8846            assert!(text.contains("R3-1-5"), "{text}");
8847            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
8848            assert!(text.contains("2"), "the rest are counted: {text}");
8849            assert!(text.contains("#1 (alpha)"), "{text}");
8850        }
8851        let plain = words("en").approval_detail("u", "main", "s", None);
8852        assert!(!plain.contains("reject"), "{plain}");
8853    }
8854
8855    #[tokio::test]
8856    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
8857        crate::run::pin_test_home();
8858        let mut state = run_state();
8859        state.config.graph.land_approval = false;
8860        state.contested_handoff = Some(contested());
8861        let pr = green_pr();
8862        let c = contested_to_ask(&state);
8863        assert_eq!(
8864            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8865                .await
8866                .unwrap(),
8867            ApprovalGate::Pending,
8868            "silence is a hold"
8869        );
8870        let store = ask::Questions::open();
8871        let filed: Vec<_> = store
8872            .list()
8873            .into_iter()
8874            .filter(|q| q.run == state.id)
8875            .collect();
8876        assert_eq!(filed.len(), 1);
8877        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
8878
8879        assert_eq!(
8880            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8881                .await
8882                .unwrap(),
8883            ApprovalGate::Pending
8884        );
8885        let mut q = store
8886            .list()
8887            .into_iter()
8888            .find(|q| q.run == state.id)
8889            .unwrap();
8890        assert_eq!(q.id, filed[0].id, "the same question after a resume");
8891        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8892        store.put(&mut q).unwrap();
8893        assert_eq!(
8894            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8895                .await
8896                .unwrap(),
8897            ApprovalGate::Approved
8898        );
8899    }
8900
8901    #[test]
8902    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
8903        let rows = parse_numstat(NUMSTAT);
8904        assert_eq!(
8905            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
8906            ["src/web.rs", "src/land.rs", "assets/logo.png"]
8907        );
8908        assert_eq!(rows[2].added, None, "a binary file has no line counts");
8909    }
8910    #[test]
8911    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
8912        // Reported from a real run: the merge question arrived in English on a
8913        // repository with `language = "ja"`. magi's own strings have to follow
8914        // that setting too - "it is a literal in Rust" is not an answer.
8915        let mut state = run_state();
8916        state.config.graph.language = "ja".to_owned();
8917        let pr = green_pr();
8918        let commits = ["c1".to_owned()];
8919
8920        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
8921        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
8922        assert!(ja.contains("squash されるコミット"), "{ja}");
8923        assert!(ja.contains("レビューコメント"), "{ja}");
8924        assert!(ja.contains("差分"), "{ja}");
8925        assert!(
8926            !ja.contains("Commits being squashed"),
8927            "no English left over"
8928        );
8929
8930        let w = words("ja");
8931        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
8932        assert!(
8933            w.approval_detail("http://x/1", "main", "feat: x", None)
8934                .contains("パネル")
8935        );
8936
8937        // The evidence itself is language-neutral and must survive either way.
8938        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
8939        assert!(ja.contains("feat: x"), "nor is the merge subject");
8940
8941        // English stays the default, and a language magi cannot check falls
8942        // back to it rather than shipping a guess.
8943        state.config.graph.language = "en".to_owned();
8944        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
8945        assert!(en.contains("Commits being squashed"), "{en}");
8946        assert_eq!(words("Klingon").html_lang, "en");
8947    }
8948
8949    /// A `gh pr list` result naming exactly one pull request whose base and
8950    /// merge time both fit the run is exactly the case
8951    /// [`find_external_merge`] exists to act on.
8952    #[test]
8953    fn pick_open_pr_classifies_by_count_and_base() {
8954        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
8955        assert_eq!(
8956            pick_open_pr(one, "main").unwrap(),
8957            OpenPr::One {
8958                url: "https://x/pull/58".into(),
8959                title: "t".into()
8960            }
8961        );
8962        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
8963        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
8964        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
8965                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
8966        assert_eq!(
8967            pick_open_pr(two, "main").unwrap(),
8968            OpenPr::Many(vec!["u1".into(), "u2".into()])
8969        );
8970        // A fork's same-named branch is not a pull request for this head.
8971        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
8972        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
8973        assert!(pick_open_pr("not json", "main").is_err());
8974        // An incomplete record is an error, never "nothing open".
8975        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
8976        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
8977    }
8978
8979    #[test]
8980    fn pick_merged_pr_picks_the_unique_match() {
8981        let json = r#"[
8982            {"url": "https://github.com/o/r/pull/42", "number": 42,
8983             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
8984        ]"#;
8985        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
8986        let found = pick_merged_pr(json, "main", created_at)
8987            .expect("valid json")
8988            .expect("one unambiguous match");
8989        assert_eq!(found.url, "https://github.com/o/r/pull/42");
8990        assert_eq!(found.number, 42);
8991    }
8992
8993    /// Two candidates surviving the filter is exactly as uninformative as
8994    /// zero — a branch name can be reused across runs — so neither is
8995    /// preferred over the other and nothing is recorded automatically.
8996    #[test]
8997    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
8998        let json = r#"[
8999            {"url": "https://github.com/o/r/pull/42", "number": 42,
9000             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
9001            {"url": "https://github.com/o/r/pull/43", "number": 43,
9002             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
9003        ]"#;
9004        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9005        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9006    }
9007
9008    /// A pull request that targets a different base branch cannot be this
9009    /// run's, whatever its head branch is named — a reused branch name from
9010    /// an unrelated task must not be recorded as this run's merge.
9011    #[test]
9012    fn pick_merged_pr_ignores_a_different_base_branch() {
9013        let json = r#"[
9014            {"url": "https://github.com/o/r/pull/42", "number": 42,
9015             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
9016        ]"#;
9017        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9018        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9019    }
9020
9021    /// A pull request merged before this run was even created cannot be this
9022    /// run's winner, no matter how its head branch is spelled.
9023    #[test]
9024    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
9025        let json = r#"[
9026            {"url": "https://github.com/o/r/pull/42", "number": 42,
9027             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
9028        ]"#;
9029        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9030        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9031    }
9032
9033    #[test]
9034    fn slug_of_pr_url_reads_host_owner_and_repo() {
9035        assert_eq!(
9036            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
9037            Some("github.com/yukimemi/shun")
9038        );
9039    }
9040
9041    #[test]
9042    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
9043        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
9044        assert_eq!(slug_of_pr_url("not a url at all"), None);
9045        assert_eq!(slug_of_pr_url("https://github.com"), None);
9046    }
9047
9048    #[test]
9049    fn slug_of_repo_url_reads_host_owner_and_repo() {
9050        assert_eq!(
9051            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
9052            Some("github.com/yukimemi/magi")
9053        );
9054        assert_eq!(slug_of_repo_url("https://github.com"), None);
9055    }
9056
9057    #[test]
9058    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
9059        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
9060            .expect("same repo, different case");
9061    }
9062
9063    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
9064    /// own in-progress run and rewrote its status from a pull request in a
9065    /// completely different repository. This is the guard that must catch
9066    /// that even when an explicit (but wrong) id is given.
9067    #[test]
9068    fn ensure_same_repo_refuses_a_different_repo() {
9069        let err =
9070            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
9071        let msg = format!("{err:#}");
9072        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9073        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
9074    }
9075
9076    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
9077    /// instance mirroring a `github.com` repository's name, say) must not be
9078    /// treated as the same repository just because the trailing path
9079    /// matches.
9080    #[test]
9081    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
9082        let err = ensure_same_repo(
9083            "github.com/yukimemi/magi",
9084            "github.example.com/yukimemi/magi",
9085        )
9086        .unwrap_err();
9087        let msg = format!("{err:#}");
9088        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9089        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
9090    }
9091
9092    /// No winner decided yet means there is no branch to ask GitHub about at
9093    /// all — `find_external_merge` must return `None` without ever spawning
9094    /// `gh`, which this proves by never providing a real repository to spawn
9095    /// it in.
9096    #[tokio::test]
9097    async fn find_external_merge_returns_none_without_a_winner() {
9098        let state = RunState::new(
9099            PathBuf::from("/no/such/repo"),
9100            "main".to_owned(),
9101            "0000000000000000000000000000000000000000".to_owned(),
9102            "irrelevant".to_owned(),
9103            crate::config::Config::default(),
9104        );
9105        assert_eq!(find_external_merge(&state).await.unwrap(), None);
9106    }
9107
9108    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
9109        let mut run = RunState::new(
9110            PathBuf::from(repo),
9111            "main".to_owned(),
9112            "abcdef1234".to_owned(),
9113            "x".to_owned(),
9114            crate::config::Config::default(),
9115        );
9116        run.id = id.to_owned();
9117        run.status = status;
9118        run.driver_exited = true;
9119        run.pr = Some(crate::run::PrRecord {
9120            number: url.rsplit('/').next().unwrap().parse().unwrap(),
9121            url: url.to_owned(),
9122            state: state.to_owned(),
9123            checks: "red".to_owned(),
9124            round: 0,
9125            rounds: 2,
9126            red_at_merge: Vec::new(),
9127        });
9128        run.save_under(home).unwrap();
9129    }
9130
9131    fn recorded(home: &Path, id: &str) -> String {
9132        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9133        serde_json::from_str::<RunState>(&body)
9134            .unwrap()
9135            .pr
9136            .unwrap()
9137            .state
9138    }
9139
9140    const PR: &str = "https://github.com/o/r/pull/7";
9141
9142    #[test]
9143    fn write_through_updates_predecessors_and_siblings_only() {
9144        let tmp = tempfile::tempdir().unwrap();
9145        let h = tmp.path();
9146        pr_run(
9147            h,
9148            "20261004-100000-aaaa",
9149            "/repo/r",
9150            RunStatus::Superseded,
9151            PR,
9152            "open",
9153        );
9154        pr_run(
9155            h,
9156            "20261004-100100-bbbb",
9157            "/repo/r",
9158            RunStatus::Blocked,
9159            PR,
9160            "open",
9161        );
9162        // Not terminal: a driver may be writing it.
9163        pr_run(
9164            h,
9165            "20261004-100200-cccc",
9166            "/repo/r",
9167            RunStatus::Landing,
9168            PR,
9169            "open",
9170        );
9171        // Another repository's pull request with the same number.
9172        pr_run(
9173            h,
9174            "20261004-100300-dddd",
9175            "/repo/other",
9176            RunStatus::Blocked,
9177            "https://github.com/o/other/pull/7",
9178            "open",
9179        );
9180        // A different pull request of the same repository.
9181        pr_run(
9182            h,
9183            "20261004-100400-eeee",
9184            "/repo/r",
9185            RunStatus::Blocked,
9186            "https://github.com/o/r/pull/8",
9187            "open",
9188        );
9189        pr_run(
9190            h,
9191            "20261004-100500-ffff",
9192            "/repo/r",
9193            RunStatus::Merged,
9194            PR,
9195            "open",
9196        );
9197        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9198
9199        assert_eq!(
9200            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9201            2
9202        );
9203        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9204        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9205        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9206        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9207        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9208        // The source's own record is the caller's to write.
9209        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9210        // Idempotent.
9211        assert_eq!(
9212            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9213            0
9214        );
9215        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9216        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9217    }
9218
9219    #[test]
9220    fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9221        let home = tempfile::tempdir().unwrap();
9222        let live_id = "20261004-100000-aaaa";
9223        let unknown_id = "20261004-100100-bbbb";
9224        for id in [live_id, unknown_id] {
9225            pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9226            let mut s = RunState::load_under(id, home.path()).unwrap();
9227            s.driver_exited = false;
9228            if id == live_id {
9229                s.driver_pid = Some(std::process::id());
9230                s.driver_started_at = crate::proc::process_started_at(std::process::id());
9231            }
9232            s.save_under(home.path()).unwrap();
9233        }
9234        let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9235        assert_eq!(apply_pr_states(home.path(), &known), 0);
9236        for id in [live_id, unknown_id] {
9237            assert_eq!(recorded(home.path(), id), "open");
9238        }
9239    }
9240
9241    #[test]
9242    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9243        let tmp = tempfile::tempdir().unwrap();
9244        let h = tmp.path();
9245        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9246        pr_run(
9247            h,
9248            "20261004-100000-aaaa",
9249            "/repo/r",
9250            RunStatus::Superseded,
9251            &url(1),
9252            "open",
9253        );
9254        pr_run(
9255            h,
9256            "20261004-100100-bbbb",
9257            "/repo/r",
9258            RunStatus::Blocked,
9259            &url(2),
9260            "open",
9261        );
9262        pr_run(
9263            h,
9264            "20261004-100200-cccc",
9265            "/repo/r",
9266            RunStatus::Ready,
9267            &url(3),
9268            "open",
9269        );
9270        pr_run(
9271            h,
9272            "20261004-100300-dddd",
9273            "/repo/r",
9274            RunStatus::Ready,
9275            &url(4),
9276            "open",
9277        );
9278        pr_run(
9279            h,
9280            "20261004-100400-eeee",
9281            "/repo/r",
9282            RunStatus::Implementing,
9283            &url(1),
9284            "open",
9285        );
9286        assert_eq!(stale_open_prs(h).len(), 4);
9287
9288        let mut known = BTreeMap::new();
9289        known.insert(url(1), PrLifecycle::Merged);
9290        known.insert(url(2), PrLifecycle::Closed);
9291        known.insert(url(3), PrLifecycle::Open);
9292        // #4: the forge could not be read, so it has no answer.
9293        assert_eq!(apply_pr_states(h, &known), 2);
9294        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9295        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9296        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9297        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9298        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9299        assert_eq!(apply_pr_states(h, &known), 0);
9300    }
9301
9302    // --- the land loop against a scripted forge -------------------------
9303
9304    use std::collections::VecDeque;
9305    use std::sync::Mutex;
9306
9307    /// Answers views from a script (the last one repeats), merges from a
9308    /// queue, and records every call so a test can assert the order.
9309    struct Scripted {
9310        views: Mutex<VecDeque<Seen>>,
9311        merges: Mutex<VecDeque<(bool, String)>>,
9312        fix: Mutex<Option<Fixed>>,
9313        log: Mutex<Vec<&'static str>>,
9314        argvs: Mutex<Vec<Vec<String>>>,
9315        required: Mutex<Option<BTreeSet<String>>>,
9316        /// Set once a merge answered ok: the forge then reports `merged`,
9317        /// unless `queued` says the merge only entered a queue.
9318        merged: Mutex<bool>,
9319        queued: Mutex<bool>,
9320        /// Views fail once a merge answered ok.
9321        unreadable_after_merge: Mutex<bool>,
9322        /// Workflow runs asked to rerun, and whether that call fails.
9323        reruns: Mutex<Vec<String>>,
9324        rerun_fails: Mutex<bool>,
9325        /// Unix seconds; every poll moves it on by `POLL`.
9326        clock: Mutex<i64>,
9327    }
9328
9329    impl Scripted {
9330        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9331            Self {
9332                views: Mutex::new(views.into()),
9333                merges: Mutex::new(
9334                    merges
9335                        .into_iter()
9336                        .map(|(ok, m)| (ok, m.to_owned()))
9337                        .collect(),
9338                ),
9339                fix: Mutex::new(None),
9340                log: Mutex::new(Vec::new()),
9341                argvs: Mutex::new(Vec::new()),
9342                required: Mutex::new(None),
9343                merged: Mutex::new(false),
9344                queued: Mutex::new(false),
9345                unreadable_after_merge: Mutex::new(false),
9346                reruns: Mutex::new(Vec::new()),
9347                rerun_fails: Mutex::new(false),
9348                clock: Mutex::new(1_000),
9349            }
9350        }
9351        fn argvs(&self) -> Vec<Vec<String>> {
9352            self.argvs.lock().unwrap().clone()
9353        }
9354        fn calls(&self) -> Vec<&'static str> {
9355            self.log.lock().unwrap().clone()
9356        }
9357    }
9358
9359    impl Forge for Scripted {
9360        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9361            self.log.lock().unwrap().push("view");
9362            if *self.unreadable_after_merge.lock().unwrap()
9363                && !self.argvs.lock().unwrap().is_empty()
9364            {
9365                anyhow::bail!("forge unreachable");
9366            }
9367            let mut v = self.views.lock().unwrap();
9368            let mut seen = if v.len() > 1 {
9369                v.pop_front().unwrap()
9370            } else {
9371                v[0].clone()
9372            };
9373            if *self.merged.lock().unwrap() {
9374                seen.pr.state = PrLifecycle::Merged;
9375            }
9376            Ok(seen)
9377        }
9378        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9379            self.log.lock().unwrap().push("merge");
9380            self.argvs.lock().unwrap().push(argv.to_vec());
9381            let out = self
9382                .merges
9383                .lock()
9384                .unwrap()
9385                .pop_front()
9386                .expect("unscripted merge");
9387            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9388            {
9389                *self.merged.lock().unwrap() = true;
9390            }
9391            Ok(out)
9392        }
9393        async fn poll(&self) {
9394            self.log.lock().unwrap().push("poll");
9395            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9396        }
9397        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9398            self.log.lock().unwrap().push("rerun");
9399            self.reruns.lock().unwrap().push(run.to_owned());
9400            if *self.rerun_fails.lock().unwrap() {
9401                anyhow::bail!("gh run rerun: no");
9402            }
9403            Ok(())
9404        }
9405        fn now(&self) -> i64 {
9406            *self.clock.lock().unwrap()
9407        }
9408        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9409            self.required.lock().unwrap().clone()
9410        }
9411        async fn fix(
9412            &self,
9413            _state: &mut RunState,
9414            _pr: &PrState,
9415            _round: usize,
9416            _budget: usize,
9417            _reason: &str,
9418            _logs: &str,
9419        ) -> Result<Fixed> {
9420            self.log.lock().unwrap().push("fix");
9421            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
9422        }
9423    }
9424
9425    const REFUSED: &str =
9426        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
9427
9428    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
9429        let mut pr = green_pr();
9430        pr.checks = checks;
9431        pr.blocking = Blocking::of(merge_state);
9432        if !comments {
9433            pr.review_comments.clear();
9434        }
9435        Seen {
9436            pr,
9437            title: "feat: x".to_owned(),
9438            failing_urls: Vec::new(),
9439            head: head.to_owned(),
9440            rollup_head: head.to_owned(),
9441            merge_state: merge_state.to_owned(),
9442            contexts: Vec::new(),
9443            base: "main".to_owned(),
9444        }
9445    }
9446
9447    fn landing_state() -> RunState {
9448        crate::run::pin_test_home();
9449        let mut state = run_state();
9450        state.config.graph.land_approval = false;
9451        state
9452    }
9453
9454    #[test]
9455    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
9456        assert!(!awaiting_new_head(None, "aaa"));
9457        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
9458        assert!(awaiting_new_head(Some("abc123"), "def456"));
9459        assert!(awaiting_new_head(Some("abc123"), ""));
9460    }
9461
9462    #[test]
9463    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
9464        let open = |c, m: &str| seen("a", c, m, false);
9465        let table = [
9466            (None, false, Refused::Pending),
9467            (
9468                Some(open(Checks::Pending, "BLOCKED")),
9469                false,
9470                Refused::Pending,
9471            ),
9472            (
9473                Some(open(Checks::Unknown, "BLOCKED")),
9474                false,
9475                Refused::Pending,
9476            ),
9477            (
9478                Some(open(Checks::Green, "UNKNOWN")),
9479                false,
9480                Refused::Pending,
9481            ),
9482            (Some(open(Checks::Green, "")), false, Refused::Pending),
9483            (
9484                Some(open(Checks::Green, "BLOCKED")),
9485                false,
9486                Refused::Recheck,
9487            ),
9488            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
9489        ];
9490        for (after, rechecked, want) in table {
9491            assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
9492        }
9493        let mut closed = open(Checks::Green, "CLEAN");
9494        closed.pr.state = PrLifecycle::Closed;
9495        assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
9496    }
9497
9498    #[tokio::test]
9499    async fn a_normal_landing_merges_on_the_first_look() {
9500        let mut state = landing_state();
9501        let forge = Scripted::new(
9502            vec![seen("a", Checks::Green, "CLEAN", false)],
9503            vec![(true, "")],
9504        );
9505        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9506            .await
9507            .unwrap();
9508        // One fresh read, then the head-bound merge.
9509        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9510        assert_eq!(state.status, RunStatus::Merged);
9511    }
9512
9513    #[tokio::test]
9514    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
9515        let mut state = landing_state();
9516        let forge = Scripted::new(
9517            vec![seen("a", Checks::Green, "CLEAN", false)],
9518            std::iter::repeat_n((true, ""), 100).collect(),
9519        );
9520        *forge.queued.lock().unwrap() = true;
9521        let task = async {
9522            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9523                .await
9524                .unwrap();
9525        };
9526        // Still open after the command succeeded: it keeps watching and
9527        // never records a merge (it stops at the wait ceiling instead).
9528        task.await;
9529        assert_ne!(state.status, RunStatus::Merged);
9530    }
9531
9532    #[tokio::test]
9533    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
9534        let mut state = landing_state();
9535        let forge = Scripted::new(
9536            vec![seen("a", Checks::Green, "CLEAN", false)],
9537            std::iter::repeat_n((true, ""), 100).collect(),
9538        );
9539        *forge.unreadable_after_merge.lock().unwrap() = true;
9540        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9541            .await
9542            .ok();
9543        assert_ne!(state.status, RunStatus::Merged);
9544    }
9545
9546    #[tokio::test]
9547    async fn a_task_public_head_is_awaited_on_land_entry() {
9548        let mut state = landing_state();
9549        state.pr_head = Some("magi/task-abcd-1234".into());
9550        state.pr_pushed_head = Some("new".into());
9551        let forge = Scripted::new(
9552            vec![
9553                seen("old", Checks::Green, "CLEAN", false),
9554                seen("new", Checks::Green, "CLEAN", false),
9555            ],
9556            vec![(true, "")],
9557        );
9558        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9559            .await
9560            .unwrap();
9561        assert_eq!(
9562            forge.calls(),
9563            ["view", "poll", "view", "view", "merge", "view"]
9564        );
9565        assert_eq!(state.status, RunStatus::Merged);
9566    }
9567
9568    #[tokio::test]
9569    async fn a_superseded_public_head_owner_cannot_reenter_land() {
9570        let mut state = landing_state();
9571        state.pr_taken_over_by = Some("next".into());
9572        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
9573        assert!(
9574            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9575                .await
9576                .is_err()
9577        );
9578        assert!(forge.calls().is_empty());
9579    }
9580
9581    #[tokio::test]
9582    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
9583        let mut state = landing_state();
9584        let forge = Scripted::new(
9585            vec![
9586                seen("old", Checks::Green, "CLEAN", true),
9587                // The forge has not moved to the new head yet: still green.
9588                seen("old", Checks::Green, "CLEAN", true),
9589                seen("new", Checks::Pending, "BLOCKED", true),
9590                seen("new", Checks::Green, "CLEAN", true),
9591            ],
9592            vec![(true, "")],
9593        );
9594        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9595            head: "NEW".to_owned(),
9596        });
9597        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9598            .await
9599            .unwrap();
9600        assert_eq!(
9601            forge.calls(),
9602            [
9603                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
9604                "view"
9605            ]
9606        );
9607        assert_eq!(state.status, RunStatus::Merged);
9608    }
9609
9610    #[tokio::test]
9611    async fn a_head_that_never_arrives_stops_naming_both_commits() {
9612        let mut state = landing_state();
9613        let forge = Scripted::new(
9614            vec![
9615                seen("old", Checks::Green, "CLEAN", true),
9616                seen("someone-elses", Checks::Green, "CLEAN", true),
9617            ],
9618            vec![],
9619        );
9620        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9621            head: "mine".to_owned(),
9622        });
9623        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9624            .await
9625            .unwrap();
9626        assert!(!forge.calls().contains(&"merge"));
9627        let why = state.merge.as_ref().unwrap().detail.clone();
9628        assert!(
9629            why.contains("mine") && why.contains("someone-elses"),
9630            "{why}"
9631        );
9632        assert_eq!(state.status, RunStatus::Blocked);
9633    }
9634
9635    #[tokio::test]
9636    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
9637        let mut state = landing_state();
9638        let forge = Scripted::new(
9639            vec![
9640                seen("a", Checks::Green, "CLEAN", false),
9641                seen("a", Checks::Green, "CLEAN", false),
9642                seen("a", Checks::Pending, "BLOCKED", false),
9643                seen("a", Checks::Pending, "BLOCKED", false),
9644                seen("a", Checks::Green, "CLEAN", false),
9645            ],
9646            vec![(false, REFUSED), (true, "")],
9647        );
9648        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9649            .await
9650            .unwrap();
9651        assert_eq!(
9652            forge.calls(),
9653            [
9654                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
9655                "view"
9656            ]
9657        );
9658        assert_eq!(state.status, RunStatus::Merged);
9659    }
9660
9661    #[tokio::test]
9662    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
9663        let mut state = landing_state();
9664        let forge = Scripted::new(
9665            vec![
9666                seen("a", Checks::Green, "CLEAN", false),
9667                seen("a", Checks::Green, "BLOCKED", false),
9668            ],
9669            vec![(false, REFUSED), (false, REFUSED)],
9670        );
9671        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9672            .await
9673            .unwrap();
9674        // One re-look is allowed for the forge's own lag, then it is final.
9675        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
9676        let why = state.merge.as_ref().unwrap().detail.clone();
9677        assert!(
9678            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
9679            "{why}"
9680        );
9681        assert_eq!(state.status, RunStatus::Blocked);
9682    }
9683
9684    #[test]
9685    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
9686        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
9687        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
9688        // The pull request is still on the commit before the push.
9689        assert_eq!(bound_head("old", "old", Some("new")), None);
9690        // The checks are the previous commit's.
9691        assert_eq!(bound_head("new", "old", Some("new")), None);
9692        assert_eq!(bound_head("new", "old", None), None);
9693        // Nothing readable.
9694        assert_eq!(bound_head("", "", None), None);
9695        assert_eq!(bound_head("", "", Some("new")), None);
9696        assert_eq!(bound_head("abc", "", None), None);
9697    }
9698
9699    fn view_json(head: &str) -> String {
9700        format!(
9701            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
9702            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
9703            "reviews":[],"comments":[]}}"#
9704        )
9705    }
9706
9707    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
9708        format!(
9709            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
9710            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
9711            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
9712            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
9713        )
9714    }
9715
9716    #[test]
9717    fn rollup_is_bound_to_the_commit_in_the_same_node() {
9718        // The view already points at the new head, but the node still answers
9719        // for the old commit with its red check: the head stays unbound.
9720        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
9721        assert_eq!(s.rollup_head, "old");
9722        assert_eq!(s.pr.checks, Checks::Red);
9723        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
9724        assert_eq!(s.failing_urls.len(), 1);
9725    }
9726
9727    #[test]
9728    fn checks_come_from_the_node_not_the_view() {
9729        let view = view_json("new").replace(
9730            r#""reviews""#,
9731            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
9732        );
9733        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
9734        assert_eq!(s.pr.checks, Checks::Green);
9735        assert!(s.pr.failing.is_empty());
9736        assert_eq!(
9737            bound_head(&s.head, &s.rollup_head, Some("new")),
9738            Some("new")
9739        );
9740    }
9741
9742    #[test]
9743    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
9744        for node in [
9745            None,
9746            Some("not json".to_owned()),
9747            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
9748            Some(node_json("new", "SUCCESS", true)),
9749        ] {
9750            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
9751            assert!(s.rollup_head.is_empty());
9752            assert_eq!(s.pr.checks, Checks::Unknown);
9753            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
9754        }
9755    }
9756
9757    #[test]
9758    fn the_merge_command_is_pinned_to_the_observed_head() {
9759        let argv = merge_argv_at(7, "feat: x", "deadbeef");
9760        let at = argv
9761            .iter()
9762            .position(|a| a == "--match-head-commit")
9763            .unwrap();
9764        assert_eq!(argv[at + 1], "deadbeef");
9765    }
9766
9767    #[tokio::test]
9768    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
9769        let mut state = landing_state();
9770        // The pull request is on the pushed head but the rollup is still the
9771        // previous commit's red, non-required result.
9772        let mut stale = seen("new", Checks::Red, "CLEAN", false);
9773        stale.rollup_head = "old".to_owned();
9774        let forge = Scripted::new(
9775            vec![seen("old", Checks::Green, "CLEAN", true), stale],
9776            vec![],
9777        );
9778        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9779            head: "new".to_owned(),
9780        });
9781        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9782            .await
9783            .unwrap();
9784        assert!(!forge.calls().contains(&"merge"));
9785        assert_eq!(state.status, RunStatus::Blocked);
9786        let why = state.merge.as_ref().unwrap().detail.clone();
9787        assert!(why.contains("new") && why.contains("old"), "{why}");
9788    }
9789
9790    #[test]
9791    fn a_refusal_read_against_another_commits_checks_is_pending() {
9792        let mut after = seen("a", Checks::Green, "BLOCKED", false);
9793        after.rollup_head = "old".to_owned();
9794        assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
9795    }
9796
9797    #[tokio::test]
9798    async fn a_matching_head_with_red_non_required_checks_still_merges() {
9799        let mut state = landing_state();
9800        let forge = Scripted::new(
9801            vec![seen("a", Checks::Red, "CLEAN", false)],
9802            vec![(true, "")],
9803        );
9804        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9805            .await
9806            .unwrap();
9807        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9808        assert_eq!(state.status, RunStatus::Merged);
9809    }
9810
9811    #[tokio::test]
9812    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
9813        let mut state = landing_state();
9814        let forge = Scripted::new(
9815            vec![
9816                seen("a", Checks::Green, "CLEAN", false),
9817                seen("a", Checks::Green, "CLEAN", false),
9818                // Re-viewed after the refusal: someone pushed.
9819                seen("b", Checks::Green, "BLOCKED", false),
9820                seen("b", Checks::Green, "CLEAN", false),
9821            ],
9822            vec![(false, REFUSED), (true, "")],
9823        );
9824        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9825            .await
9826            .unwrap();
9827        assert_eq!(
9828            forge.calls(),
9829            [
9830                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
9831            ]
9832        );
9833        assert_eq!(state.status, RunStatus::Merged);
9834    }
9835
9836    fn merged_view(head: &str) -> Seen {
9837        let mut m = seen(head, Checks::Green, "CLEAN", false);
9838        m.pr.state = PrLifecycle::Merged;
9839        m
9840    }
9841
9842    fn has(argv: &[String], flag: &str) -> bool {
9843        argv.iter().any(|a| a == flag)
9844    }
9845
9846    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
9847        let at = argv.iter().position(|a| a == flag)?;
9848        argv.get(at + 1).map(String::as_str)
9849    }
9850
9851    const URL: &str = "https://github.com/o/r/pull/42";
9852
9853    #[tokio::test]
9854    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
9855        let mut state = landing_state();
9856        let forge = Scripted::new(
9857            vec![
9858                seen("abc", Checks::Green, "CLEAN", false),
9859                seen("abc", Checks::Green, "CLEAN", false),
9860            ],
9861            vec![(true, "")],
9862        );
9863        land_with(&mut state, URL, &forge).await.unwrap();
9864        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9865        let argv = &forge.argvs()[0];
9866        assert!(has(argv, "--squash") && has(argv, "--subject"));
9867        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
9868        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
9869        assert_eq!(state.status, RunStatus::Merged);
9870        assert!(state.land_armed_head.is_none());
9871    }
9872
9873    #[tokio::test]
9874    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
9875        let mut state = landing_state();
9876        state.land_armed_head = Some("a".to_owned());
9877        let forge = Scripted::new(
9878            vec![seen("a", Checks::Green, "CLEAN", false)],
9879            vec![(true, ""), (true, "")],
9880        );
9881        land_with(&mut state, URL, &forge).await.unwrap();
9882        let argvs = forge.argvs();
9883        assert!(has(&argvs[0], "--disable-auto"));
9884        assert!(!has(&argvs[1], "--auto"));
9885        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
9886        assert_eq!(state.status, RunStatus::Merged);
9887        assert!(state.land_armed_head.is_none());
9888    }
9889
9890    #[tokio::test]
9891    async fn an_approval_never_carries_over_to_a_new_head() {
9892        crate::run::pin_test_home();
9893        let mut state = run_state();
9894        state.config.graph.land_approval = true;
9895        let pr = green_pr();
9896        let store = ask::Questions::open();
9897
9898        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
9899            .await
9900            .unwrap();
9901        let mut q = store
9902            .list()
9903            .into_iter()
9904            .find(|q| q.run == state.id)
9905            .unwrap();
9906        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9907        store.put(&mut q).unwrap();
9908        assert_eq!(
9909            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
9910                .await
9911                .unwrap(),
9912            ApprovalGate::Approved,
9913            "the same head keeps its approval"
9914        );
9915
9916        // A new head is a new question, not the old word.
9917        assert_eq!(
9918            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
9919                .await
9920                .unwrap(),
9921            ApprovalGate::Pending
9922        );
9923        let all: Vec<_> = store
9924            .list()
9925            .into_iter()
9926            .filter(|q| q.run == state.id)
9927            .collect();
9928        assert_eq!(all.len(), 2);
9929
9930        // A question recorded before heads were tracked is not reused either.
9931        state.land_approval = None;
9932        assert_eq!(
9933            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
9934                .await
9935                .unwrap(),
9936            ApprovalGate::Pending
9937        );
9938        let open = store
9939            .list()
9940            .into_iter()
9941            .filter(|q| q.run == state.id && q.status.open())
9942            .count();
9943        assert_eq!(open, 1, "the superseded question was retired");
9944    }
9945
9946    #[tokio::test]
9947    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
9948        let mut state = landing_state();
9949        let forge = Scripted::new(
9950            vec![
9951                seen("a", Checks::Green, "CLEAN", false),
9952                // The fresh read before the merge: someone pushed.
9953                seen("b", Checks::Green, "CLEAN", false),
9954            ],
9955            vec![(true, "")],
9956        );
9957        land_with(&mut state, URL, &forge).await.unwrap();
9958        let argvs = forge.argvs();
9959        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
9960        assert!(!has(&argvs[0], "--auto"));
9961        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
9962        assert_eq!(state.status, RunStatus::Merged);
9963    }
9964
9965    fn passing(label: &str) -> CheckInfo {
9966        CheckInfo {
9967            label: label.to_owned(),
9968            verdict: Verdict::Pass,
9969            required: Some(false),
9970            run: None,
9971        }
9972    }
9973
9974    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
9975        (label.to_owned(), run.map(str::to_owned))
9976    }
9977
9978    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
9979        LandReruns {
9980            head: head.to_owned(),
9981            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
9982        }
9983    }
9984
9985    #[test]
9986    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
9987        let f = [failing("test (macos)", Some("7"))];
9988        assert_eq!(
9989            rerun_step(&f, None, "h", 100),
9990            RerunStep::Rerun(vec!["7".to_owned()])
9991        );
9992        let rec = reran("h", &[("7", 100)]);
9993        let grace = crate::release_watch::RERUN_GRACE;
9994        assert_eq!(
9995            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
9996            RerunStep::Grace
9997        );
9998        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
9999    }
10000
10001    #[test]
10002    fn a_failure_without_a_run_id_goes_to_the_fixer() {
10003        let f = [failing("a", Some("7")), failing("ci/external", None)];
10004        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
10005        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
10006    }
10007
10008    #[test]
10009    fn a_moved_head_forgets_the_reruns() {
10010        let f = [failing("a", Some("7"))];
10011        let rec = reran("old", &[("7", 100)]);
10012        assert_eq!(
10013            rerun_step(&f, Some(&rec), "new", 101),
10014            RerunStep::Rerun(vec!["7".to_owned()])
10015        );
10016    }
10017
10018    #[test]
10019    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
10020        let f = [
10021            failing("a", Some("7")),
10022            failing("b", Some("7")),
10023            failing("c", Some("8")),
10024        ];
10025        assert_eq!(
10026            rerun_step(&f, None, "h", 0),
10027            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
10028        );
10029        let rec = reran("h", &[("7", 0)]);
10030        assert_eq!(
10031            rerun_step(&f, Some(&rec), "h", 1),
10032            RerunStep::Rerun(vec!["8".to_owned()])
10033        );
10034    }
10035
10036    #[test]
10037    fn only_blocking_failures_are_candidates_for_a_rerun() {
10038        let check = |label: &str, verdict, required, run: &str| CheckInfo {
10039            label: label.to_owned(),
10040            verdict,
10041            required,
10042            run: Some(run.to_owned()),
10043        };
10044        let got = rerunnable(&[
10045            check("passed", Verdict::Pass, Some(true), "1"),
10046            check("optional", Verdict::Fail, Some(false), "2"),
10047            check("required", Verdict::Fail, Some(true), "3"),
10048            check("unknown", Verdict::Fail, None, "4"),
10049        ]);
10050        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
10051        assert_eq!(labels, ["required", "unknown"]);
10052    }
10053
10054    fn red(head: &str, run: Option<&str>) -> Seen {
10055        let mut s = seen(head, Checks::Red, "BLOCKED", false);
10056        s.pr.failing = vec!["test (macos)".to_owned()];
10057        s.contexts = vec![CheckInfo {
10058            label: "test (macos)".to_owned(),
10059            verdict: Verdict::Fail,
10060            required: Some(true),
10061            run: run.map(str::to_owned),
10062        }];
10063        s
10064    }
10065
10066    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
10067
10068    #[tokio::test]
10069    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
10070        let mut state = landing_state();
10071        let forge = Scripted::new(
10072            vec![
10073                red("a", Some("7")),
10074                seen("a", Checks::Pending, "BLOCKED", false),
10075                seen("a", Checks::Green, "CLEAN", false),
10076            ],
10077            vec![(true, "")],
10078        );
10079        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10080        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10081        assert!(!forge.calls().contains(&"fix"));
10082        assert_eq!(state.status, RunStatus::Merged);
10083        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10084        let argvs = forge.argvs();
10085        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
10086        assert!(
10087            state
10088                .events
10089                .iter()
10090                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
10091        );
10092    }
10093
10094    #[tokio::test]
10095    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
10096        let mut state = landing_state();
10097        let forge = Scripted::new(
10098            vec![
10099                red("a", Some("7")),
10100                seen("a", Checks::Green, "CLEAN", false),
10101            ],
10102            vec![(true, "")],
10103        );
10104        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10105        // The red view repeats for the grace, then the fixer is asked once.
10106        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10107        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
10108        let calls = forge.calls();
10109        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
10110        let fix_at = calls.iter().position(|c| *c == "fix");
10111        assert!(fix_at.is_none_or(|f| f > rerun_at));
10112    }
10113
10114    #[tokio::test]
10115    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
10116        let mut state = landing_state();
10117        let mut v = red("a", Some("7"));
10118        v.pr.checks = Checks::Pending;
10119        v.contexts.push(CheckInfo {
10120            label: "slow".to_owned(),
10121            verdict: Verdict::Pending,
10122            required: Some(true),
10123            run: Some("8".to_owned()),
10124        });
10125        let forge = Scripted::new(
10126            vec![v, seen("a", Checks::Green, "CLEAN", false)],
10127            vec![(true, "")],
10128        );
10129        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10130        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10131        assert_eq!(state.status, RunStatus::Merged);
10132    }
10133
10134    #[test]
10135    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10136        let mut v = red("a", Some("7"));
10137        v.contexts.push(CheckInfo {
10138            label: "same run, still going".to_owned(),
10139            verdict: Verdict::Pending,
10140            required: Some(true),
10141            run: Some("7".to_owned()),
10142        });
10143        assert!(rerunnable(&v.contexts).is_empty());
10144    }
10145
10146    #[tokio::test]
10147    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10148        let mut state = landing_state();
10149        state.config.graph.land_rounds = 0;
10150        let forge = Scripted::new(
10151            vec![
10152                red("a", Some("7")),
10153                seen("a", Checks::Green, "CLEAN", false),
10154            ],
10155            vec![(true, "")],
10156        );
10157        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10158        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10159        assert_eq!(state.status, RunStatus::Merged);
10160        assert!(!forge.calls().contains(&"fix"));
10161        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10162    }
10163
10164    #[tokio::test]
10165    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
10166        let mut state = landing_state();
10167        state.config.graph.land_rounds = 1;
10168        let forge = Scripted::new(
10169            vec![
10170                seen("a", Checks::Green, "CLEAN", true),
10171                red("b", Some("7")),
10172                seen("b", Checks::Green, "CLEAN", false),
10173            ],
10174            vec![(true, "")],
10175        );
10176        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10177            head: "b".to_owned(),
10178        });
10179        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10180        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10181        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
10182        assert_eq!(state.pr.as_ref().unwrap().round, 1);
10183        assert_eq!(state.status, RunStatus::Merged);
10184        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
10185        assert_eq!(
10186            value_of(&forge.argvs()[0], "--match-head-commit"),
10187            Some("b")
10188        );
10189    }
10190
10191    #[tokio::test]
10192    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
10193        let mut state = landing_state();
10194        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10195        *forge.rerun_fails.lock().unwrap() = true;
10196        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10197        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10198        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
10199        assert_eq!(
10200            forge.calls().iter().filter(|c| **c == "fix").count(),
10201            1,
10202            "{:?}",
10203            forge.calls()
10204        );
10205    }
10206
10207    #[tokio::test]
10208    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
10209        let mut state = landing_state();
10210        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10211        // The record was written just before the previous pass died.
10212        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
10213        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10214        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10215        assert!(forge.reruns.lock().unwrap().is_empty());
10216    }
10217
10218    #[tokio::test]
10219    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
10220        let mut state = landing_state();
10221        state.config.graph.rerun_flaky_checks = false;
10222        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10223        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10224        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10225        assert!(forge.reruns.lock().unwrap().is_empty());
10226        assert!(forge.calls().contains(&"fix"));
10227
10228        let mut state = landing_state();
10229        let forge = Scripted::new(vec![red("a", None)], vec![]);
10230        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10231        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10232        assert!(forge.reruns.lock().unwrap().is_empty());
10233        assert!(forge.calls().contains(&"fix"));
10234    }
10235
10236    fn names(xs: &[&str]) -> BTreeSet<String> {
10237        xs.iter().map(|x| (*x).to_owned()).collect()
10238    }
10239
10240    #[test]
10241    fn a_required_check_the_rollup_never_listed_is_named() {
10242        let req = names(&["build"]);
10243        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
10244        assert!(why.contains("never reported: build"), "{why}");
10245        assert!(!why.contains("probably waiting for a review"), "{why}");
10246    }
10247
10248    #[test]
10249    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
10250        let why = waiting_on("BLOCKED", &[passing("review")], None);
10251        assert!(why.contains("could not be read"), "{why}");
10252        assert!(!why.contains("probably waiting for a review"), "{why}");
10253    }
10254
10255    #[test]
10256    fn all_required_reported_keeps_the_review_guess() {
10257        let req = names(&["build"]);
10258        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10259        assert!(why.contains("probably waiting for a review"), "{why}");
10260        assert!(!why.contains("never reported"), "{why}");
10261    }
10262
10263    #[test]
10264    fn required_names_match_the_rollup_ignoring_case_only() {
10265        let req = names(&["Build"]);
10266        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10267        assert!(!why.contains("never reported"), "{why}");
10268    }
10269
10270    #[test]
10271    fn required_contexts_are_read_from_protection_and_rulesets() {
10272        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
10273        assert_eq!(
10274            parse_classic_required(classic),
10275            Some(names(&["build", "lint"]))
10276        );
10277        let rules = r#"[{"type":"pull_request","parameters":{}},
10278            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
10279        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
10280        assert_eq!(parse_ruleset_required("nope"), None);
10281        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
10282    }
10283
10284    #[test]
10285    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
10286        let shown = BTreeSet::new();
10287        let ok = seen("a", Checks::Green, "CLEAN", false);
10288        let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
10289        assert!(guard(Some(&ok)));
10290        assert!(!guard(None));
10291        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
10292        let mut stale = ok.clone();
10293        stale.rollup_head = "old".to_owned();
10294        assert!(!guard(Some(&stale)));
10295        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
10296        assert!(!guard(Some(&merged_view("a"))));
10297    }
10298
10299    #[test]
10300    fn the_rollup_node_carries_whether_each_check_is_required() {
10301        let node = node_json("new", "SUCCESS", false)
10302            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
10303        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
10304        assert_eq!(s.contexts.len(), 1);
10305        assert_eq!(s.contexts[0].required, Some(true));
10306        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
10307        assert_eq!(s.contexts[0].required, None);
10308    }
10309
10310    #[tokio::test]
10311    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
10312        let mut state = landing_state();
10313        state.land_armed_head = Some("a".to_owned());
10314        let forge = Scripted::new(
10315            vec![seen("a", Checks::Green, "CLEAN", true)],
10316            vec![(false, "disable exploded")],
10317        );
10318        land_with(&mut state, URL, &forge).await.unwrap();
10319        assert!(!forge.calls().contains(&"fix"));
10320        assert_eq!(state.status, RunStatus::Blocked);
10321        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
10322        let why = state.merge.as_ref().unwrap().detail.clone();
10323        assert!(why.contains("disable exploded"), "{why}");
10324    }
10325}