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
3835            .resource_block
3836            .get_or_insert_with(|| crate::run::ResourceBlock {
3837                cause: crate::run::ResourceBlockCause::Other,
3838                node: node.into(),
3839                kind: "shared build cache".into(),
3840                detail: detail.clone(),
3841                at: jiff::Timestamp::now(),
3842            });
3843        state.event("resource", &detail);
3844        state.save()?;
3845        bail!("landing verification could not start: {detail}");
3846    }
3847    Ok(())
3848}
3849
3850/// Gate the local winner and publish that exact SHA, retaining the saved lease.
3851/// A push completed before a crash is recoverable only when its SHA was gated.
3852async fn publish_landing_head<F: Forge>(
3853    state: &mut RunState,
3854    observed: &str,
3855    pr_url: &str,
3856    forge: &F,
3857) -> Result<String> {
3858    crate::pr_head::ensure_owner(state)?;
3859    let winner = state.winner().cloned().context("landing has no winner")?;
3860    let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3861    let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3862    anyhow::ensure!(
3863        observed == expected
3864            || (observed == head
3865                && state.gate_head.as_deref() == Some(&head)
3866                && state.gate_allows_progress()),
3867        "public head moved outside this run; refusing to overwrite it"
3868    );
3869    anyhow::ensure!(
3870        git::is_clean(&winner.worktree).await?,
3871        "landing worktree is dirty"
3872    );
3873    if state.gate_head.as_deref() != Some(&head) || !state.gate_ran || !state.gate_allows_progress()
3874    {
3875        state.gate.clear();
3876        state.gate_head = None;
3877        state.gate_ran = false;
3878        state.pre_gate.clear();
3879        state.pre_gate_commit = None;
3880        state.save()?;
3881        let inherited = crate::repo_lock::WaitPolicy::current();
3882        let wait = crate::repo_lock::WaitPolicy::new(
3883            Duration::from_secs(state.config.graph.timeout_resource_wait),
3884            move || inherited.cancelled(),
3885        );
3886        let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3887        loop {
3888            if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3889                state.gate = outcomes.clone();
3890                check_landing_resources(state, "gate", &outcomes)?;
3891            }
3892            if outcomes.iter().all(|o| o.ok()) {
3893                break;
3894            }
3895            match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3896                crate::graph::GateFix::Retry => {
3897                    outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3898                }
3899                crate::graph::GateFix::Stop => break,
3900                crate::graph::GateFix::Defer => {
3901                    // The repair checks e2e immediately, before the next gate.
3902                    // Preserve a cache wait here as well as below; otherwise
3903                    // the daemon charges an attempt for unstarted verification.
3904                    if let Some(e2e) = &state.gate_repair_e2e {
3905                        let outcomes = e2e.outcomes.clone();
3906                        check_landing_resources(state, "e2e", &outcomes)?;
3907                    }
3908                    bail!("landing gate repair deferred");
3909                }
3910            }
3911        }
3912        state.gate = outcomes;
3913        state.gate_ran = true;
3914        state.gate_head = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3915        state.save()?;
3916    }
3917    anyhow::ensure!(
3918        state.gate_allows_progress(),
3919        "landing gate failed; nothing published"
3920    );
3921    let checked = state
3922        .gate_head
3923        .clone()
3924        .context("landing gate has no checked SHA")?;
3925    anyhow::ensure!(
3926        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3927            && git::is_clean(&winner.worktree).await?,
3928        "landing tree changed after gate"
3929    );
3930    let inherited = crate::repo_lock::WaitPolicy::current();
3931    let wait = crate::repo_lock::WaitPolicy::new(
3932        Duration::from_secs(state.config.graph.timeout_resource_wait),
3933        move || inherited.cancelled(),
3934    );
3935    let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3936    if matches!(e2e, crate::graph::GateFix::Defer) {
3937        let outcomes = state
3938            .gate_repair_e2e
3939            .as_ref()
3940            .context("deferred landing e2e has no outcomes")?
3941            .outcomes
3942            .clone();
3943        check_landing_resources(state, "e2e", &outcomes)?;
3944    }
3945    anyhow::ensure!(
3946        matches!(e2e, crate::graph::GateFix::Retry),
3947        "post-repair e2e has not passed on the landing head; nothing published"
3948    );
3949    anyhow::ensure!(
3950        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3951            && git::is_clean(&winner.worktree).await?,
3952        "landing tree changed during post-repair e2e"
3953    );
3954    crate::pr_head::ensure_owner(state)?;
3955    if owner_requested_stop(state)? {
3956        bail!("owner requested stop before publication");
3957    }
3958    let current = forge.view(&state.repo, pr_url).await?;
3959    anyhow::ensure!(
3960        current.pr.state == PrLifecycle::Open,
3961        "PR closed or merged while gating; nothing published"
3962    );
3963    anyhow::ensure!(
3964        current.head == observed || current.head == checked,
3965        "PR head changed while gating; nothing published"
3966    );
3967    let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3968    let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3969    let refspec = format!("{checked}:refs/heads/{public}");
3970    let lock = repo_merge_lock(&state.repo);
3971    let _slot = lock.lock().await;
3972    let pushed = git::git_raw(
3973        &winner.worktree,
3974        &["push", &lease, &state.config.merge.remote, &refspec],
3975    )
3976    .await?;
3977    anyhow::ensure!(
3978        pushed.ok(),
3979        "publishing gated landing head failed: {}",
3980        pushed.stderr
3981    );
3982    state.pr_pushed_head = Some(checked.clone());
3983    state.land_sync_pending = false;
3984    state.save()?;
3985    Ok(checked)
3986}
3987
3988/// A scratch rebase moves the winner ref before its index and files. Recover
3989/// only a tree proven unchanged since the saved start; never discard edits.
3990async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
3991    let winner = state.winner().cloned().context("landing has no winner")?;
3992    let inherited = crate::repo_lock::WaitPolicy::current();
3993    let wait = crate::repo_lock::WaitPolicy::new(
3994        Duration::from_secs(state.config.graph.timeout_resource_wait),
3995        move || inherited.cancelled(),
3996    );
3997    let _coordination = crate::repo_lock::SeatLease::operation_with(
3998        &winner.worktree,
3999        state.config.coordination.as_ref(),
4000        &wait,
4001    )
4002    .await?;
4003    if !git::is_clean(&winner.worktree).await? {
4004        let from = state
4005            .land_rebase_from
4006            .as_deref()
4007            .context("landing worktree is dirty")?;
4008        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4009        anyhow::ensure!(head != from, "landing worktree is dirty");
4010        for args in [
4011            vec!["diff", "--cached", "--quiet", from],
4012            vec!["diff", "--quiet"],
4013        ] {
4014            anyhow::ensure!(
4015                git::git_raw(&winner.worktree, &args).await?.ok(),
4016                "landing worktree has edits beyond the interrupted rebase"
4017            );
4018        }
4019        // Preserve build artifacts and local files, including ignored ones.
4020        // read-tree can overwrite ignored paths, so reject collisions with
4021        // the new tree (including file/directory conflicts) before updating.
4022        let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
4023        let tracked = git::git(
4024            &winner.worktree,
4025            &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
4026        )
4027        .await?;
4028        for local in untracked.split('\0').filter(|p| !p.is_empty()) {
4029            anyhow::ensure!(
4030                !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
4031                    path == local
4032                        || path
4033                            .strip_prefix(local)
4034                            .is_some_and(|rest| rest.starts_with('/'))
4035                        || local
4036                            .strip_prefix(path)
4037                            .is_some_and(|rest| rest.starts_with('/'))
4038                }),
4039                "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
4040            );
4041        }
4042        git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
4043    }
4044    crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
4045    state.land_rebase_from = None;
4046    state.save()?;
4047    Ok(())
4048}
4049
4050async fn rebase_landing_worktree(
4051    state: &mut RunState,
4052    onto: &str,
4053) -> Result<crate::rebase::Rebased> {
4054    restore_landing_worktree(state).await?;
4055    let winner = state.winner().cloned().context("landing has no winner")?;
4056    state.land_sync_pending = true;
4057    state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
4058    state.save()?;
4059    let scratch = state.dir().join("rebase");
4060    let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
4061    if result == crate::rebase::Rebased::Applied {
4062        restore_landing_worktree(state).await?;
4063    }
4064    Ok(result)
4065}
4066
4067/// Reconcile a stopped landing before changing its branch. This also handles
4068/// old records whose graph status or merge detail no longer identifies land.
4069async fn resume_landing_head<F: Forge>(
4070    state: &mut RunState,
4071    observed: &str,
4072    pr_url: &str,
4073    forge: &F,
4074) -> Result<String> {
4075    crate::pr_head::ensure_owner(state)?;
4076    let winner = state.winner().cloned().context("landing has no winner")?;
4077    let local = git::rev_parse(&winner.worktree, "HEAD").await?;
4078    let expected = state
4079        .pr_pushed_head
4080        .as_deref()
4081        .or(state.gate_head.as_deref())
4082        .context("missing public head lease")?;
4083    anyhow::ensure!(
4084        observed == expected
4085            || (observed == local
4086                && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
4087                    || git::is_ancestor(&winner.worktree, expected, &local).await)),
4088        "public head moved outside this run; refusing to sync"
4089    );
4090    // An operator may have extended and published the winner while approval
4091    // was pending. Matching local/public history can be adopted as the lease,
4092    // but publish_landing_head must gate its new SHA before proceeding. A
4093    // remote-only or divergent update still cannot be overwritten.
4094    // Save reconciliation before rebasing again; a prior push may have reached
4095    // the remote immediately before the previous driver's last save.
4096    state.pr_pushed_head = Some(observed.to_owned());
4097    state.save()?;
4098    restore_landing_worktree(state).await?;
4099    let remote = state.config.merge.remote.clone();
4100    let base = state.base_branch.clone();
4101    let fetched = git::fetch(&state.repo, &remote, &base).await?;
4102    anyhow::ensure!(
4103        fetched.ok(),
4104        "cannot fetch landing base: {}",
4105        fetched.stderr
4106    );
4107    let onto = format!("{remote}/{base}");
4108    let tip = git::rev_parse(&state.repo, &onto).await?;
4109    if !git::is_ancestor(&state.repo, &tip, &local).await {
4110        match rebase_landing_worktree(state, &onto).await? {
4111            crate::rebase::Rebased::Applied => {}
4112            crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4113        }
4114    }
4115    publish_landing_head(state, observed, pr_url, forge).await
4116}
4117
4118async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4119    crate::pr_head::ensure_owner(state)?;
4120    let reopening_hold = crate::merge_decision::resumable_hold(state);
4121    let repo = state.repo.clone();
4122    let budget = state.config.graph.land_rounds;
4123    let mut round = 0usize;
4124    // Counted apart from `round`: a rebase is not a fix, and a base that
4125    // moved is not the change's fault.
4126    let mut rebases = 0usize;
4127    let mut waited = Duration::ZERO;
4128    // Comment bodies the fixer has already been shown. A comment is
4129    // outstanding until it has been handed over once; after that it is a
4130    // recorded decision, not an open question, and re-feeding it would loop the
4131    // budget away on a comment the fixer already declined with an argument.
4132    let mut shown: BTreeSet<String> = BTreeSet::new();
4133    // Await the last successfully published head, including across resumes.
4134    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4135    // Whether a settled-checks refusal has already been given its one re-look.
4136    let mut rechecked = false;
4137
4138    // Marks the run resumable through exactly this function, not through a
4139    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
4140    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
4141    // status specifically to know a resumed run belongs back in `land`
4142    // rather than at a second `gh pr create`. Set on every entry - fresh or
4143    // resumed - because a resume that parked here again must keep reading
4144    // `Landing`, not whatever a first pass through `merge` left behind.
4145    state.status = RunStatus::Landing;
4146    state.event("land", format!("watching {pr_url}"));
4147    state.save()?;
4148
4149    // An armed merge recorded by an earlier pass may or may not have reached
4150    // the forge (the record is written before the call). It is taken back on
4151    // the first observation, where a pull request is known to stop with.
4152    let mut resumed_armed = state.land_armed_head.is_some();
4153
4154    loop {
4155        let seen = forge.view(&repo, pr_url).await?;
4156        let mut pr = seen.pr.clone();
4157        pr.review_comments.retain(|c| !shown.contains(&c.body));
4158        state.pr = Some(crate::run::PrRecord {
4159            url: pr.url.clone(),
4160            number: pr.number,
4161            state: pr.state.as_str().to_owned(),
4162            checks: pr.checks.as_str().to_owned(),
4163            round,
4164            rounds: budget,
4165            red_at_merge: Vec::new(),
4166        });
4167        state.save()?;
4168
4169        if pr.state == PrLifecycle::Open {
4170            state.observe_branch_owner_holds(&seen.head).await;
4171            state.save()?;
4172        }
4173        crate::pr_head::ensure_owner(state)?;
4174        if reopening_hold {
4175            anyhow::ensure!(
4176                pr.state == PrLifecycle::Open,
4177                "held PR is no longer open; cannot request a fresh decision"
4178            );
4179            // An explicit resume authorizes a fresh question only. Preserve the
4180            // answered card, and never enter repair/rebase/merge on this pass.
4181            disarm(forge, state, &repo, pr.number)
4182                .await
4183                .map_err(anyhow::Error::msg)?;
4184            let previous = state.merge_decision.take();
4185            let binding = state.land_approval.take();
4186            state.config.graph.land_approval = true;
4187            let subject = merge_subject(
4188                crate::graph::landing_title(state, &seen.title),
4189                &crate::graph::landing_subject_source(state),
4190            );
4191            let contested = contested_to_ask(state);
4192            if let Err(error) =
4193                approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4194            {
4195                state.merge_decision = previous;
4196                state.land_approval = binding;
4197                return Err(error);
4198            }
4199            state.end_pass_parked(crate::run::ParkCause::Question);
4200            state.parked = true;
4201            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()));
4202            state.save()?;
4203            return Ok(pr);
4204        }
4205        if owner_stop(state, &mut pr, forge).await? {
4206            return Ok(pr);
4207        }
4208
4209        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4210            // An approval already given for the same head is reused, so
4211            // nothing is asked twice. A failed disable
4212            // stops the run with the record kept: pushing on could change the
4213            // head under an arm that is still live.
4214            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4215                let why = format!(
4216                    "a previous pass may have armed auto-merge and it could not be disabled \
4217                     on resume: {e}"
4218                );
4219                stop(state, &repo, &pr, &why).await?;
4220                return Ok(pr);
4221            }
4222        }
4223
4224        // The head moved away from the one auto-merge was armed on, by
4225        // someone other than this loop. The arm is pinned and would refuse to
4226        // merge the new commit, but the approval was for the old one: take it
4227        // back and go through the normal path again.
4228        if pr.state == PrLifecycle::Open
4229            && !seen.head.is_empty()
4230            && state
4231                .land_armed_head
4232                .as_deref()
4233                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4234        {
4235            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4236                let why = format!(
4237                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
4238                );
4239                stop(state, &repo, &pr, &why).await?;
4240                return Ok(pr);
4241            }
4242        }
4243
4244        if state.land_sync_pending && pr.state == PrLifecycle::Open {
4245            // Disarming can outlive an owner answer. Consume it before our
4246            // own rebase invalidates the head shown on the decision card.
4247            if owner_stop(state, &mut pr, forge).await? {
4248                return Ok(pr);
4249            }
4250            let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4251                binding.head.eq_ignore_ascii_case(&seen.head)
4252                    && ask::Questions::open()
4253                        .get(&binding.question)
4254                        .is_ok_and(|q| {
4255                            q.run == state.id
4256                                && q.node == APPROVAL_NODE
4257                                && q.status == ask::QuestionStatus::Answered
4258                                && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4259                        })
4260            });
4261            if repair_answer {
4262                let subject = merge_subject(
4263                    crate::graph::landing_title(state, &seen.title),
4264                    &crate::graph::landing_subject_source(state),
4265                );
4266                let contested = contested_to_ask(state);
4267                if matches!(
4268                    approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4269                    ApprovalGate::Repair
4270                ) {
4271                    if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4272                        state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4273                        state.save()?;
4274                    }
4275                    return Ok(pr);
4276                }
4277            }
4278            let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4279            // A stop detected before publication must apply its durable
4280            // decision, even when verification returned an error meanwhile.
4281            if owner_stop(state, &mut pr, forge).await? {
4282                return Ok(pr);
4283            }
4284            let head = synced?;
4285            awaiting_head = Some(head);
4286            waited = Duration::ZERO;
4287            rechecked = false;
4288            // Read CI and approvals afresh, never from the pre-push observation.
4289            continue;
4290        }
4291
4292        if pr.state == PrLifecycle::Open {
4293            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4294                if waited >= WAIT_CEILING {
4295                    let want = awaiting_head.as_deref().unwrap_or_default();
4296                    let why = format!(
4297                        "the pull request's checks were still not about one readable head after \
4298                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
4299                         someone may have pushed over it",
4300                        WAIT_CEILING.as_secs() / 60,
4301                        if want.is_empty() { "any" } else { want },
4302                        if seen.head.is_empty() {
4303                            "nothing readable"
4304                        } else {
4305                            &seen.head
4306                        },
4307                        if seen.rollup_head.is_empty() {
4308                            "nothing readable"
4309                        } else {
4310                            &seen.rollup_head
4311                        },
4312                    );
4313                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
4314                    return Ok(pr);
4315                }
4316                waited += POLL;
4317                forge.poll().await;
4318                continue;
4319            }
4320            // Reset once, when the awaited head first shows up; resetting on
4321            // every re-observation would let a standing refusal wait forever.
4322            if awaiting_head.take().is_some() {
4323                // The checks now being read belong to the new head; give them
4324                // the same grace a fresh pull request gets.
4325                waited = Duration::ZERO;
4326            }
4327        }
4328
4329        // A failed workflow run is rerun once, judged by its own run rather
4330        // than by the rollup's aggregate: another run still pending, or a fix
4331        // budget already spent, must not keep a flaky failure from its one
4332        // rerun. It spends no round and no attempt.
4333        if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
4334            let failing = rerunnable(&seen.contexts);
4335            let now = forge.now();
4336            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4337                RerunStep::Rerun(runs) => {
4338                    let rec = state.land_reruns.get_or_insert_with(Default::default);
4339                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
4340                        *rec = LandReruns {
4341                            head: seen.head.clone(),
4342                            runs: BTreeMap::new(),
4343                        };
4344                    }
4345                    for run in &runs {
4346                        rec.runs.insert(run.clone(), now);
4347                    }
4348                    // Durable before the call: a restart must not
4349                    // rerun the same run twice. A save that fails
4350                    // means no rerun.
4351                    state.save()?;
4352                    let mut any = false;
4353                    for run in &runs {
4354                        let names = failing
4355                            .iter()
4356                            .filter(|(_, r)| r.as_deref() == Some(run))
4357                            .map(|(l, _)| l.as_str())
4358                            .collect::<Vec<_>>()
4359                            .join(", ");
4360                        match forge.rerun(&repo, run).await {
4361                            Ok(()) => {
4362                                any = true;
4363                                state.event(
4364                                    "land",
4365                                    format!("reran failed check(s) {names} (run {run})"),
4366                                );
4367                            }
4368                            // Stays recorded: never a retry loop.
4369                            Err(e) => state.event(
4370                                "land",
4371                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4372                            ),
4373                        }
4374                    }
4375                    state.save()?;
4376                    if any {
4377                        waited = Duration::ZERO;
4378                        forge.poll().await;
4379                        continue;
4380                    }
4381                }
4382                RerunStep::Grace => {
4383                    if waited >= WAIT_CEILING {
4384                        let why = format!(
4385                            "checks were still failing after a rerun and {} minutes",
4386                            WAIT_CEILING.as_secs() / 60
4387                        );
4388                        stop(state, &repo, &pr, &why).await?;
4389                        return Ok(pr);
4390                    }
4391                    waited += POLL;
4392                    forge.poll().await;
4393                    continue;
4394                }
4395                RerunStep::Fix => {}
4396            }
4397        }
4398
4399        let step = decide(&pr, round, budget, waited);
4400        // Armed on exactly this head: the forge is doing the waiting. A
4401        // decision to merge (or keep waiting) is only watched, never re-armed
4402        // and never re-approved; anything else - a red check, a comment, a
4403        // conflict - falls through to its own arm, which disarms first.
4404        let armed_here = state
4405            .land_armed_head
4406            .as_deref()
4407            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4408        if armed_here && matches!(step, Step::Merge | Step::Wait) {
4409            if waited >= WAIT_CEILING {
4410                let required = if seen.base.is_empty() {
4411                    None
4412                } else {
4413                    forge.required_contexts(&repo, &seen.base).await
4414                };
4415                let why = format!(
4416                    "auto-merge was armed on {} but the pull request did not merge within {} \
4417                     minutes ({})",
4418                    seen.head,
4419                    WAIT_CEILING.as_secs() / 60,
4420                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4421                );
4422                stop_disarmed(forge, state, &repo, &pr, &why).await?;
4423                return Ok(pr);
4424            }
4425            waited += POLL;
4426            forge.poll().await;
4427            continue;
4428        }
4429        if armed_here && !matches!(step, Step::Done { .. }) {
4430            // Not merging or waiting any more: whatever is next may change the
4431            // head or give up, and neither may leave the arm standing.
4432            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4433                let why = format!("auto-merge could not be disabled: {e}");
4434                stop(state, &repo, &pr, &why).await?;
4435                return Ok(pr);
4436            }
4437        }
4438        match step {
4439            Step::Wait => {
4440                if waited >= WAIT_CEILING {
4441                    let why = format!(
4442                        "checks were still running after {} minutes",
4443                        WAIT_CEILING.as_secs() / 60
4444                    );
4445                    stop(state, &repo, &pr, &why).await?;
4446                    return Ok(pr);
4447                }
4448                waited += POLL;
4449                forge.poll().await;
4450            }
4451            Step::Done { merged } => {
4452                if merged {
4453                    state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4454                }
4455                // Auto-merge is pinned to the approved head, but the forge's
4456                // own handling of a later push is not something magi can
4457                // see: if the pull request merged on another commit, say so
4458                // loudly rather than record a clean landing.
4459                if let Some(armed) = state.land_armed_head.take() {
4460                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4461                        let msg = format!(
4462                            "{} merged on {} but the owner approved {armed}; review what landed",
4463                            pr.url, seen.head
4464                        );
4465                        tracing::warn!("{msg}");
4466                        state.event("land", msg);
4467                        // Only an arm left by an older build can get here.
4468                        // The wording is fixed so a repeat does not relight
4469                        // the notice.
4470                        crate::notices::raise_with(
4471                            crate::notices::Notice::warn(
4472                                &format!("merged-unapproved-head:{}", state.id),
4473                                "A pull request merged on a commit the owner did not approve; \
4474                                 review what landed",
4475                            )
4476                            .link(crate::notices::Link::Run {
4477                                id: state.id.clone(),
4478                            }),
4479                            &state.config.notify,
4480                        );
4481                    }
4482                }
4483                state.status = if merged {
4484                    RunStatus::Merged
4485                } else {
4486                    RunStatus::Ready
4487                };
4488                let detail = if merged {
4489                    format!("{} was merged", pr.url)
4490                } else {
4491                    format!("{} was closed without merging", pr.url)
4492                };
4493                state.merge = Some(MergeOutcome {
4494                    mode: MergeMode::Pr,
4495                    ok: merged,
4496                    detail: detail.clone(),
4497                    empty: false,
4498                });
4499                state.event("land", detail);
4500                state.save()?;
4501                write_pr_state_through(state, pr.state);
4502                return Ok(pr);
4503            }
4504            Step::Merge => {
4505                state.check_delivery_evidence(&seen.head)?;
4506                let subject = merge_subject(
4507                    crate::graph::landing_title(state, &seen.title),
4508                    &crate::graph::landing_subject_source(state),
4509                );
4510                // The owner sees the panel before the one irreversible step,
4511                // and an unanswered question is a hold: silence never merges.
4512                //
4513                // The dedicated policy includes existing consent and post-repair
4514                // decisions; repair choice availability never grants merge authority.
4515                let contested = contested_to_ask(state);
4516                if !approval_reasons(state).is_empty() {
4517                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4518                        .await?
4519                    {
4520                        ApprovalGate::Approved => {}
4521                        ApprovalGate::Repair => {
4522                            // The driver consumes the saved request under the existing
4523                            // run/task claim. No merge, no second run or task.
4524                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4525                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4526                                state.save()?;
4527                            }
4528                            return Ok(pr);
4529                        }
4530                        ApprovalGate::Held => {
4531                            // Another entry point may have saved a stop after
4532                            // the first read above. Apply its durable action.
4533                            if owner_stop(state, &mut pr, forge).await? {
4534                                return Ok(pr);
4535                            }
4536                            stop(
4537                                state,
4538                                &repo,
4539                                &pr,
4540                                "the owner did not approve the merge (held or unanswered)",
4541                            )
4542                            .await?;
4543                            return Ok(pr);
4544                        }
4545                        // Filed (or still standing from an earlier visit) and
4546                        // not yet answered. Park here rather than wait: the
4547                        // question survives on disk, the daemon hands this
4548                        // run's slot to something else, and a later resume
4549                        // re-enters `land`, finds the same question, and
4550                        // either merges or stops depending on what it says
4551                        // by then.
4552                        ApprovalGate::Pending => {
4553                            state.end_pass_parked(crate::run::ParkCause::Question);
4554                            state.parked = true;
4555                            state.event(
4556                                "land",
4557                                "parked awaiting merge approval - resumes once answered",
4558                            );
4559                            state.save()?;
4560                            return Ok(pr);
4561                        }
4562                    }
4563                }
4564                // Open and past the gate above, so `seen.head` is the commit
4565                // the checks were bound to and the owner approved.
4566                //
4567                // Merge directly, bound to that commit. Auto-merge is not
4568                // armed any more: the forge checks `--match-head-commit` only
4569                // when the request is made, so an arm outlives the head it
4570                // was made for, and a push of another commit whose
4571                // requirements are met first would merge without approval.
4572                // A direct merge is checked by the forge at the moment it
4573                // happens, so only the approved head can land.
4574                let observed_head = seen.head.clone();
4575                // Read the pull request again just before: the state seen
4576                // above can be a moment old.
4577                {
4578                    let fresh = forge.view(&repo, pr_url).await.ok();
4579                    if !direct_merge_is_safe(
4580                        fresh.as_ref(),
4581                        &observed_head,
4582                        &shown,
4583                        round,
4584                        budget,
4585                        waited,
4586                    ) {
4587                        if waited >= WAIT_CEILING {
4588                            let why = "the pull request did not settle on the approved head \
4589                                       before it could be merged";
4590                            stop(state, &repo, &pr, why).await?;
4591                            return Ok(pr);
4592                        }
4593                        state.event(
4594                            "land",
4595                            "the pull request changed before merging; looking again",
4596                        );
4597                        waited += POLL;
4598                        forge.poll().await;
4599                        continue;
4600                    }
4601                }
4602                let argv = merge_argv_at(pr.number, &subject, &observed_head);
4603                let out = {
4604                    let merge_lock = repo_merge_lock(&repo);
4605                    let _merge_slot = merge_lock.lock().await;
4606                    crate::pr_head::ensure_owner(state)?;
4607                    if let Some(task_id) = state
4608                        .origin
4609                        .as_ref()
4610                        .and_then(|origin| origin.task.as_ref())
4611                    {
4612                        let task = crate::queue::Queue::open()
4613                            .get(task_id)
4614                            .context("cannot verify the task owner immediately before merge")?;
4615                        if task.runs.last() != Some(&state.id)
4616                            || task.operator_held()
4617                            || task.status == crate::queue::TaskStatus::Done
4618                        {
4619                            bail!(
4620                                "task ownership or an operator hold superseded this merge approval"
4621                            );
4622                        }
4623                    }
4624                    if let Some(binding) = &state.land_approval {
4625                        let current = ask::Questions::open().get(&binding.question)?;
4626                        if merge_consent_superseded(state, &current)?
4627                            || current.run != state.id
4628                            || !binding.head.eq_ignore_ascii_case(&observed_head)
4629                            || approval(current.resolution().as_deref()) != Approval::Merge
4630                            || current.merge_target.as_ref().is_some_and(|t| {
4631                                t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4632                            })
4633                        {
4634                            bail!(
4635                                "merge decision is stale or no longer authorizes the reviewed head"
4636                            );
4637                        }
4638                    }
4639                    forge.merge(&repo, &argv).await?
4640                };
4641                if out.0 {
4642                    // Exit 0 is not proof of a merge: with a merge queue `gh`
4643                    // enqueues (or reports the pull request already queued)
4644                    // and succeeds while it is still open. Ask the forge; an
4645                    // unreadable answer is not a confirmation either, so it
4646                    // is looked at again rather than recorded as merged.
4647                    let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4648                        if c.pr.state == PrLifecycle::Merged {
4649                            state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4650                            true
4651                        } else {
4652                            false
4653                        }
4654                    });
4655                    if !confirmed {
4656                        if waited >= WAIT_CEILING {
4657                            let why = "the merge request succeeded but the pull request \
4658                                       could not be confirmed merged after waiting";
4659                            stop(state, &repo, &pr, why).await?;
4660                            return Ok(pr);
4661                        }
4662                        state.event(
4663                            "land",
4664                            "merge accepted but the pull request is not confirmed merged yet; waiting",
4665                        );
4666                        state.save()?;
4667                        waited += POLL;
4668                        forge.poll().await;
4669                        continue;
4670                    }
4671                    pr.state = PrLifecycle::Merged;
4672                    state.status = RunStatus::Merged;
4673                    state.merge = Some(MergeOutcome {
4674                        mode: MergeMode::Pr,
4675                        ok: true,
4676                        detail: format!("gh {}", argv.join(" ")),
4677                        empty: false,
4678                    });
4679                    // The last `state.pr` snapshot is whatever the poll before
4680                    // this merge observed - still `open` - and nothing below
4681                    // refreshes it from GitHub again, so the UI's round rail
4682                    // would otherwise keep animating a merged run forever.
4683                    if let Some(pr_record) = state.pr.as_mut() {
4684                        pr_record.state = pr.state.as_str().to_owned();
4685                    }
4686                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4687                    announce_red_merge(state, &pr).await;
4688                    state.save()?;
4689                    write_pr_state_through(state, pr.state);
4690                    return Ok(pr);
4691                }
4692                let after_seen = forge.view(&repo, pr_url).await.ok();
4693                let after = after_seen.as_ref().map(|s| s.pr.state);
4694                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4695                    state.pr_merged_head = after_seen
4696                        .as_ref()
4697                        .map(|s| s.head.clone())
4698                        .filter(|h| !h.is_empty());
4699                    pr.state = PrLifecycle::Merged;
4700                    state.status = RunStatus::Merged;
4701                    state.merge = Some(outcome);
4702                    if let Some(pr_record) = state.pr.as_mut() {
4703                        pr_record.state = pr.state.as_str().to_owned();
4704                    }
4705                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4706                    announce_red_merge(state, &pr).await;
4707                    state.save()?;
4708                    write_pr_state_through(state, pr.state);
4709                    return Ok(pr);
4710                }
4711                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4712                match verdict {
4713                    Refused::Final => {
4714                        let merge_state = after_seen
4715                            .as_ref()
4716                            .map(|s| s.merge_state.as_str())
4717                            .filter(|m| !m.is_empty())
4718                            .unwrap_or("unknown");
4719                        // Which refusal this was decides what a person has to
4720                        // do about it, so the two are worded apart; both carry
4721                        // the forge's own message.
4722                        let why = format!(
4723                            "the merge was refused: {} (merge state: {merge_state})",
4724                            out.1
4725                        );
4726                        stop(state, &repo, &pr, &why).await?;
4727                        return Ok(pr);
4728                    }
4729                    verdict => {
4730                        // Back to the top, which re-decides and passes the
4731                        // approval gate again, a poll later. `waited` is not
4732                        // reset here: only a pushed fix restarts it, or a
4733                        // standing refusal would wait forever.
4734                        if waited >= WAIT_CEILING {
4735                            let why = format!(
4736                                "the merge was still refused after {} minutes: {}",
4737                                WAIT_CEILING.as_secs() / 60,
4738                                out.1
4739                            );
4740                            stop(state, &repo, &pr, &why).await?;
4741                            return Ok(pr);
4742                        }
4743                        if verdict == Refused::Recheck {
4744                            rechecked = true;
4745                        }
4746                        state.event(
4747                            "land",
4748                            "merge refused while the branch policy is not satisfied yet; waiting",
4749                        );
4750                        state.save()?;
4751                        waited += POLL;
4752                        forge.poll().await;
4753                    }
4754                }
4755            }
4756            Step::Rebase => {
4757                // Bounded by the same budget as a fix, because a rebase that
4758                // keeps being needed means the base moves faster than this
4759                // run can land and a person should decide what to do. It
4760                // spends none of that budget: the change is not what is
4761                // wrong.
4762                if rebases >= budget {
4763                    let why = format!(
4764                        "the base moved under this branch {budget} time(s) and it still does \
4765                         not merge; rebasing again would only race it"
4766                    );
4767                    stop(state, &repo, &pr, &why).await?;
4768                    return Ok(pr);
4769                }
4770                rebases += 1;
4771                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4772                    stop(
4773                        state,
4774                        &repo,
4775                        &pr,
4776                        "the pull request conflicts and this run has no winning branch to rebase",
4777                    )
4778                    .await?;
4779                    return Ok(pr);
4780                };
4781                let base = state.base_branch.clone();
4782                state.event(
4783                    "land",
4784                    format!("{} no longer merges; rebasing onto {base}", pr.url),
4785                );
4786                state.save()?;
4787
4788                // Onto the base as the *remote* has it: the local ref may be
4789                // behind, and rebasing onto a stale base produces a branch
4790                // that conflicts all over again.
4791                let remote = state.config.merge.remote.clone();
4792                git::fetch(&repo, &remote, &base).await.ok();
4793                if owner_stop(state, &mut pr, forge).await? {
4794                    return Ok(pr);
4795                }
4796                let onto = format!("{remote}/{base}");
4797                let rebased = match rebase_landing_worktree(state, &onto).await {
4798                    Ok(crate::rebase::Rebased::Applied) => Ok(None),
4799                    Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4800                    Err(e) => Err(e),
4801                };
4802                if owner_stop(state, &mut pr, forge).await? {
4803                    return Ok(pr);
4804                }
4805                match rebased {
4806                    Ok(None) => {
4807                        let published =
4808                            publish_landing_head(state, &seen.head, pr_url, forge).await;
4809                        if owner_stop(state, &mut pr, forge).await? {
4810                            return Ok(pr);
4811                        }
4812                        let head = published?;
4813                        crate::graph::refresh_reviewed_commits(state, &branch).await;
4814                        state.pr_pushed_head = Some(head.clone());
4815                        awaiting_head = Some(head);
4816                        rechecked = false;
4817                        state.event("land", format!("rebased {branch} onto {base}"));
4818                        state.save()?;
4819                        // The forge has to re-run its checks against the
4820                        // rebased head before anything else can be decided.
4821                        waited = Duration::ZERO;
4822                        tokio::time::sleep(POLL).await;
4823                    }
4824                    // The fixer's rounds are spent (or it could not finish):
4825                    // that is a decision for a person.
4826                    Ok(Some(conflict)) => {
4827                        let why = format!(
4828                            "{} conflicts with {base} and the rebase did not apply: {}",
4829                            pr.url,
4830                            conflict.chars().take(600).collect::<String>()
4831                        );
4832                        stop(state, &repo, &pr, &why).await?;
4833                        return Ok(pr);
4834                    }
4835                    Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4836                    Err(e) => {
4837                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
4838                        stop(state, &repo, &pr, &why).await?;
4839                        return Ok(pr);
4840                    }
4841                }
4842            }
4843            Step::GiveUp { reason } => {
4844                stop(state, &repo, &pr, &reason).await?;
4845                return Ok(pr);
4846            }
4847            Step::Fix { reason } => {
4848                round += 1;
4849                waited = Duration::ZERO;
4850                for c in &pr.review_comments {
4851                    shown.insert(c.body.clone());
4852                }
4853                state.event("land", format!("round {round}: {reason}"));
4854                state.save()?;
4855
4856                let logs = failing_logs(&repo, &seen.failing_urls).await;
4857                let was_red = pr.checks == Checks::Red;
4858                if owner_stop(state, &mut pr, forge).await? {
4859                    return Ok(pr);
4860                }
4861                let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4862                if owner_stop(state, &mut pr, forge).await? {
4863                    return Ok(pr);
4864                }
4865                match fixed {
4866                    Fixed::Committed { head } => {
4867                        // Whatever the next look shows may still be the
4868                        // previous head; see `awaiting_new_head`.
4869                        state.pr_pushed_head = Some(head.clone());
4870                        awaiting_head = Some(head);
4871                        rechecked = false;
4872                        waited = Duration::ZERO;
4873                        forge.poll().await;
4874                    }
4875                    Fixed::Declined if was_red => {
4876                        let why = format!(
4877                            "the fixer produced no commit while {} check(s) were failing \
4878                             ({}); stopping instead of looping on an unchanged tree",
4879                            pr.failing.len(),
4880                            pr.failing.join(", ")
4881                        );
4882                        stop(state, &repo, &pr, &why).await?;
4883                        return Ok(pr);
4884                    }
4885                    // Comment-driven round with no commit: the fixer read the
4886                    // comments and changed nothing, which is a decision it is
4887                    // allowed to make. The comments are recorded as shown, so
4888                    // the next observation sees a clean pull request.
4889                    Fixed::Declined => state.event(
4890                        "land",
4891                        format!("round {round}: fixer declined the comments, nothing committed"),
4892                    ),
4893                    Fixed::Failed(why) => {
4894                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4895                        return Ok(pr);
4896                    }
4897                }
4898                state.save()?;
4899            }
4900        }
4901    }
4902}
4903
4904/// One observation, plus the two things [`PrState`] deliberately does not carry:
4905/// the title (needed for the squash subject) and where the failing checks'
4906/// logs live.
4907#[derive(Clone)]
4908struct Seen {
4909    pr: PrState,
4910    title: String,
4911    failing_urls: Vec<(String, String)>,
4912    /// `headRefOid`: the commit this observation, checks included, is about.
4913    head: String,
4914    /// The commit the checks belong to, read from the same GraphQL node as
4915    /// the checks themselves. Empty when unreadable.
4916    rollup_head: String,
4917    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
4918    /// BEHIND and DRAFT together, and a stop reason has to say which.
4919    merge_state: String,
4920    /// Every check of the rollup, for naming what an armed merge waits on.
4921    contexts: Vec<CheckInfo>,
4922    /// `baseRefName`, to look up which contexts the base requires.
4923    base: String,
4924}
4925
4926/// One check of the rollup as far as a stop reason needs it.
4927#[derive(Clone)]
4928struct CheckInfo {
4929    label: String,
4930    verdict: Verdict,
4931    required: Option<bool>,
4932    /// The workflow run the check belongs to; `None` for an external status.
4933    run: Option<String>,
4934}
4935
4936/// What to do with a red head before a fix round is spent on it.
4937#[derive(Debug, Clone, PartialEq, Eq)]
4938enum RerunStep {
4939    /// Rerun these workflow runs (each not yet rerun for this head).
4940    Rerun(Vec<String>),
4941    /// Reran already and the forge may still report the old failure: wait.
4942    Grace,
4943    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
4944    Fix,
4945}
4946
4947/// Failed checks a rerun may help: a failure of a check that blocks (`required`
4948/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
4949/// pending checks in another run do not delay this run's rerun.
4950fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4951    contexts
4952        .iter()
4953        .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4954        // `gh run rerun --failed` refuses a run with a job still going.
4955        .filter(|c| {
4956            c.run.as_ref().is_none_or(|r| {
4957                !contexts.iter().any(|o| {
4958                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4959                        && o.run.as_ref() == Some(r)
4960                })
4961            })
4962        })
4963        .map(|c| (c.label.clone(), c.run.clone()))
4964        .collect()
4965}
4966
4967/// Pure: rerun once per run id, wait out the grace, then hand over.
4968fn rerun_step(
4969    failing: &[(String, Option<String>)],
4970    record: Option<&LandReruns>,
4971    head: &str,
4972    now: i64,
4973) -> RerunStep {
4974    // A failure with no workflow run (an external status) cannot be rerun, and
4975    // the fixer's push re-runs everything anyway.
4976    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4977        return RerunStep::Fix;
4978    }
4979    // Reruns belong to the head they were made on.
4980    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4981    let mut fresh: Vec<String> = Vec::new();
4982    let mut recent = false;
4983    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4984        match record.and_then(|r| r.runs.get(run)) {
4985            None => {
4986                if !fresh.iter().any(|f| f == run) {
4987                    fresh.push(run.to_owned());
4988                }
4989            }
4990            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4991            Some(_) => {}
4992        }
4993    }
4994    if !fresh.is_empty() {
4995        RerunStep::Rerun(fresh)
4996    } else if recent {
4997        RerunStep::Grace
4998    } else {
4999        RerunStep::Fix
5000    }
5001}
5002
5003/// Read the pull request: `gh pr view` for the top-level thread and merge
5004/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
5005/// inline review comments `gh pr view` does not report.
5006async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
5007    let view = gh(
5008        repo,
5009        &[
5010            "pr".to_owned(),
5011            "view".to_owned(),
5012            pr_url.to_owned(),
5013            "--json".to_owned(),
5014            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
5015                .to_owned(),
5016        ],
5017    )
5018    .await?;
5019    if !view.0 {
5020        bail!("gh pr view {pr_url}: {}", view.1);
5021    }
5022    let number = parse_pr(&view.1)?.number;
5023    let node = last_commit_node(repo, number).await;
5024    let mut seen = seen_from(&view.1, node.as_deref())?;
5025
5026    let inline = gh(
5027        repo,
5028        &[
5029            "api".to_owned(),
5030            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
5031        ],
5032    )
5033    .await?;
5034    if inline.0 {
5035        match parse_inline_comments(&inline.1) {
5036            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5037            // An unreadable inline thread must not end a landing: the rollup
5038            // and the top-level thread are still real signal.
5039            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5040        }
5041    } else {
5042        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5043    }
5044    Ok(seen)
5045}
5046
5047/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
5048/// response. No I/O.
5049///
5050/// The commit oid and the checks are read from the *same* node, so the rollup
5051/// is bound to the commit it belongs to by construction; two reads that agree
5052/// before and after another response prove nothing about what that response
5053/// held. The view's own rollup is never used. A node that is missing,
5054/// unreadable, carries GraphQL errors, or whose checks run past the page
5055/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
5056/// as "look again" and never as a reason to merge.
5057fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5058    let mut pr = parse_pr(view_json)?;
5059    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5060
5061    let mut rollup_head = String::new();
5062    let mut failing_urls = Vec::new();
5063    let mut contexts = Vec::new();
5064    let mut checks = Checks::Unknown;
5065    let mut failing = Vec::new();
5066    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5067        (checks, failing) = rollup_verdict(&rollup);
5068        failing_urls = rollup
5069            .iter()
5070            .filter(|c| c.verdict() == Verdict::Fail)
5071            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5072            .collect();
5073        contexts = rollup
5074            .iter()
5075            .map(|c| CheckInfo {
5076                label: c.label(),
5077                verdict: c.verdict(),
5078                required: c.is_required,
5079                run: c.url().and_then(run_of),
5080            })
5081            .collect();
5082        rollup_head = oid;
5083    }
5084    pr.checks = checks;
5085    pr.failing = failing;
5086
5087    Ok(Seen {
5088        pr,
5089        title: raw.title,
5090        failing_urls,
5091        head: raw.head_ref_oid,
5092        rollup_head,
5093        merge_state: raw.merge_state_status,
5094        contexts,
5095        base: raw.base_ref_name,
5096    })
5097}
5098
5099/// The last commit's oid and its checks from one GraphQL response, `None`
5100/// when anything about it cannot be trusted.
5101fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5102    let v: serde_json::Value = serde_json::from_str(json).ok()?;
5103    if v.get("errors").is_some_and(|e| !e.is_null()) {
5104        return None;
5105    }
5106    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5107    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5108    let contexts = commit.pointer("/statusCheckRollup/contexts");
5109    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5110        // No rollup at all: the commit has no checks.
5111        return Some((oid.to_owned(), Vec::new()));
5112    };
5113    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5114        return None;
5115    }
5116    let nodes = contexts.get("nodes")?.as_array()?;
5117    let rollup = nodes
5118        .iter()
5119        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5120        .collect::<Result<Vec<_>, _>>()
5121        .ok()?;
5122    Some((oid.to_owned(), rollup))
5123}
5124
5125/// The pull request's last commit and its checks, in one response. `None`
5126/// when the forge could not be asked.
5127async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5128    let out = gh(
5129        repo,
5130        &[
5131            "api".to_owned(),
5132            "graphql".to_owned(),
5133            "-F".to_owned(),
5134            "owner={owner}".to_owned(),
5135            "-F".to_owned(),
5136            "repo={repo}".to_owned(),
5137            "-F".to_owned(),
5138            format!("number={number}"),
5139            "-f".to_owned(),
5140            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5141             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5142             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5143             ... on CheckRun{name status conclusion detailsUrl \
5144             isRequired(pullRequestNumber:$number)} \
5145             ... on StatusContext{context state targetUrl \
5146             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5147                .to_owned(),
5148        ],
5149    )
5150    .await
5151    .ok()?;
5152    out.0.then_some(out.1)
5153}
5154
5155/// What a fix round did.
5156#[doc(hidden)]
5157#[derive(Debug, PartialEq)]
5158pub enum Fixed {
5159    /// The fixer committed something, and this is the head that was pushed.
5160    Committed {
5161        /// The commit now at the tip of the pushed branch.
5162        head: String,
5163    },
5164    /// The fixer ran and chose to change nothing.
5165    Declined,
5166    /// The fixer could not run, or said nothing usable.
5167    Failed(String),
5168}
5169
5170/// Hand the failures and the comments to the fixer, then commit and push.
5171///
5172/// The fixer works in the winner's own worktree so its commits land on the
5173/// branch the pull request is built from, and it runs with `allow_write` for
5174/// the same reason.
5175#[doc(hidden)]
5176pub async fn fix_round(
5177    state: &mut RunState,
5178    pr: &PrState,
5179    round: usize,
5180    budget: usize,
5181    reason: &str,
5182    logs: &str,
5183) -> Result<Fixed> {
5184    let winner = state
5185        .winner()
5186        .cloned()
5187        .context("landing needs a winning candidate; none is recorded on this run")?;
5188    let roles = state
5189        .config
5190        .resolve_roles()
5191        .context("resolve the roster for the fix round")?;
5192    // Same chain as the review loop (see `crate::fixer`): the configured
5193    // fixers in order, otherwise the winner's own author continuing its own
5194    // conversation - the competition is over, so its context is pure benefit.
5195    let attempts = crate::fixer::attempts(state, &roles, &winner);
5196    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5197
5198    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5199    let mut artifacts = agent::artifacts_dir(&state.dir());
5200    if let Some(request) = state
5201        .merge_repairs
5202        .last()
5203        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5204    {
5205        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5206    }
5207    let prompt = if state.config.cache_dir().is_some() {
5208        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5209    } else {
5210        prompt
5211    };
5212    // Read before the fixer runs: it normally commits for itself, so HEAD has
5213    // already moved by the time it returns and a later read would see no
5214    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
5215    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5216    // Each id at most once, forward only: the next one is asked only when the
5217    // call advances, and only the last attempt's result is judged below, so an
5218    // exhausted chain ends as a single failed fixer does.
5219    let mut last = None;
5220    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5221        if owner_requested_stop(state)? {
5222            return Ok(Fixed::Declined);
5223        }
5224        let mut seat = seat_of(state, &seat_key, &spec.id);
5225        let stem = if i == 0 {
5226            format!("land-{round}")
5227        } else {
5228            format!("land-{round}-{}", spec.id)
5229        };
5230        let out = agent::invoke(
5231            &spec,
5232            &mut seat,
5233            &Invocation {
5234                scope: crate::agent::InvocationScope::Repository,
5235                coordination: state.config.coordination.as_ref(),
5236                fetched_base: state.fetched_base.as_ref(),
5237                cwd: &winner.worktree,
5238                prompt: &prompt,
5239                timeout: Duration::from_secs(state.config.graph.timeout_fix),
5240                allow_write: true,
5241                unsandboxed: false,
5242                sessions: state.config.graph.sessions,
5243                artifacts: &artifacts,
5244                stem: &stem,
5245                run: &state.id,
5246                node: "land",
5247                cache_dir: state.config.cache_dir().as_deref(),
5248                attachments: &[],
5249                writable: &[],
5250            },
5251        )
5252        .await;
5253        state.seats.insert(seat.key.clone(), seat);
5254        if let Some(next) = ids.get(i + 1)
5255            && agent::chain_advances(&out)
5256        {
5257            let (class, why) = crate::fixer::failure_of(&out);
5258            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5259            continue;
5260        }
5261        last = Some(out);
5262        break;
5263    }
5264    let out = last.expect("the fixer chain always has an entry");
5265
5266    match out {
5267        Ok(o) if o.quota_exhausted() => {
5268            return Ok(Fixed::Failed(
5269                "rate limited (quota); the fixer could not run".to_owned(),
5270            ));
5271        }
5272        Ok(o) if !o.usable() => {
5273            return Ok(Fixed::Failed(format!(
5274                "the fixer produced nothing usable ({})",
5275                crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5276            )));
5277        }
5278        Ok(_) => {}
5279        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5280        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5281    }
5282
5283    if owner_requested_stop(state)? {
5284        return Ok(Fixed::Declined);
5285    }
5286
5287    // An agent that edited files but never committed would otherwise push
5288    // nothing and look like a refusal.
5289    let wait = crate::repo_lock::WaitPolicy::new(
5290        Duration::from_secs(state.config.graph.timeout_resource_wait),
5291        || false,
5292    );
5293    let coordination = crate::repo_lock::SeatLease::operation_with(
5294        &winner.worktree,
5295        state.config.coordination.as_ref(),
5296        &wait,
5297    )
5298    .await?;
5299    match git::rescue_commit(
5300        &winner.worktree,
5301        &format!("magi: land round {round} fixes (uncommitted work)"),
5302    )
5303    .await
5304    {
5305        Ok(r) => state.note_withheld("land", &r.withheld),
5306        Err(e)
5307            if state
5308                .merge_repairs
5309                .last()
5310                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5311        {
5312            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5313        }
5314        Err(_) => {}
5315    }
5316    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5317    drop(coordination);
5318    if after == before {
5319        return Ok(Fixed::Declined);
5320    }
5321
5322    if owner_requested_stop(state)? {
5323        return Ok(Fixed::Declined);
5324    }
5325    crate::pr_head::ensure_owner(state)?;
5326    let remote = state.config.merge.remote.clone();
5327    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5328    let push = if state.pr_head.is_some() {
5329        let expected = state
5330            .pr_pushed_head
5331            .as_deref()
5332            .context("missing public head lease")?;
5333        git::push_as(
5334            &winner.worktree,
5335            &remote,
5336            &winner.branch,
5337            &public_head,
5338            expected,
5339        )
5340        .await?
5341    } else {
5342        git::push(&winner.worktree, &remote, &winner.branch).await?
5343    };
5344    if !push.ok() {
5345        return Ok(Fixed::Failed(format!(
5346            "pushing {} to {remote} failed: {}",
5347            public_head, push.stderr
5348        )));
5349    }
5350    state.event(
5351        "land",
5352        format!("round {round}: pushed a fix to {public_head}"),
5353    );
5354    state.pr_pushed_head = Some(after.clone());
5355    state.save()?;
5356    Ok(Fixed::Committed { head: after })
5357}
5358
5359/// Fetch or create a seat, keeping its conversation across nodes.
5360pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5361    crate::fixer::seat_for(state, key, agent)
5362}
5363
5364/// Recheck the live PR and local worktree before spending the requested fix.
5365/// A moved/closed PR is held, never repaired using obsolete evidence.
5366pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5367    let seen = observe(&state.repo, url).await?;
5368    let winner = state.winner().context("no winning branch to repair")?;
5369    if seen.pr.state != PrLifecycle::Open || seen.head != head {
5370        anyhow::bail!(
5371            "PR is closed or its head changed; review the current head before requesting repair again"
5372        );
5373    }
5374    if git::rev_parse(&winner.worktree, "HEAD").await? != head
5375        || !git::is_clean(&winner.worktree).await?
5376    {
5377        anyhow::bail!(
5378            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5379        );
5380    }
5381    // Always disable any forge-side reservation, including one not recorded
5382    // by this run. A repair request is never merge authorization.
5383    let view = gh(
5384        &state.repo,
5385        &[
5386            "pr".into(),
5387            "view".into(),
5388            url.into(),
5389            "--json".into(),
5390            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5391        ],
5392    )
5393    .await?;
5394    if !view.0 {
5395        bail!("cannot check auto-merge: {}", view.1);
5396    }
5397    let live: serde_json::Value = serde_json::from_str(&view.1)?;
5398    let public = state.pr_branch().unwrap_or(&winner.branch);
5399    if live["state"] != "OPEN"
5400        || live["headRefOid"] != head
5401        || live["headRefName"] != public
5402        || live["baseRefName"] != state.base_branch
5403        || live["isCrossRepository"] != false
5404    {
5405        bail!("PR changed during repair preflight; review its current head explicitly");
5406    }
5407    let auto_merge = live
5408        .get("autoMergeRequest")
5409        .context("cannot read the PR auto-merge reservation; repair held")?;
5410    if !auto_merge.is_null() {
5411        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5412        if !out.0 {
5413            bail!("cannot disable auto-merge; repair held: {}", out.1);
5414        }
5415    }
5416    Ok(seen.pr)
5417}
5418
5419/// What the fixer is told.
5420fn fix_prompt(
5421    state: &RunState,
5422    pr: &PrState,
5423    round: usize,
5424    budget: usize,
5425    reason: &str,
5426    logs: &str,
5427) -> String {
5428    let mut s = format!(
5429        "Your patch is open as a pull request and it is not landing. Land round \
5430         {round} of {budget}.\n\n\
5431         Pull request: {}\n\n\
5432         What is holding it: {reason}\n\n\
5433         # The task\n\n{}\n",
5434        pr.url, state.instruction
5435    );
5436
5437    if pr.failing.is_empty() {
5438        s.push_str("\n# Failing checks\n\n(none)\n");
5439    } else {
5440        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5441        if logs.trim().is_empty() {
5442            s.push_str(
5443                "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5444            );
5445        } else {
5446            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5447        }
5448    }
5449
5450    if pr.review_comments.is_empty() {
5451        s.push_str("\n# Review comments\n\n(none)\n");
5452    } else {
5453        s.push_str("\n# Review comments\n");
5454        for c in &pr.review_comments {
5455            let where_ = match (&c.path, c.line) {
5456                (Some(p), Some(l)) => format!(" ({p}:{l})"),
5457                (Some(p), None) => format!(" ({p})"),
5458                _ => String::new(),
5459            };
5460            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5461        }
5462    }
5463
5464    s.push_str(
5465        "\n# Rules\n\n\
5466         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5467            failing test; do not silence a lint with an allow attribute; do not \
5468            stretch a timeout to hide a race. If the check is right, the code is \
5469            wrong.\n\
5470         2. Change nothing the checks and the comments did not raise. A \
5471            drive-by refactor turns a one-line fix into a pull request that \
5472            needs reviewing again.\n\
5473         3. If a comment is wrong, say so with a checkable argument and change \
5474            nothing for it. A declined comment with a reason is a correct \
5475            outcome; a change made to appease a reviewer is not.\n\
5476         4. Commit in this worktree. magi pushes to the pull request's branch \
5477            for you; do not push, merge, or close anything yourself.\n\
5478         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5479         # Output\n\n\
5480         Say what you changed and why, and what you declined and why.",
5481    );
5482
5483    let language = &state.config.graph.language;
5484    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5485        let _ = write!(s, "\n\nWrite all prose in {language}.");
5486    }
5487    // After the language line, so the exception is the last word on it.
5488    s.push_str(&crate::prompt::github_rule(
5489        language,
5490        &state.config.graph.github_language,
5491    ));
5492    if let Some(overlay) = state.config.prompts.overlay("fix") {
5493        let _ = write!(s, "\n\n{overlay}");
5494    }
5495    s.push('\n');
5496    s.push_str(crate::prompt::child_run_ownership());
5497    s
5498}
5499
5500/// Failing log tails, the way the operator collects them by hand:
5501/// `gh run view --log-failed`.
5502async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5503    let mut out = String::new();
5504    for (name, url) in failing.iter().take(MAX_LOGS) {
5505        let args = match (job_of(url), run_of(url)) {
5506            (Some(job), _) => vec![
5507                "run".to_owned(),
5508                "view".to_owned(),
5509                "--log-failed".to_owned(),
5510                "--job".to_owned(),
5511                job,
5512            ],
5513            (None, Some(run)) => vec![
5514                "run".to_owned(),
5515                "view".to_owned(),
5516                run,
5517                "--log-failed".to_owned(),
5518            ],
5519            // Not a GitHub Actions check - an external status has no log here.
5520            (None, None) => continue,
5521        };
5522        let (ok, body) = match gh(repo, &args).await {
5523            Ok(v) => v,
5524            Err(e) => (false, format!("{e:#}")),
5525        };
5526        if !ok && body.trim().is_empty() {
5527            continue;
5528        }
5529        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5530    }
5531    out
5532}
5533
5534/// Job id out of a check's `detailsUrl`
5535/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
5536fn job_of(details_url: &str) -> Option<String> {
5537    let after = details_url.split("/job/").nth(1)?;
5538    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5539    (!id.is_empty()).then_some(id)
5540}
5541
5542/// Workflow run id out of a check's `detailsUrl`.
5543pub(crate) fn run_of(details_url: &str) -> Option<String> {
5544    let after = details_url.split("/actions/runs/").nth(1)?;
5545    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5546    (!id.is_empty()).then_some(id)
5547}
5548
5549/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
5550/// it lands on GitHub, not in front of the operator. Pure so a test can hold
5551/// it to that.
5552fn stop_comment(run_id: &str, why: &str) -> String {
5553    format!(
5554        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5555         The branch is untouched and the run is `{run_id}`. Nothing was merged."
5556    )
5557}
5558
5559/// Leave the pull request open, say why on it, and mark the run blocked.
5560///
5561/// The comment is what makes an unattended stop actionable: the operator wakes
5562/// up to a pull request that explains itself rather than to a silent queue.
5563async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5564    let body = stop_comment(&state.id, why);
5565    let (_, mut body) = crate::github_text::prepare(state, "", &body);
5566    if !body.contains(MARKER) {
5567        body = format!("{MARKER}\n{body}");
5568    }
5569    let posted = gh(
5570        repo,
5571        &[
5572            "pr".to_owned(),
5573            "comment".to_owned(),
5574            pr.number.to_string(),
5575            "--body".to_owned(),
5576            body,
5577        ],
5578    )
5579    .await;
5580    match posted {
5581        Ok((true, _)) => {}
5582        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5583        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5584    }
5585    state.status = RunStatus::Blocked;
5586    state.merge = Some(MergeOutcome {
5587        mode: MergeMode::Pr,
5588        ok: false,
5589        detail: why.to_owned(),
5590        empty: false,
5591    });
5592    state.event("land", format!("stopped: {why}"));
5593    state.save()?;
5594    Ok(())
5595}
5596
5597/// Run `gh` in `repo`, returning success and the combined output.
5598///
5599/// Combined because `gh` reports a refused merge on stderr and the pull request
5600/// json on stdout, and both are evidence.
5601///
5602/// `GH_REPO` is stripped from the child's environment: every call site here
5603/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
5604/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
5605/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
5606/// `GH_REPO` the operator happens to have exported for an unrelated script
5607/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
5608/// below) to a different repository than the one actually on disk - which
5609/// for the same-repo guard in [`correct_manual_merge`] would mean the check
5610/// could be made to agree with whatever repository a forged `--merged` URL
5611/// claims, defeating it entirely.
5612pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5613    let out = tokio::process::Command::new("gh")
5614        .args(args)
5615        .current_dir(cwd)
5616        .env_remove("GH_REPO")
5617        .quiet()
5618        .stdin(std::process::Stdio::null())
5619        .output()
5620        .await
5621        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5622    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5623    let err = String::from_utf8_lossy(&out.stderr);
5624    if body.trim().is_empty() {
5625        body = err.into_owned();
5626    } else if !err.trim().is_empty() {
5627        body.push_str(&err);
5628    }
5629    Ok((out.status.success(), body.trim().to_owned()))
5630}
5631
5632/// Verdict of one entry in the status rollup.
5633#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5634pub(crate) enum Verdict {
5635    Pass,
5636    Fail,
5637    Pending,
5638    Unknown,
5639}
5640
5641impl Verdict {
5642    /// Short operator-facing check result.
5643    pub(crate) fn label(self) -> &'static str {
5644        match self {
5645            Self::Pass => "passed",
5646            Self::Fail => "failed",
5647            Self::Pending => "pending",
5648            Self::Unknown => "unknown",
5649        }
5650    }
5651}
5652
5653#[derive(Debug, Deserialize)]
5654#[serde(rename_all = "camelCase")]
5655struct GhPr {
5656    #[serde(default)]
5657    url: String,
5658    #[serde(default)]
5659    number: u64,
5660    #[serde(default)]
5661    state: String,
5662    #[serde(default)]
5663    title: String,
5664    #[serde(default)]
5665    status_check_rollup: Vec<GhCheck>,
5666    /// GitHub's own verdict on whether the pull request can be merged.
5667    ///
5668    /// Worth asking for because it is the only place the *required* check set
5669    /// is applied: the rollup lists every check equally, so a repository that
5670    /// deliberately does not require `coverage` still looks red here. See
5671    /// [`Blocking`].
5672    #[serde(default)]
5673    merge_state_status: String,
5674    /// The commit the pull request currently points at. Compared with the
5675    /// commit a fix round pushed, it is how the loop knows the forge has moved
5676    /// on and the rollup belongs to the new head.
5677    #[serde(default)]
5678    head_ref_oid: String,
5679    #[serde(default)]
5680    base_ref_name: String,
5681    #[serde(default)]
5682    reviews: Vec<GhReview>,
5683    #[serde(default)]
5684    comments: Vec<GhComment>,
5685}
5686
5687/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
5688/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
5689/// status API, which is how CodeRabbit reports - has `context`/`state` and no
5690/// conclusion at all.
5691#[derive(Debug, Deserialize)]
5692#[serde(rename_all = "camelCase")]
5693struct GhCheck {
5694    #[serde(default)]
5695    name: Option<String>,
5696    #[serde(default)]
5697    context: Option<String>,
5698    #[serde(default)]
5699    status: Option<String>,
5700    #[serde(default)]
5701    conclusion: Option<String>,
5702    #[serde(default)]
5703    state: Option<String>,
5704    #[serde(default)]
5705    details_url: Option<String>,
5706    #[serde(default)]
5707    target_url: Option<String>,
5708    /// Whether the base branch requires this check. Only the GraphQL node
5709    /// carries it; `None` is "not read", never "not required".
5710    #[serde(default)]
5711    is_required: Option<bool>,
5712}
5713
5714impl GhCheck {
5715    /// Name to show a human and hand to the fixer.
5716    fn label(&self) -> String {
5717        self.name
5718            .clone()
5719            .or_else(|| self.context.clone())
5720            .unwrap_or_else(|| "(unnamed check)".to_owned())
5721    }
5722
5723    /// Where this check's logs live, when it has any.
5724    fn url(&self) -> Option<&str> {
5725        self.details_url
5726            .as_deref()
5727            .or(self.target_url.as_deref())
5728            .filter(|u| !u.is_empty())
5729    }
5730
5731    /// Did it pass?
5732    ///
5733    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
5734    /// skips release and bot pull requests by design, and a skip that blocked
5735    /// landing would block exactly the pull requests that need no review.
5736    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
5737    /// merging over one is merging over a check that never ran.
5738    fn verdict(&self) -> Verdict {
5739        if let Some(status) = self.status.as_deref() {
5740            if !status.eq_ignore_ascii_case("COMPLETED") {
5741                return Verdict::Pending;
5742            }
5743        }
5744        let outcome = self
5745            .conclusion
5746            .as_deref()
5747            .or(self.state.as_deref())
5748            .unwrap_or("");
5749        match outcome.to_ascii_uppercase().as_str() {
5750            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5751            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5752            | "ACTION_REQUIRED" => Verdict::Fail,
5753            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5754                Verdict::Pending
5755            }
5756            _ => Verdict::Unknown,
5757        }
5758    }
5759}
5760
5761#[derive(Debug, Deserialize)]
5762struct GhAuthor {
5763    #[serde(default)]
5764    login: String,
5765}
5766
5767#[derive(Debug, Deserialize)]
5768struct GhReview {
5769    #[serde(default)]
5770    author: GhAuthor,
5771    #[serde(default)]
5772    body: String,
5773}
5774
5775#[derive(Debug, Deserialize)]
5776struct GhComment {
5777    #[serde(default)]
5778    author: GhAuthor,
5779    #[serde(default)]
5780    body: String,
5781}
5782
5783#[derive(Debug, Deserialize)]
5784struct GhUser {
5785    #[serde(default)]
5786    login: String,
5787}
5788
5789#[derive(Debug, Deserialize)]
5790struct GhInline {
5791    #[serde(default)]
5792    user: GhUser,
5793    #[serde(default)]
5794    path: Option<String>,
5795    #[serde(default)]
5796    line: Option<u64>,
5797    #[serde(default)]
5798    body: String,
5799}
5800
5801impl Default for GhAuthor {
5802    fn default() -> Self {
5803        Self {
5804            login: "(unknown)".to_owned(),
5805        }
5806    }
5807}
5808
5809impl Default for GhUser {
5810    fn default() -> Self {
5811        Self {
5812            login: "(unknown)".to_owned(),
5813        }
5814    }
5815}
5816
5817#[cfg(test)]
5818pub(crate) mod tests {
5819    use super::*;
5820    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5821
5822    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5823        format!(
5824            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5825        )
5826    }
5827
5828    #[test]
5829    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5830        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5831        assert_eq!(
5832            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5833            Ok(())
5834        );
5835        for (json, why) in [
5836            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5837            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5838            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5839            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5840            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5841            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5842        ] {
5843            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5844                .unwrap_err()
5845                .why;
5846            assert!(err.contains(why), "{json}: {err}");
5847        }
5848        // A head that moved on past what was verified is left alone.
5849        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5850        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5851        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5852        assert!(
5853            !closable(
5854                &head_json("x", "main", "OPEN", false),
5855                "magi/27b2/A",
5856                "main",
5857                &[]
5858            )
5859            .unwrap_err()
5860            .retry
5861        );
5862        assert!(is_forge_url("https://github.com/o/r.git"));
5863        assert!(is_forge_url("git@github.com:o/r.git"));
5864        assert!(!is_forge_url("/tmp/origin.git"));
5865        assert!(!is_forge_url("C:\\work\\origin.git"));
5866        assert!(!is_forge_url("file:///tmp/origin.git"));
5867        assert!(forge_unavailable(
5868            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5869        ));
5870        assert!(!forge_unavailable(
5871            "gh pr list failed: error connecting to api.github.com"
5872        ));
5873        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5874        // A record that does not say whether it is a fork is not trusted.
5875        assert!(
5876            closable(
5877                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5878                "b",
5879                "main",
5880                &["aaa".to_owned()]
5881            )
5882            .is_err()
5883        );
5884    }
5885
5886    #[test]
5887    fn the_close_comment_names_the_commit_on_the_base() {
5888        let e = crate::already::Evidence {
5889            proof: crate::already::Proof::PatchId,
5890            tip: "1234567890".to_owned(),
5891            commits: vec!["0e368de0000".to_owned()],
5892        };
5893        let c = superseded_comment("main", &e);
5894        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5895    }
5896
5897    /// 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.
5898    const GREEN_OPEN: &str = r####"{
5899  "url": "https://github.com/yukimemi/magi/pull/10",
5900  "number": 10,
5901  "state": "OPEN",
5902  "mergeStateStatus": "CLEAN",
5903  "statusCheckRollup": [
5904    {
5905      "__typename": "CheckRun",
5906      "conclusion": "SKIPPED",
5907      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5908      "name": "review",
5909      "status": "COMPLETED",
5910      "workflowName": "claude-review"
5911    },
5912    {
5913      "__typename": "CheckRun",
5914      "conclusion": "SUCCESS",
5915      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5916      "name": "check (ubuntu-latest)",
5917      "status": "COMPLETED",
5918      "workflowName": "CI"
5919    },
5920    {
5921      "__typename": "CheckRun",
5922      "conclusion": "SUCCESS",
5923      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5924      "name": "rustfmt",
5925      "status": "COMPLETED",
5926      "workflowName": "CI"
5927    },
5928    {
5929      "__typename": "StatusContext",
5930      "context": "CodeRabbit",
5931      "state": "SUCCESS",
5932      "targetUrl": ""
5933    }
5934  ],
5935  "reviews": [],
5936  "comments": [
5937    {
5938      "author": {
5939        "login": "coderabbitai"
5940      },
5941      "authorAssociation": "NONE",
5942      "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"
5943    }
5944  ]
5945}"####;
5946
5947    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
5948    const RED_OPEN: &str = r####"{
5949  "url": "https://github.com/yukimemi/magi/pull/9",
5950  "number": 9,
5951  "state": "OPEN",
5952  "mergeStateStatus": "UNSTABLE",
5953  "statusCheckRollup": [
5954    {
5955      "__typename": "CheckRun",
5956      "conclusion": "SUCCESS",
5957      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5958      "name": "check (ubuntu-latest)",
5959      "status": "COMPLETED",
5960      "workflowName": "CI"
5961    },
5962    {
5963      "__typename": "CheckRun",
5964      "conclusion": "SUCCESS",
5965      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5966      "name": "rustfmt",
5967      "status": "COMPLETED",
5968      "workflowName": "CI"
5969    },
5970    {
5971      "__typename": "CheckRun",
5972      "conclusion": "FAILURE",
5973      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5974      "name": "editorconfig",
5975      "status": "COMPLETED",
5976      "workflowName": "CI"
5977    },
5978    {
5979      "__typename": "StatusContext",
5980      "context": "CodeRabbit",
5981      "state": "SUCCESS",
5982      "targetUrl": ""
5983    }
5984  ],
5985  "reviews": [],
5986  "comments": [
5987    {
5988      "author": {
5989        "login": "coderabbitai"
5990      },
5991      "authorAssociation": "NONE",
5992      "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"
5993    }
5994  ]
5995}"####;
5996
5997    /// 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.
5998    const PENDING_OPEN: &str = r####"{
5999  "url": "https://github.com/yukimemi/magi/pull/9",
6000  "number": 9,
6001  "state": "OPEN",
6002  "statusCheckRollup": [
6003    {
6004      "__typename": "CheckRun",
6005      "conclusion": "SUCCESS",
6006      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6007      "name": "check (ubuntu-latest)",
6008      "status": "COMPLETED",
6009      "workflowName": "CI"
6010    },
6011    {
6012      "__typename": "CheckRun",
6013      "conclusion": "SUCCESS",
6014      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6015      "name": "rustfmt",
6016      "status": "COMPLETED",
6017      "workflowName": "CI"
6018    },
6019    {
6020      "__typename": "CheckRun",
6021      "conclusion": null,
6022      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6023      "name": "editorconfig",
6024      "status": "IN_PROGRESS",
6025      "workflowName": "CI"
6026    },
6027    {
6028      "__typename": "StatusContext",
6029      "context": "CodeRabbit",
6030      "state": "SUCCESS",
6031      "targetUrl": ""
6032    }
6033  ],
6034  "reviews": [],
6035  "comments": []
6036}"####;
6037
6038    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
6039    const MERGED: &str = r####"{
6040  "url": "https://github.com/yukimemi/magi/pull/16",
6041  "number": 16,
6042  "state": "MERGED",
6043  "statusCheckRollup": [
6044    {
6045      "__typename": "CheckRun",
6046      "conclusion": "SUCCESS",
6047      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6048      "name": "check (ubuntu-latest)",
6049      "status": "COMPLETED",
6050      "workflowName": "CI"
6051    },
6052    {
6053      "__typename": "CheckRun",
6054      "conclusion": "SUCCESS",
6055      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6056      "name": "review",
6057      "status": "COMPLETED",
6058      "workflowName": "claude-review"
6059    }
6060  ],
6061  "reviews": [],
6062  "comments": []
6063}"####;
6064
6065    /// 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.
6066    const REVIEWED_OPEN: &str = r####"{
6067  "url": "https://github.com/yukimemi/magi/pull/12",
6068  "number": 12,
6069  "state": "OPEN",
6070  "statusCheckRollup": [
6071    {
6072      "__typename": "CheckRun",
6073      "conclusion": "SUCCESS",
6074      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6075      "name": "check (ubuntu-latest)",
6076      "status": "COMPLETED",
6077      "workflowName": "CI"
6078    },
6079    {
6080      "__typename": "CheckRun",
6081      "conclusion": "SUCCESS",
6082      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6083      "name": "review",
6084      "status": "COMPLETED",
6085      "workflowName": "claude-review"
6086    }
6087  ],
6088  "reviews": [
6089    {
6090      "author": {
6091        "login": "claude"
6092      },
6093      "state": "COMMENTED",
6094      "body": ""
6095    }
6096  ],
6097  "comments": [
6098    {
6099      "author": {
6100        "login": "coderabbitai"
6101      },
6102      "authorAssociation": "NONE",
6103      "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"
6104    },
6105    {
6106      "author": {
6107        "login": "claude"
6108      },
6109      "authorAssociation": "NONE",
6110      "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"
6111    }
6112  ]
6113}"####;
6114
6115    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
6116    const INLINE: &str = r####"[
6117  {
6118    "user": {
6119      "login": "claude[bot]"
6120    },
6121    "path": "src/graph.rs",
6122    "line": 231,
6123    "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"
6124  }
6125]"####;
6126
6127    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
6128    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6129<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6130
6131> [!IMPORTANT]
6132> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6133> 
6134> This repository does not receive automatic reviews because it has fewer than 10 stars.
6135> 
6136> <details>
6137> <summary>⚙️ Run configuration</summary>
6138> 
6139> **Configuration used**: defaults
6140> 
6141> **Review profile**: CHILL
6142> 
6143> **Plan**: Team
6144> 
6145> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6146> 
6147> </details>
6148
6149<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6150
6151<!-- tips_start -->
6152
6153---
6154
6155Thanks 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.
6156
6157<details>
6158<summary>❤️ Share</summary>
6159
6160- [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"####;
6161
6162    /// 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.
6163    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6164
6165---
6166### Reviewing PR #16
6167
6168- [x] Read AGENTS.md conventions
6169- [x] Review `src/daemon.rs` changes
6170- [x] Review `src/main.rs` changes (new `doctor` reporting)
6171- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6172- [x] Check test coverage for new behavior
6173- [x] Run verification commands (blocked — see note)
6174- [x] Post findings"####;
6175
6176    /// The same job's real comment on pull request #12 once it had something to say.
6177    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6178
6179---
6180### Review: `magi review <branch>` — cheap-half-only graph
6181
6182Read 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.
6183
6184**Correctness**
6185
6186- 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"####;
6187
6188    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6189        PrState {
6190            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6191            number: 16,
6192            state: PrLifecycle::Open,
6193            checks,
6194            // These tests are about red-means-fix, so a red here is one the
6195            // forge gates on. Without saying so they would assert the new
6196            // "merge past a check nobody requires" path by accident.
6197            blocking: if matches!(checks, Checks::Red) {
6198                Blocking::Yes
6199            } else {
6200                Blocking::No
6201            },
6202            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6203            review_comments: (0..comments)
6204                .map(|i| ReviewComment {
6205                    author: "coderabbitai".to_owned(),
6206                    path: Some("src/graph.rs".to_owned()),
6207                    line: Some(231),
6208                    body: format!("finding {i}"),
6209                })
6210                .collect(),
6211        }
6212    }
6213
6214    #[test]
6215    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6216        use CiExpectation::{Absent, Expected};
6217        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6218            let p = pr(checks, &["x"], 0);
6219            for waited in [Duration::ZERO, CHECKS_GRACE] {
6220                assert_eq!(
6221                    decide_with(&p, 0, 4, waited, Expected),
6222                    decide(&p, 0, 4, waited)
6223                );
6224            }
6225        }
6226        // Nothing will ever report: no wait, no give-up, no fix round.
6227        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6228            let p = pr(checks, &["x"], 0);
6229            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6230            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6231        }
6232        // A conflict is not a check: it still wants the rebase.
6233        let mut p = pr(Checks::Unknown, &[], 0);
6234        p.blocking = Blocking::Conflict;
6235        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6236        // And a pull request that left our hands is still done.
6237        p.state = PrLifecycle::Merged;
6238        assert_eq!(
6239            decide_with(&p, 0, 4, Duration::ZERO, Absent),
6240            Step::Done { merged: true }
6241        );
6242    }
6243
6244    #[test]
6245    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6246        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6247        assert_eq!(state.number, 10);
6248        assert_eq!(state.state, PrLifecycle::Open);
6249        assert_eq!(state.checks, Checks::Green);
6250        assert!(state.failing.is_empty());
6251        assert!(
6252            state.review_comments.is_empty(),
6253            "the only comment is CodeRabbit's trigger notice: {:?}",
6254            state.review_comments
6255        );
6256        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6257    }
6258
6259    #[test]
6260    fn a_failing_check_parses_as_red_and_is_named() {
6261        let state = parse_pr(RED_OPEN).expect("red fixture parses");
6262        assert_eq!(state.checks, Checks::Red);
6263        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6264        // The captured payload says `UNSTABLE` - mergeable, with a check
6265        // nobody requires red - which is exactly the shape that had to be
6266        // merged by hand. Asserted separately, in
6267        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
6268        // test is about is that a red check is *named*, so the reason a fixer
6269        // is handed says which one; so it asks the blocking question here.
6270        let mut blocking = state.clone();
6271        blocking.blocking = Blocking::Yes;
6272        match decide(&blocking, 0, 4, Duration::ZERO) {
6273            Step::Fix { reason } => {
6274                assert!(reason.contains("editorconfig"), "reason: {reason}");
6275                assert!(reason.contains("failing"), "reason: {reason}");
6276            }
6277            other => panic!("expected a fix round, got {other:?}"),
6278        }
6279    }
6280
6281    #[test]
6282    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6283        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6284        assert_eq!(state.checks, Checks::Pending);
6285        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6286    }
6287
6288    #[test]
6289    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6290        let state = parse_pr(MERGED).expect("merged fixture parses");
6291        assert_eq!(state.state, PrLifecycle::Merged);
6292        assert_eq!(
6293            decide(&state, 0, 4, Duration::ZERO),
6294            Step::Done { merged: true }
6295        );
6296    }
6297
6298    #[test]
6299    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6300        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6301        assert_eq!(state.checks, Checks::Green);
6302        let authors: Vec<&str> = state
6303            .review_comments
6304            .iter()
6305            .map(|c| c.author.as_str())
6306            .collect();
6307        assert_eq!(
6308            authors,
6309            vec!["claude"],
6310            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6311        );
6312        match decide(&state, 0, 4, Duration::ZERO) {
6313            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6314            other => panic!("expected a fix round, got {other:?}"),
6315        }
6316    }
6317
6318    #[test]
6319    fn inline_review_comments_keep_their_file_and_line() {
6320        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6321        assert_eq!(comments.len(), 1);
6322        assert_eq!(comments[0].author, "claude[bot]");
6323        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6324        assert_eq!(comments[0].line, Some(231));
6325        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6326    }
6327
6328    #[test]
6329    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6330        assert!(
6331            is_noise(CODERABBIT_TRIGGER),
6332            "CodeRabbit's trigger notice declares itself not a review"
6333        );
6334        assert!(
6335            is_noise(CLAUDE_CHECKLIST),
6336            "a progress checklist asks for nothing"
6337        );
6338        assert!(
6339            !is_noise(CLAUDE_FINDING),
6340            "a review that names a bug is input, not noise"
6341        );
6342
6343        let mut clean = pr(Checks::Green, &[], 0);
6344        clean.review_comments.push(ReviewComment {
6345            author: "coderabbitai".to_owned(),
6346            path: None,
6347            line: None,
6348            body: CODERABBIT_TRIGGER.to_owned(),
6349        });
6350        clean.review_comments.retain(|c| !is_noise(&c.body));
6351        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6352
6353        let mut found = pr(Checks::Green, &[], 0);
6354        found.review_comments.push(ReviewComment {
6355            author: "claude".to_owned(),
6356            path: None,
6357            line: None,
6358            body: CLAUDE_FINDING.to_owned(),
6359        });
6360        found.review_comments.retain(|c| !is_noise(&c.body));
6361        assert!(matches!(
6362            decide(&found, 0, 4, Duration::ZERO),
6363            Step::Fix { .. }
6364        ));
6365    }
6366
6367    #[test]
6368    fn the_policy_table_holds_for_every_combination_that_matters() {
6369        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6370            (
6371                "pending checks are waited for, even on the last round",
6372                pr(Checks::Pending, &[], 0),
6373                4,
6374                4,
6375                Duration::ZERO,
6376                Step::Wait,
6377            ),
6378            (
6379                "red checks are fixed",
6380                pr(Checks::Red, &["editorconfig"], 0),
6381                0,
6382                4,
6383                Duration::ZERO,
6384                Step::Fix {
6385                    reason: "1 check(s) failing: editorconfig".to_owned(),
6386                },
6387            ),
6388            (
6389                "green with comments is fixed, not merged",
6390                pr(Checks::Green, &[], 2),
6391                1,
6392                4,
6393                Duration::ZERO,
6394                Step::Fix {
6395                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6396                        .to_owned(),
6397                },
6398            ),
6399            (
6400                "green and clean merges",
6401                pr(Checks::Green, &[], 0),
6402                3,
6403                4,
6404                Duration::ZERO,
6405                Step::Merge,
6406            ),
6407            (
6408                "an unreadable rollup is waited on while the grace lasts",
6409                pr(Checks::Unknown, &[], 0),
6410                0,
6411                4,
6412                Duration::ZERO,
6413                Step::Wait,
6414            ),
6415            (
6416                "an unreadable rollup is never merged once the grace is spent",
6417                pr(Checks::Unknown, &[], 0),
6418                0,
6419                4,
6420                CHECKS_GRACE,
6421                Step::GiveUp {
6422                    reason: "no check status is readable on the pull request after 3 minute(s); \
6423                             refusing to merge on a guess"
6424                        .to_owned(),
6425                },
6426            ),
6427        ];
6428        for (what, state, round, budget, waited, want) in cases {
6429            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6430        }
6431    }
6432
6433    #[test]
6434    fn the_forge_verdict_survives_the_round_trip_from_gh() {
6435        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
6436        // requested but never parsed is the kind of thing that looks wired up
6437        // and answers `Unsaid` forever.
6438        let green = parse_pr(GREEN_OPEN).expect("parse");
6439        assert_eq!(green.blocking, Blocking::No);
6440        let red = parse_pr(RED_OPEN).expect("parse");
6441        assert_eq!(
6442            red.blocking,
6443            Blocking::No,
6444            "`UNSTABLE` is mergeable: the red check is one nobody requires"
6445        );
6446        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6447        // A payload from an older `gh` has no such field at all.
6448        let quiet =
6449            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6450        assert_eq!(quiet.blocking, Blocking::Unsaid);
6451    }
6452
6453    #[test]
6454    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6455        // Pull request 37's only red check was `editorconfig`, failing
6456        // because the action could not fetch its own binary after
6457        // editorconfig-checker v4 renamed its release assets. The repository
6458        // does not require it. magi answered by asking a fixer to repair a
6459        // change that was fine, and the pull request had to be merged by hand.
6460        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6461        nonblocking.blocking = Blocking::No;
6462        assert_eq!(
6463            decide(&nonblocking, 0, 4, Duration::ZERO),
6464            Step::Merge,
6465            "the forge says nothing is in the way, so nothing is"
6466        );
6467
6468        // The same red, gated on: that is a fix round, as before.
6469        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6470        blocking.blocking = Blocking::Yes;
6471        assert!(matches!(
6472            decide(&blocking, 0, 4, Duration::ZERO),
6473            Step::Fix { .. }
6474        ));
6475
6476        // A review comment still outranks green-enough: a non-required red
6477        // must not become a way to merge past an unanswered reviewer.
6478        let mut commented = pr(Checks::Red, &["coverage"], 1);
6479        commented.blocking = Blocking::No;
6480        assert!(matches!(
6481            decide(&commented, 0, 4, Duration::ZERO),
6482            Step::Fix { .. }
6483        ));
6484
6485        // And silence from the forge is not consent.
6486        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6487        unsaid.blocking = Blocking::Unsaid;
6488        assert!(matches!(
6489            decide(&unsaid, 0, 4, Duration::ZERO),
6490            Step::Fix { .. }
6491        ));
6492    }
6493
6494    #[test]
6495    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6496        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6497        red.blocking = Blocking::No;
6498        assert_eq!(
6499            decide(&red, 0, 4, Duration::ZERO),
6500            Step::Merge,
6501            "announcing must not change the decision"
6502        );
6503        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6504        assert!(said.contains("yukimemi/magi"), "{said}");
6505        assert!(said.contains("#16"), "{said}");
6506        assert!(
6507            said.contains("https://github.com/yukimemi/magi/pull/16"),
6508            "{said}"
6509        );
6510        assert!(
6511            said.contains("test (windows-latest)") && said.contains("coverage"),
6512            "{said}"
6513        );
6514
6515        // `failing` can be left over on a green observation; only `checks` counts.
6516        let green = pr(Checks::Green, &["stale"], 0);
6517        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6518    }
6519
6520    #[test]
6521    fn red_merge_notices_omit_empty_check_details() {
6522        for checks in [vec![], vec!["", "  "]] {
6523            let red = pr(Checks::Red, &checks, 0);
6524            let summary = red_merge_summary("owner/repo", &red).unwrap();
6525            let notice = crate::notices::merged_red("run-1", &summary);
6526            assert_eq!(
6527                notice.message,
6528                format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6529            );
6530            assert_eq!(notice.severity, crate::notices::Severity::Warn);
6531        }
6532        let red = pr(Checks::Red, &["", " test ", " "], 0);
6533        let summary = red_merge_summary("owner/repo", &red).unwrap();
6534        assert!(summary.contains("with red checks: test ("), "{summary}");
6535    }
6536
6537    #[test]
6538    fn the_repo_label_comes_from_the_pull_request_url() {
6539        let p = Path::new("/tmp/checkout");
6540        assert_eq!(
6541            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6542            "yukimemi/magi"
6543        );
6544        assert_eq!(repo_label(p, "not a url"), "checkout");
6545    }
6546
6547    #[test]
6548    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6549        // Pull requests 35 and 37 were both rebased by hand: a competition
6550        // that runs for two hours against a repository merging pull requests
6551        // all day conflicts on the way in, and that is arithmetic rather
6552        // than a defect in the change.
6553        let mut conflicted = pr(Checks::Green, &[], 0);
6554        conflicted.blocking = Blocking::Conflict;
6555        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6556
6557        // Decided before the checks, and even with the rounds spent: every
6558        // check on a branch that cannot land is an answer about a state that
6559        // cannot land, and a conflict is not the change's fault.
6560        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6561        red.blocking = Blocking::Conflict;
6562        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6563
6564        // The lifecycle still wins over everything, conflict included.
6565        let mut merged = pr(Checks::Red, &[], 0);
6566        merged.blocking = Blocking::Conflict;
6567        merged.state = PrLifecycle::Merged;
6568        assert_eq!(
6569            decide(&merged, 0, 4, Duration::ZERO),
6570            Step::Done { merged: true }
6571        );
6572    }
6573
6574    #[test]
6575    fn the_forge_verdict_is_read_off_merge_state_status() {
6576        // The spellings that mean "mergeable". `UNSTABLE` is the one that
6577        // matters: mergeable, with a non-required check red or still running.
6578        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6579            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6580            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6581        }
6582        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6583        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6584        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6585        // An older `gh`, or a token without the scope, says nothing - and
6586        // refusing to guess is the rule everywhere else in this module.
6587        for quiet in ["", "UNKNOWN"] {
6588            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6589            assert!(Blocking::of(quiet).stops_a_merge());
6590        }
6591    }
6592
6593    #[test]
6594    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6595        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6596        // The exact stderr from run ec12, in a jj-colocated repository.
6597        let jj = "could not determine current branch: failed to run git: not on any branch";
6598
6599        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6600            .expect("the forge says merged, so it merged");
6601        assert!(landed.ok);
6602        assert!(
6603            landed.detail.contains("but the pull request is merged"),
6604            "the record must not read as a clean success: {}",
6605            landed.detail
6606        );
6607        assert!(
6608            landed.detail.contains("not on any branch"),
6609            "and it must keep what the command actually said: {}",
6610            landed.detail
6611        );
6612
6613        // A pull request still open means the merge really failed.
6614        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6615        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6616        // And an unreadable answer is not evidence of success.
6617        assert!(merged_after_all(&argv, jj, None).is_none());
6618    }
6619
6620    #[test]
6621    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6622        let mut state = pr(Checks::Red, &["editorconfig"], 3);
6623        state.state = PrLifecycle::Closed;
6624        assert_eq!(
6625            decide(&state, 0, 4, Duration::ZERO),
6626            Step::Done { merged: false },
6627            "a human closing the pull request ends the loop, whatever CI says"
6628        );
6629    }
6630
6631    #[test]
6632    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6633        let red = decide(
6634            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6635            4,
6636            4,
6637            Duration::ZERO,
6638        );
6639        match red {
6640            Step::GiveUp { reason } => {
6641                assert!(reason.contains("editorconfig"), "reason: {reason}");
6642                assert!(reason.contains("test (macos)"), "reason: {reason}");
6643                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6644            }
6645            other => panic!("expected a give-up, got {other:?}"),
6646        }
6647
6648        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6649        match commented {
6650            Step::GiveUp { reason } => {
6651                assert!(reason.contains("unresolved"), "reason: {reason}");
6652                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6653            }
6654            other => panic!("expected a give-up, got {other:?}"),
6655        }
6656    }
6657
6658    #[test]
6659    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6660        let candidate_commit = "magi: candidate A (uncommitted work)";
6661        let subject = merge_subject(candidate_commit, "add retries to the uploader");
6662        let argv = merge_argv(16, &subject);
6663
6664        assert!(argv.contains(&"--squash".to_owned()));
6665        assert!(argv.contains(&"--delete-branch".to_owned()));
6666        assert!(argv.contains(&"--subject".to_owned()));
6667        assert_eq!(
6668            argv.last().map(String::as_str),
6669            Some("add retries to the uploader"),
6670            "the subject must not be the candidate commit message"
6671        );
6672        assert_ne!(subject, candidate_commit);
6673    }
6674
6675    #[test]
6676    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6677        assert_eq!(
6678            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6679            "feat: a queue, an unattended loop, and a phone UI"
6680        );
6681        assert_eq!(
6682            merge_subject("", "# port the retry logic\n\ndetails"),
6683            "port the retry logic",
6684            "an empty title falls back to the task's first line, heading marks stripped"
6685        );
6686    }
6687
6688    #[test]
6689    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6690        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6691        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6692        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6693        assert_eq!(job_of("https://coderabbit.ai/status"), None);
6694        assert_eq!(run_of(""), None);
6695    }
6696
6697    #[test]
6698    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6699        let mut out = Vec::new();
6700        push_if_outstanding(
6701            &mut out,
6702            ReviewComment {
6703                author: "yukimemi".to_owned(),
6704                path: None,
6705                line: None,
6706                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6707            },
6708        );
6709        assert!(out.is_empty());
6710    }
6711
6712    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
6713    /// falls back to the repository - which keeps these tests free of a
6714    /// worktree, a `git` invocation and a network.
6715    fn run_state() -> RunState {
6716        let mut state = RunState::new(
6717            std::path::PathBuf::from("/repo/magi"),
6718            "main".to_owned(),
6719            "abcdef1234".to_owned(),
6720            "add retries to the uploader".to_owned(),
6721            crate::config::Config::default(),
6722        );
6723        // Tests run in parallel against one persistent home and the ids
6724        // `RunState::new` draws from the clock can repeat, so two tests would
6725        // share a run's questions. The home also outlives the process, so the
6726        // counter alone would reuse an earlier run's ids.
6727        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6728        let nanos = std::time::SystemTime::now()
6729            .duration_since(std::time::UNIX_EPOCH)
6730            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6731        state.id = format!(
6732            "20261004-{nanos:06}-{:04x}",
6733            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6734        );
6735        state
6736    }
6737
6738    fn green_pr() -> PrState {
6739        PrState {
6740            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6741            number: 42,
6742            state: PrLifecycle::Open,
6743            checks: Checks::Green,
6744            // The forge sees nothing in the way unless a test says otherwise.
6745            blocking: Blocking::No,
6746            failing: Vec::new(),
6747            review_comments: vec![ReviewComment {
6748                author: "coderabbitai".to_owned(),
6749                path: Some("src/land.rs".to_owned()),
6750                line: Some(212),
6751                body: "this branch never checks the exit code".to_owned(),
6752            }],
6753        }
6754    }
6755
6756    #[test]
6757    fn land_fixer_defers_review_and_verification_without_a_cache() {
6758        let state = run_state();
6759        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6760        assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
6761        assert!(p.contains("Commit in this worktree"), "{p}");
6762    }
6763
6764    #[test]
6765    fn github_facing_land_text_is_english_whatever_the_language() {
6766        let mut state = run_state();
6767        state.config.graph.language = "ja".to_owned();
6768        let comment = stop_comment(&state.id, "checks are still red");
6769        assert!(crate::github_text::check("", &comment).is_empty());
6770        assert!(comment.is_ascii(), "{comment}");
6771        assert!(comment.starts_with(MARKER));
6772
6773        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6774        let ja_at = p.find("Write all prose in ja").unwrap();
6775        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6776        assert!(ja_at < rule_at, "{p}");
6777        assert!(p.contains("stays in Japanese"), "{p}");
6778
6779        state.config.graph.language = "en".to_owned();
6780        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6781        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6782        assert!(!p.contains("does not apply"), "{p}");
6783    }
6784
6785    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6786
6787    fn panel() -> String {
6788        approval_panel(
6789            &run_state(),
6790            &green_pr(),
6791            NUMSTAT,
6792            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6793            &[
6794                "land: ask before merging".to_owned(),
6795                "land: colour the diff".to_owned(),
6796            ],
6797            "feat: merge approval from the phone",
6798        )
6799    }
6800
6801    #[test]
6802    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6803        let html = panel();
6804        for needle in [
6805            "42",
6806            "main",
6807            "src/land.rs",
6808            "src/web.rs",
6809            "assets/logo.png",
6810            "feat: merge approval from the phone",
6811            "land: ask before merging",
6812            "land: colour the diff",
6813            "coderabbitai",
6814            "this branch never checks the exit code",
6815            "green",
6816        ] {
6817            assert!(html.contains(needle), "the panel must state `{needle}`");
6818        }
6819    }
6820
6821    async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
6822        crate::run::pin_test_home();
6823        let tmp = tempfile::tempdir().unwrap();
6824        let repo = tmp.path().join("repo");
6825        let remote = tmp.path().join("remote.git");
6826        std::fs::create_dir(&repo).unwrap();
6827        git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
6828            .await
6829            .unwrap();
6830        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
6831        git::git(&repo, &["config", "user.name", "Test"])
6832            .await
6833            .unwrap();
6834        git::git(&repo, &["config", "user.email", "test@example.test"])
6835            .await
6836            .unwrap();
6837        std::fs::write(repo.join("base"), "base").unwrap();
6838        git::git(&repo, &["add", "."]).await.unwrap();
6839        git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
6840        git::git(
6841            &repo,
6842            &["remote", "add", "origin", remote.to_str().unwrap()],
6843        )
6844        .await
6845        .unwrap();
6846        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
6847        git::git(&repo, &["checkout", "-b", "winner"])
6848            .await
6849            .unwrap();
6850        std::fs::write(repo.join("change"), "change").unwrap();
6851        git::git(&repo, &["add", "."]).await.unwrap();
6852        git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
6853        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
6854        git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
6855            .await
6856            .unwrap();
6857        let mut state = run_state();
6858        state.repo = repo.clone();
6859        state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
6860        state.config.graph.land = true;
6861        state.config.graph.land_approval = true;
6862        state.pr_head = Some("task-head".into());
6863        state.pr_pushed_head = Some(head.clone());
6864        state.candidates = vec![winning_candidate("change")];
6865        state.candidates[0].worktree = repo;
6866        state.candidates[0].branch = "winner".into();
6867        state.tally = Some(uncontested_tally());
6868        state.gate_ran = true;
6869        state.gate_head = Some(head.clone());
6870        state.land_sync_pending = true;
6871        state.save().unwrap();
6872        (tmp, state, head)
6873    }
6874
6875    async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
6876        let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
6877        resume_landing_head(state, head, &green_pr().url, &forge).await
6878    }
6879
6880    async fn advance_landing_base(state: &RunState) {
6881        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
6882        std::fs::write(state.repo.join("advance"), "advance").unwrap();
6883        git::git(&state.repo, &["add", "."]).await.unwrap();
6884        git::git(&state.repo, &["commit", "-m", "advance base"])
6885            .await
6886            .unwrap();
6887        git::git(&state.repo, &["push", "origin", "main"])
6888            .await
6889            .unwrap();
6890        git::git(&state.repo, &["checkout", "winner"])
6891            .await
6892            .unwrap();
6893    }
6894
6895    #[tokio::test]
6896    async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
6897        let (_tmp, mut state, old) = landing_git_fixture().await;
6898        advance_landing_base(&state).await;
6899        state.config.verify.gate = vec!["test -f advance".into()];
6900        let head = resume_observed_head(&mut state, &old).await.unwrap();
6901        assert_ne!(head, old);
6902        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
6903        assert!(state.gate_allows_progress());
6904        assert_eq!(
6905            git::remote_head(&state.repo, "origin", "task-head")
6906                .await
6907                .unwrap(),
6908            Some(head.clone())
6909        );
6910        assert_eq!(state.pr_pushed_head, Some(head));
6911        assert!(!state.land_sync_pending);
6912        assert!(state.config.graph.land_approval);
6913    }
6914
6915    fn landing_gate_fixer(state: &mut RunState, script: &str) {
6916        state.config.disk.min_free_bytes = 0;
6917        state.config.graph.gate_fix_rounds = 1;
6918        state.config.agents = vec![crate::config::AgentSpec {
6919            id: "mock".into(),
6920            kind: crate::config::AgentKind::Command,
6921            model: None,
6922            command: vec!["sh".into(), "-c".into(), script.into()],
6923            extra_args: Vec::new(),
6924            env: Default::default(),
6925            prompt_delivery: None,
6926            max_turn_secs: None,
6927        }];
6928        state.candidates[0].agent = "mock".into();
6929    }
6930
6931    #[tokio::test]
6932    async fn landing_resume_revalidates_an_operator_published_winner_extension() {
6933        let (_tmp, mut state, old) = landing_git_fixture().await;
6934        std::fs::write(state.repo.join("change"), "operator update").unwrap();
6935        git::commit_all(&state.repo, "extend winner").await.unwrap();
6936        let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
6937        git::git(
6938            &state.repo,
6939            &["push", "origin", "HEAD:refs/heads/task-head"],
6940        )
6941        .await
6942        .unwrap();
6943        state.config.verify.gate = vec!["exit 1".into()];
6944        state.config.graph.gate_fix_rounds = 0;
6945        assert!(resume_observed_head(&mut state, &updated).await.is_err());
6946        assert_ne!(old, updated);
6947        assert_eq!(state.gate_head.as_ref(), Some(&updated));
6948        assert!(!state.gate_allows_progress());
6949        assert!(state.land_sync_pending);
6950        state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
6951        let head = resume_observed_head(&mut state, &updated).await.unwrap();
6952        assert_eq!(head, updated);
6953        assert!(state.gate_allows_progress());
6954        assert!(!state.land_sync_pending);
6955        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
6956        land_with(&mut state, &green_pr().url, &forge)
6957            .await
6958            .unwrap();
6959        assert!(state.parked);
6960        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
6961        assert!(forge.argvs().is_empty());
6962    }
6963
6964    #[tokio::test]
6965    async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
6966        for divergent in [false, true] {
6967            let (_tmp, mut state, old) = landing_git_fixture().await;
6968            if divergent {
6969                git::git(&state.repo, &["reset", "--hard", "origin/main"])
6970                    .await
6971                    .unwrap();
6972            }
6973            std::fs::write(state.repo.join("external"), "external").unwrap();
6974            git::commit_all(&state.repo, "external update")
6975                .await
6976                .unwrap();
6977            let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
6978            git::git(
6979                &state.repo,
6980                &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
6981            )
6982            .await
6983            .unwrap();
6984            if !divergent {
6985                git::git(&state.repo, &["reset", "--hard", &old])
6986                    .await
6987                    .unwrap();
6988            }
6989            let error = resume_observed_head(&mut state, &external)
6990                .await
6991                .unwrap_err();
6992            assert!(
6993                error
6994                    .to_string()
6995                    .contains("public head moved outside this run")
6996            );
6997            assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
6998            assert_eq!(
6999                git::remote_head(&state.repo, "origin", "task-head")
7000                    .await
7001                    .unwrap(),
7002                Some(external)
7003            );
7004        }
7005    }
7006
7007    #[tokio::test]
7008    async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
7009        let (_tmp, mut state, old) = landing_git_fixture().await;
7010        advance_landing_base(&state).await;
7011        landing_gate_fixer(
7012            &mut state,
7013            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7014        );
7015        state.config.verify.gate =
7016            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7017        state.config.verify.e2e = vec!["test -f advance".into()];
7018        // First save a red gate without spending a fixer round. Resume must
7019        // still offer the remaining budget for this exact same failed head.
7020        state.config.graph.gate_fix_rounds = 0;
7021        assert!(resume_observed_head(&mut state, &old).await.is_err());
7022        assert_eq!(
7023            git::remote_head(&state.repo, "origin", "task-head")
7024                .await
7025                .unwrap(),
7026            Some(old.clone())
7027        );
7028        state.config.graph.gate_fix_rounds = 1;
7029        let head = resume_observed_head(&mut state, &old).await.unwrap();
7030        assert_eq!(state.gate_fixes.len(), 1);
7031        assert!(state.gate_fixes[0].committed);
7032        assert!(state.gate_allows_progress());
7033        assert_eq!(state.gate_head.as_ref(), Some(&head));
7034        assert_eq!(
7035            git::remote_head(&state.repo, "origin", "task-head")
7036                .await
7037                .unwrap(),
7038            Some(head.clone())
7039        );
7040        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7041        land_with(&mut state, &green_pr().url, &forge)
7042            .await
7043            .unwrap();
7044        assert!(state.parked);
7045        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7046        assert!(forge.argvs().is_empty());
7047    }
7048
7049    #[tokio::test]
7050    async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7051        let (_tmp, mut state, old) = landing_git_fixture().await;
7052        advance_landing_base(&state).await;
7053        landing_gate_fixer(
7054            &mut state,
7055            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7056        );
7057        state.config.verify.gate = vec!["echo still red; exit 1".into()];
7058        for _ in 0..2 {
7059            assert!(resume_observed_head(&mut state, &old).await.is_err());
7060            assert_eq!(state.gate_fixes.len(), 1);
7061            assert_eq!(
7062                git::remote_head(&state.repo, "origin", "task-head")
7063                    .await
7064                    .unwrap(),
7065                Some(old.clone())
7066            );
7067        }
7068    }
7069
7070    #[tokio::test]
7071    async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7072        let (_tmp, mut state, old) = landing_git_fixture().await;
7073        advance_landing_base(&state).await;
7074        landing_gate_fixer(
7075            &mut state,
7076            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7077        );
7078        state.config.verify.gate =
7079            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7080        // The repair makes the gate green while breaking an e2e invariant.
7081        state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7082        for resume in 0..3 {
7083            assert!(resume_observed_head(&mut state, &old).await.is_err());
7084            state = RunState::load(&state.id).unwrap();
7085            let evidence = state.gate_repair_e2e.as_ref().unwrap();
7086            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7087            assert_eq!(evidence.head, head);
7088            assert!(!evidence.passed);
7089            assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7090            assert_eq!(state.gate_fixes.len(), 1);
7091            assert_eq!(
7092                git::remote_head(&state.repo, "origin", "task-head")
7093                    .await
7094                    .unwrap(),
7095                Some(old.clone())
7096            );
7097            if resume > 0 {
7098                assert!(
7099                    state.gate_allows_progress(),
7100                    "a green gate must not bypass red e2e"
7101                );
7102            }
7103        }
7104        // Once e2e genuinely succeeds on the same SHA, the saved requirement
7105        // can be satisfied without another fixer or approval bypass.
7106        state.config.verify.e2e = vec!["grep -q fixed change".into()];
7107        let head = resume_observed_head(&mut state, &old).await.unwrap();
7108        assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7109        assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7110        assert_eq!(state.gate_fixes.len(), 1);
7111        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7112        land_with(&mut state, &green_pr().url, &forge)
7113            .await
7114            .unwrap();
7115        assert!(state.parked);
7116        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7117        assert!(forge.argvs().is_empty());
7118    }
7119
7120    #[tokio::test]
7121    async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7122        for phase in 0..3 {
7123            let (_tmp, mut state, old) = landing_git_fixture().await;
7124            advance_landing_base(&state).await;
7125            let head = resume_observed_head(&mut state, &old).await.unwrap();
7126            // Persist the shapes left by interruption, resource deferral,
7127            // and a successful check for a different (older) SHA.
7128            state.land_sync_pending = true;
7129            state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7130            state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7131                head: if phase == 2 {
7132                    old.clone()
7133                } else {
7134                    head.clone()
7135                },
7136                passed: phase == 2,
7137                outcomes: if phase == 1 {
7138                    vec![crate::run::CommandOutcome {
7139                        command: state.config.verify.e2e[0].clone(),
7140                        code: None,
7141                        output_tail: "verification resource unavailable".into(),
7142                        duration_ms: 0,
7143                        resource_blocked: true,
7144                    }]
7145                } else {
7146                    Vec::new()
7147                },
7148            });
7149            state.save().unwrap();
7150            for _ in 0..2 {
7151                state = RunState::load(&state.id).unwrap();
7152                assert!(resume_observed_head(&mut state, &head).await.is_err());
7153                let evidence = state.gate_repair_e2e.as_ref().unwrap();
7154                assert_eq!(evidence.head, head);
7155                assert!(!evidence.passed);
7156                assert!(!evidence.outcomes.is_empty());
7157                assert_eq!(
7158                    git::remote_head(&state.repo, "origin", "task-head")
7159                        .await
7160                        .unwrap(),
7161                    Some(head.clone())
7162                );
7163                assert!(state.land_sync_pending);
7164            }
7165        }
7166    }
7167
7168    async fn interrupt_landing_rebase(state: &mut RunState) {
7169        state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7170        state.save().unwrap();
7171        let scratch = state.dir().join("rebase");
7172        assert_eq!(
7173            crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7174                .await
7175                .unwrap(),
7176            crate::rebase::Rebased::Applied
7177        );
7178        assert!(!git::is_clean(&state.repo).await.unwrap());
7179    }
7180
7181    async fn landing_setup(state: &mut RunState) {
7182        git::git(
7183            &state.repo,
7184            &["config", "extensions.worktreeConfig", "true"],
7185        )
7186        .await
7187        .unwrap();
7188        state.config.worktree.setup =
7189            vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7190        crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7191            .await
7192            .unwrap();
7193        state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7194    }
7195
7196    #[tokio::test]
7197    async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7198        for phase in 0..3 {
7199            let (_tmp, mut state, old) = landing_git_fixture().await;
7200            advance_landing_base(&state).await;
7201            landing_setup(&mut state).await;
7202            if phase > 0 {
7203                interrupt_landing_rebase(&mut state).await;
7204                if phase == 2 {
7205                    // Crash after clean removed setup products, before setup.
7206                    git::sync_to_head(&state.repo).await.unwrap();
7207                    assert!(!state.repo.join(".env").exists());
7208                }
7209                state = RunState::load(&state.id).unwrap();
7210            }
7211            let head = resume_observed_head(&mut state, &old).await.unwrap();
7212            assert_ne!(head, old);
7213            assert!(state.gate_allows_progress());
7214            assert!(state.land_rebase_from.is_none());
7215            assert_eq!(
7216                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7217                "secret"
7218            );
7219            assert!(git::is_clean(&state.repo).await.unwrap());
7220            assert!(
7221                !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7222                    .await
7223                    .unwrap()
7224                    .contains(".env")
7225            );
7226        }
7227    }
7228
7229    #[tokio::test]
7230    async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7231        let (_tmp, mut state, _) = landing_git_fixture().await;
7232        advance_landing_base(&state).await;
7233        landing_setup(&mut state).await;
7234        interrupt_landing_rebase(&mut state).await;
7235        // Exercise unusual names without asking Windows to create an invalid
7236        // control-character filename. Unix also covers embedded newlines.
7237        let locals = [
7238            "local notes",
7239            #[cfg(unix)]
7240            "local\nnotes",
7241        ];
7242        let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7243            .await
7244            .unwrap();
7245        std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7246        for local in locals {
7247            std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7248            assert!(
7249                git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7250                    .await
7251                    .unwrap()
7252                    .ok()
7253            );
7254        }
7255        restore_landing_worktree(&mut state).await.unwrap();
7256        for local in locals {
7257            assert_eq!(
7258                std::fs::read_to_string(state.repo.join(local)).unwrap(),
7259                "keep these notes"
7260            );
7261        }
7262        assert_eq!(
7263            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7264            "secret"
7265        );
7266        assert!(git::is_clean(&state.repo).await.unwrap());
7267        assert!(state.land_rebase_from.is_none());
7268    }
7269
7270    #[tokio::test]
7271    async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7272        for local in ["advance", "advance/notes", "new-directory"] {
7273            let (_tmp, mut state, _) = landing_git_fixture().await;
7274            advance_landing_base(&state).await;
7275            // Also cover a local file blocking a new tracked directory.
7276            git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7277            std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7278            std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7279            git::git(&state.repo, &["add", "."]).await.unwrap();
7280            git::git(&state.repo, &["commit", "-m", "add directory"])
7281                .await
7282                .unwrap();
7283            git::git(&state.repo, &["push", "origin", "main"])
7284                .await
7285                .unwrap();
7286            git::git(&state.repo, &["checkout", "winner"])
7287                .await
7288                .unwrap();
7289            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7290                .await
7291                .unwrap();
7292            std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7293            let path = state.repo.join(local);
7294            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7295            std::fs::write(&path, "local notes").unwrap();
7296            interrupt_landing_rebase(&mut state).await;
7297            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7298            let checkpoint = state.land_rebase_from.clone();
7299            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7300            assert!(
7301                error.to_string().contains("conflicting with HEAD"),
7302                "{error:#}"
7303            );
7304            assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7305            assert_eq!(
7306                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7307                before
7308            );
7309            assert_eq!(state.land_rebase_from, checkpoint);
7310        }
7311    }
7312
7313    #[tokio::test]
7314    async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7315        for interrupted in [false, true] {
7316            let (_tmp, mut state, old) = landing_git_fixture().await;
7317            assert!(state.config.worktree.setup.is_empty());
7318            // A gate product exists before the base moves, without setup metadata.
7319            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7320                .await
7321                .unwrap();
7322            std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7323            state.config.verify.gate =
7324                vec!["mkdir -p target && printf artifact > target/probe".into()];
7325            state.gate_ran = false;
7326            resume_observed_head(&mut state, &old).await.unwrap();
7327            assert_eq!(
7328                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7329                "artifact"
7330            );
7331            advance_landing_base(&state).await;
7332            if interrupted {
7333                interrupt_landing_rebase(&mut state).await;
7334                state = RunState::load(&state.id).unwrap();
7335            }
7336            state.config.verify.gate =
7337                vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7338            let head = resume_observed_head(&mut state, &old).await.unwrap();
7339            assert_ne!(head, old);
7340            assert!(state.gate_allows_progress());
7341            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7342            assert_eq!(
7343                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7344                "artifact"
7345            );
7346            assert!(state.land_rebase_from.is_none());
7347            assert_eq!(
7348                git::remote_head(&state.repo, "origin", "task-head")
7349                    .await
7350                    .unwrap(),
7351                Some(head.clone())
7352            );
7353            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7354            land_with(&mut state, &green_pr().url, &forge)
7355                .await
7356                .unwrap();
7357            assert!(state.parked);
7358            assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7359            assert!(forge.argvs().is_empty());
7360        }
7361    }
7362
7363    #[tokio::test]
7364    #[cfg(unix)]
7365    async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7366        use std::os::fd::AsRawFd as _;
7367
7368        for interrupted in [false, true] {
7369            let (_tmp, mut state, _) = landing_git_fixture().await;
7370            advance_landing_base(&state).await;
7371            landing_setup(&mut state).await;
7372            if interrupted {
7373                interrupt_landing_rebase(&mut state).await;
7374            }
7375            state.config.worktree.setup =
7376                vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7377            let common = crate::repo_lock::common_dir(&state.repo)
7378                .await
7379                .unwrap()
7380                .unwrap();
7381            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7382            state.config.coordination = crate::config::test_coordination();
7383            state.config.graph.timeout_resource_wait = 0;
7384            let external = std::fs::OpenOptions::new()
7385                .read(true)
7386                .write(true)
7387                .create(true)
7388                .truncate(false)
7389                .open(common.join("updater.lock"))
7390                .unwrap();
7391            // SAFETY: the descriptor belongs to `external` and remains open.
7392            let locked =
7393                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7394            assert_eq!(locked, 0);
7395            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7396            let checkpoint = state.land_rebase_from.clone();
7397            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7398            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7399            assert_eq!(
7400                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7401                before
7402            );
7403            assert_eq!(state.land_rebase_from, checkpoint);
7404            assert_eq!(
7405                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7406                "secret"
7407            );
7408
7409            drop(external);
7410            state.config.graph.timeout_resource_wait = 2;
7411            restore_landing_worktree(&mut state).await.unwrap();
7412            assert!(git::is_clean(&state.repo).await.unwrap());
7413            assert!(state.land_rebase_from.is_none());
7414            assert_eq!(
7415                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7416                "restored"
7417            );
7418        }
7419    }
7420
7421    #[tokio::test]
7422    #[cfg(unix)]
7423    async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7424        use std::sync::atomic::{AtomicBool, Ordering};
7425
7426        let (_tmp, mut state, _) = landing_git_fixture().await;
7427        advance_landing_base(&state).await;
7428        landing_setup(&mut state).await;
7429        interrupt_landing_rebase(&mut state).await;
7430        let common = crate::repo_lock::common_dir(&state.repo)
7431            .await
7432            .unwrap()
7433            .unwrap();
7434        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7435        state.config.coordination = crate::config::test_coordination();
7436        let holder = crate::repo_lock::SeatLease::for_checkout(
7437            &state.repo,
7438            true,
7439            state.config.coordination.as_ref(),
7440        )
7441        .await
7442        .unwrap();
7443        assert!(holder.is_some());
7444        let cancelled = Arc::new(AtomicBool::new(false));
7445        let signal = cancelled.clone();
7446        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7447            signal.load(Ordering::SeqCst)
7448        });
7449        let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7450        let checkpoint = state.land_rebase_from.clone();
7451        let (result, ()) = tokio::join!(
7452            tokio::time::timeout(
7453                Duration::from_secs(2),
7454                wait.scope(restore_landing_worktree(&mut state)),
7455            ),
7456            async {
7457                tokio::time::sleep(Duration::from_millis(100)).await;
7458                cancelled.store(true, Ordering::SeqCst);
7459            }
7460        );
7461        let error = result
7462            .expect("cancellation must release the waiting driver")
7463            .unwrap_err();
7464        assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7465        assert_eq!(
7466            git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7467            before
7468        );
7469        assert_eq!(state.land_rebase_from, checkpoint);
7470        assert_eq!(
7471            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7472            "secret"
7473        );
7474        drop(holder);
7475        restore_landing_worktree(&mut state).await.unwrap();
7476        assert!(state.land_rebase_from.is_none());
7477        assert!(git::is_clean(&state.repo).await.unwrap());
7478    }
7479
7480    #[tokio::test]
7481    #[cfg(unix)]
7482    async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
7483        use std::sync::atomic::{AtomicBool, Ordering};
7484
7485        for e2e in [false, true] {
7486            let (_tmp, mut state, head) = landing_git_fixture().await;
7487            let common = crate::repo_lock::common_dir(&state.repo)
7488                .await
7489                .unwrap()
7490                .unwrap();
7491            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7492            state.config.coordination = crate::config::test_coordination();
7493            if e2e {
7494                state.config.verify.e2e = vec!["true".into()];
7495                state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7496                    head: head.clone(),
7497                    passed: false,
7498                    outcomes: Vec::new(),
7499                });
7500            } else {
7501                state.config.verify.gate = vec!["true".into()];
7502                state.gate_ran = false;
7503            }
7504            let holder = crate::repo_lock::SeatLease::for_checkout(
7505                &state.repo,
7506                true,
7507                state.config.coordination.as_ref(),
7508            )
7509            .await
7510            .unwrap();
7511            assert!(holder.is_some());
7512            let cancelled = Arc::new(AtomicBool::new(false));
7513            let signal = cancelled.clone();
7514            let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7515                signal.load(Ordering::SeqCst)
7516            });
7517            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7518            let url = green_pr().url;
7519            let (result, ()) = tokio::join!(
7520                tokio::time::timeout(
7521                    Duration::from_secs(2),
7522                    wait.scope(publish_landing_head(&mut state, &head, &url, &forge)),
7523                ),
7524                async {
7525                    tokio::time::sleep(Duration::from_millis(100)).await;
7526                    cancelled.store(true, Ordering::SeqCst);
7527                }
7528            );
7529            let error = result
7530                .expect("cancellation must release the verification waiter")
7531                .unwrap_err();
7532            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7533            assert!(forge.argvs().is_empty());
7534            assert_eq!(
7535                git::remote_head(&state.repo, "origin", "task-head")
7536                    .await
7537                    .unwrap(),
7538                Some(head.clone())
7539            );
7540            drop(holder);
7541            // Cancellation must release its reservation so another attempt can verify.
7542            assert_eq!(
7543                publish_landing_head(&mut state, &head, &url, &forge)
7544                    .await
7545                    .unwrap(),
7546                head
7547            );
7548        }
7549    }
7550
7551    #[tokio::test]
7552    async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
7553        for staged in [false, true] {
7554            let (_tmp, mut state, old) = landing_git_fixture().await;
7555            advance_landing_base(&state).await;
7556            interrupt_landing_rebase(&mut state).await;
7557            std::fs::write(state.repo.join("change"), "user edit").unwrap();
7558            if staged {
7559                git::git(&state.repo, &["add", "change"]).await.unwrap();
7560            }
7561            assert!(resume_observed_head(&mut state, &old).await.is_err());
7562            assert_eq!(
7563                std::fs::read_to_string(state.repo.join("change")).unwrap(),
7564                "user edit"
7565            );
7566            assert_eq!(
7567                git::remote_head(&state.repo, "origin", "task-head")
7568                    .await
7569                    .unwrap(),
7570                Some(old)
7571            );
7572        }
7573    }
7574
7575    #[tokio::test]
7576    async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
7577        let (_tmp, mut state, head) = landing_git_fixture().await;
7578        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7579        let id = state.id.clone();
7580        let pr = land_with(&mut state, &green_pr().url, &forge)
7581            .await
7582            .unwrap();
7583        assert_eq!(pr.state, PrLifecycle::Open);
7584        assert_eq!(state.id, id);
7585        assert!(state.parked);
7586        assert_eq!(state.status, RunStatus::Landing);
7587        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7588        assert!(forge.argvs().is_empty());
7589
7590        state.land_sync_pending = true;
7591        std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
7592        let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
7593        closed.pr.state = PrLifecycle::Closed;
7594        let forge = Scripted::new(vec![closed], vec![]);
7595        assert_eq!(
7596            land_with(&mut state, &green_pr().url, &forge)
7597                .await
7598                .unwrap()
7599                .state,
7600            PrLifecycle::Closed
7601        );
7602        assert_eq!(
7603            std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
7604            "do not touch"
7605        );
7606        assert!(forge.argvs().is_empty());
7607    }
7608
7609    #[tokio::test]
7610    async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
7611        let (_tmp, mut state, old) = landing_git_fixture().await;
7612        advance_landing_base(&state).await;
7613        state.config.verify.gate = vec!["exit 1".into()];
7614        assert!(resume_observed_head(&mut state, &old).await.is_err());
7615        assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
7616        assert!(!state.gate_allows_progress());
7617        assert_eq!(
7618            git::remote_head(&state.repo, "origin", "task-head")
7619                .await
7620                .unwrap(),
7621            Some(old)
7622        );
7623        assert!(state.land_sync_pending);
7624    }
7625
7626    #[tokio::test]
7627    async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
7628        let (_tmp, mut state, old) = landing_git_fixture().await;
7629        advance_landing_base(&state).await;
7630        let pushed = resume_observed_head(&mut state, &old).await.unwrap();
7631        // Model a crash after the push but before saving its published SHA.
7632        state.pr_pushed_head = Some(old.clone());
7633        state.land_sync_pending = true;
7634        assert_eq!(
7635            resume_observed_head(&mut state, &pushed).await.unwrap(),
7636            pushed
7637        );
7638        // A writer races the observed PR snapshot: the explicit remote lease
7639        // must reject the push even though preflight saw our expected head.
7640        git::git(
7641            &state.repo,
7642            &["push", "--force", "origin", "main:refs/heads/task-head"],
7643        )
7644        .await
7645        .unwrap();
7646        assert!(
7647            publish_landing_head(
7648                &mut state,
7649                &pushed,
7650                &green_pr().url,
7651                &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
7652            )
7653            .await
7654            .unwrap_err()
7655            .to_string()
7656            .contains("publishing gated")
7657        );
7658        assert!(
7659            resume_observed_head(&mut state, "foreign-head")
7660                .await
7661                .is_err()
7662        );
7663    }
7664    #[tokio::test]
7665    async fn landing_publication_refuses_a_pr_closed_during_gate() {
7666        let (_tmp, mut state, head) = landing_git_fixture().await;
7667        let mut closed = seen(&head, Checks::Green, "CLEAN", false);
7668        closed.pr.state = PrLifecycle::Closed;
7669        let forge = Scripted::new(vec![closed], vec![]);
7670        assert!(
7671            publish_landing_head(&mut state, &head, &green_pr().url, &forge)
7672                .await
7673                .unwrap_err()
7674                .to_string()
7675                .contains("PR closed or merged")
7676        );
7677    }
7678
7679    /// A candidate selected by the uncontested tally.
7680    fn winning_candidate(summary: &str) -> Candidate {
7681        Candidate {
7682            index: 0,
7683            label: 'A',
7684            agent: "opus".to_owned(),
7685            branch: "magi/x/A".to_owned(),
7686            worktree: PathBuf::from("/wt/A"),
7687            summary: summary.to_owned(),
7688            stat: String::new(),
7689            files: 1,
7690            commits: 1,
7691            empty: false,
7692            failed: None,
7693            verified_noop: None,
7694            duration_ms: 0,
7695            folded: false,
7696        }
7697    }
7698
7699    fn uncontested_tally() -> Tally {
7700        Tally {
7701            first_choice: BTreeMap::from([('A', 1)]),
7702            borda: BTreeMap::new(),
7703            winner: 'A',
7704            rankings: 1,
7705            unanimous_initial: true,
7706            deliberated: false,
7707            changed_votes: 0,
7708            unanimous_final: true,
7709            tie_break: None,
7710            judges: 1,
7711            present: 1,
7712            quorum: 1,
7713            met_quorum: true,
7714            uncontested: None,
7715        }
7716    }
7717
7718    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
7719        ReviewRecord {
7720            attempts: 0,
7721            reviewer,
7722            agent: agent.to_owned(),
7723            summary: summary.to_owned(),
7724            findings: Vec::new(),
7725            vote: None,
7726            failed: None,
7727            duration_ms: 0,
7728        }
7729    }
7730
7731    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
7732        let answered = reviews.len();
7733        ReviewRound {
7734            round,
7735            head: "abc1234".to_owned(),
7736            verified_head: None,
7737            verified_at: None,
7738            reviews,
7739            e2e: Vec::new(),
7740            verify_retried: false,
7741            e2e_deferred: false,
7742            e2e_defer_reason: None,
7743            fix: None,
7744            blocking: 0,
7745            answered,
7746            expected: answered,
7747            clean: true,
7748            progressed: false,
7749            vote_split: false,
7750            reconsideration: Vec::new(),
7751            verdict: None,
7752        }
7753    }
7754
7755    #[test]
7756    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
7757        let en = panel();
7758        assert!(en.contains("Task"), "{en}");
7759        assert!(en.contains("add retries to the uploader"), "{en}");
7760
7761        let mut state = run_state();
7762        state.config.graph.language = "ja".to_owned();
7763        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7764        assert!(ja.contains("タスク"), "{ja}");
7765        assert!(
7766            ja.contains("add retries to the uploader"),
7767            "the task itself is not translated: {ja}"
7768        );
7769    }
7770
7771    #[test]
7772    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
7773        // `run_state()` has no candidates, no tally and no reviews - exactly
7774        // the shape a run has before anything has judged or reviewed it, and
7775        // the panel must not print an empty box for either.
7776        let html = panel();
7777        assert!(!html.contains("What changed"), "{html}");
7778        assert!(!html.contains("Review verdict"), "{html}");
7779    }
7780
7781    #[test]
7782    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
7783        let mut state = run_state();
7784        state.candidates = vec![winning_candidate("")];
7785        state.tally = Some(uncontested_tally());
7786        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7787        assert!(
7788            !html.contains("What changed"),
7789            "an empty summary must not render an empty box: {html}"
7790        );
7791    }
7792
7793    #[test]
7794    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
7795        let mut state = run_state();
7796        state.candidates = vec![winning_candidate(
7797            "Added a retry loop around the uploader PUT call.",
7798        )];
7799        state.tally = Some(uncontested_tally());
7800        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7801        assert!(en.contains("What changed"), "{en}");
7802        assert!(
7803            en.contains("Added a retry loop around the uploader PUT call."),
7804            "{en}"
7805        );
7806
7807        state.config.graph.language = "ja".to_owned();
7808        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7809        assert!(ja.contains("変更内容"), "{ja}");
7810        assert!(
7811            ja.contains("Added a retry loop around the uploader PUT call."),
7812            "{ja}"
7813        );
7814    }
7815
7816    #[test]
7817    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
7818        let mut state = run_state();
7819        state.reviews = vec![
7820            review_round(
7821                1,
7822                vec![review_record(1, "alpha", "found a race, sent back")],
7823            ),
7824            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
7825        ];
7826        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7827        assert!(en.contains("Review verdict"), "{en}");
7828        assert!(en.contains("race is fixed, clean"), "{en}");
7829        assert!(
7830            !en.contains("found a race, sent back"),
7831            "only the round that actually cleared the merge should show: {en}"
7832        );
7833
7834        state.config.graph.language = "ja".to_owned();
7835        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7836        assert!(ja.contains("レビューの結論"), "{ja}");
7837        assert!(ja.contains("レビュアー"), "{ja}");
7838        assert!(ja.contains("race is fixed, clean"), "{ja}");
7839    }
7840
7841    /// The `incomplete_review = "warn"` policy (see
7842    /// `graph::Runner::review_loop`) can push a `clean` round to
7843    /// `state.reviews` while one seat's own record still has `failed: Some`
7844    /// and an empty `summary` - a seat that never answered, not one that
7845    /// answered with nothing to say.
7846    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
7847        ReviewRecord {
7848            attempts: 0,
7849            reviewer,
7850            agent: agent.to_owned(),
7851            summary: String::new(),
7852            findings: Vec::new(),
7853            vote: None,
7854            failed: Some(reason.to_owned()),
7855            duration_ms: 0,
7856        }
7857    }
7858
7859    #[test]
7860    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
7861        let mut state = run_state();
7862        state.reviews = vec![review_round(
7863            1,
7864            vec![
7865                review_record(1, "alpha", "clean, nothing to add"),
7866                unanswered_review_record(2, "beta", "timed out"),
7867            ],
7868        )];
7869        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7870        assert!(en.contains("clean, nothing to add"), "{en}");
7871        assert!(
7872            en.contains("produced no answer: timed out"),
7873            "a seat that never answered must say so, not render a blank box: {en}"
7874        );
7875        assert!(
7876            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
7877            "no reviewer box may be left empty: {en}"
7878        );
7879
7880        state.config.graph.language = "ja".to_owned();
7881        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7882        assert!(ja.contains("回答なし: timed out"), "{ja}");
7883    }
7884
7885    #[test]
7886    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
7887        let html = panel();
7888        assert!(!html.contains("<script"), "no script survives the csp");
7889        assert!(!html.contains("<form"), "form-action is 'none'");
7890        let pr = green_pr();
7891        assert_eq!(
7892            html.matches("http").count(),
7893            html.matches(pr.url.as_str()).count(),
7894            "the only http url in the panel is the pull request's own link"
7895        );
7896        assert!(
7897            html.contains("target=\"_blank\""),
7898            "the PR link must open in a new tab so it escapes the iframe"
7899        );
7900        assert!(
7901            html.contains("rel=\"noopener noreferrer\""),
7902            "external links opening in new tabs must carry noopener noreferrer"
7903        );
7904    }
7905
7906    #[test]
7907    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
7908        let html = panel();
7909        assert!(
7910            html.contains(">+</span>"),
7911            "an added line carries a `+` in the gutter, not only a background"
7912        );
7913        assert!(
7914            html.contains(">-</span>"),
7915            "a removed line carries a `-` in the gutter, not only a background"
7916        );
7917        assert!(
7918            html.contains(">new line</span>"),
7919            "the marker is moved to the gutter, so the body is printed once without it"
7920        );
7921    }
7922
7923    #[test]
7924    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
7925        let total = DIFF_MAX_LINES + 100;
7926        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
7927        let html = approval_panel(
7928            &run_state(),
7929            &green_pr(),
7930            NUMSTAT,
7931            &diff,
7932            &[],
7933            "feat: something long",
7934        );
7935        assert!(
7936            html.contains(&format!("100 of {total} diff lines omitted")),
7937            "the note must say exactly how much was cut"
7938        );
7939        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
7940        assert!(
7941            !html.contains(&format!("line {DIFF_MAX_LINES}")),
7942            "nothing past the threshold is rendered"
7943        );
7944        assert!(
7945            html.contains("/repo/magi"),
7946            "the note says where the rest is"
7947        );
7948    }
7949
7950    #[test]
7951    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
7952        let html = approval_panel(
7953            &run_state(),
7954            &green_pr(),
7955            "1\t2\tsrc/<b>&\"x\"'.rs",
7956            "",
7957            &[],
7958            "subject",
7959        );
7960        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
7961        assert!(
7962            !html.contains("<b>"),
7963            "an agent-influenced path must never become markup"
7964        );
7965    }
7966
7967    #[tokio::test]
7968    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
7969        let a = std::path::PathBuf::from("/repo/a");
7970        let b = std::path::PathBuf::from("/repo/b");
7971
7972        let held = repo_merge_lock(&a).lock_owned().await;
7973
7974        // A second, concurrent land run against the *same* repository must
7975        // wait - `try_lock` fails while `held` is alive.
7976        assert!(
7977            repo_merge_lock(&a).try_lock().is_err(),
7978            "a second merge into the same repository must not proceed concurrently"
7979        );
7980
7981        // A run against a *different* repository must not be blocked by it -
7982        // this is what keeps a slow rebase or `gh pr merge` in one
7983        // repository from also stalling a land-approval resume in another.
7984        assert!(
7985            repo_merge_lock(&b).try_lock().is_ok(),
7986            "a different repository's merge lock must be independent"
7987        );
7988
7989        drop(held);
7990        assert!(
7991            repo_merge_lock(&a).try_lock().is_ok(),
7992            "the lock is released once the holder is done"
7993        );
7994    }
7995
7996    pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
7997        let mut state = landing_state();
7998        let mut rec = review_record(1, "alpha", "precise rejection reason");
7999        rec.vote = Some(if reject {
8000            crate::verdict::ReviewVote::Reject
8001        } else {
8002            crate::verdict::ReviewVote::Approve
8003        });
8004        if major {
8005            rec.findings = contested().findings;
8006        }
8007        let mut round = review_round(1, vec![rec]);
8008        round.clean = false;
8009        round.blocking = usize::from(major);
8010        state.reviews.push(round);
8011        state.config.graph.review_rounds = 1;
8012        state
8013    }
8014
8015    #[test]
8016    fn approval_policy_controls_are_independent_and_keep_all_reasons() {
8017        for every in [false, true] {
8018            for contested in [false, true] {
8019                for major in [false, true] {
8020                    for reject in [false, true] {
8021                        let mut state = repair_state(major, reject);
8022                        state.config.graph.land_approval = every;
8023                        state.config.graph.hold_contested_merge = contested;
8024                        let reasons = approval_reasons(&state);
8025                        assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
8026                        assert_eq!(
8027                            reasons.contains(&ApprovalReason::OpenBlockingFindings),
8028                            contested && major
8029                        );
8030                        assert_eq!(
8031                            reasons.contains(&ApprovalReason::FinalReject),
8032                            contested && reject
8033                        );
8034                        assert_eq!(
8035                            reasons.len(),
8036                            usize::from(every)
8037                                + usize::from(contested && major)
8038                                + usize::from(contested && reject)
8039                        );
8040                    }
8041                }
8042            }
8043        }
8044    }
8045
8046    #[test]
8047    fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8048        use crate::verdict::ReviewVote::{Approve, Reject};
8049        for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8050            let mut state = repair_state(false, initial_reject);
8051            state.reviews[0]
8052                .reconsideration
8053                .push(crate::run::ReviewRevoteRecord {
8054                    reviewer: 1,
8055                    agent: "alpha".into(),
8056                    vote: Some(final_vote),
8057                    reason: "changed after reading the panel <evidence>".into(),
8058                    failed: None,
8059                });
8060            assert_eq!(
8061                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8062                final_vote == Reject
8063            );
8064            for language in ["en", "ja"] {
8065                state.config.graph.language = language.into();
8066                let evidence = approval_evidence(&state);
8067                assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8068                assert!(evidence.contains("changed after reading the panel <evidence>"));
8069                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8070                assert!(html.contains("changed after reading the panel &lt;evidence&gt;"));
8071                assert!(!html.contains("<evidence>"));
8072            }
8073            state.reviews[0].reconsideration[0].vote = None;
8074            state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8075            assert_eq!(
8076                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8077                initial_reject
8078            );
8079            assert!(approval_evidence(&state).contains("timed out"));
8080        }
8081    }
8082
8083    #[tokio::test]
8084    async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8085        for every in [false, true] {
8086            for contested in [false, true] {
8087                for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8088                {
8089                    let mut state = repair_state(major, reject);
8090                    state.config.graph.land_approval = every;
8091                    state.config.graph.hold_contested_merge = contested;
8092                    let forge = Scripted::new(
8093                        vec![seen("abc", Checks::Green, "CLEAN", false)],
8094                        vec![(true, "")],
8095                    );
8096                    land_with(&mut state, URL, &forge).await.unwrap();
8097                    let expected = every || (contested && (major || reject));
8098                    assert_eq!(state.land_approval.is_some(), expected);
8099                    assert_eq!(forge.calls().contains(&"merge"), !expected);
8100                    if expected {
8101                        assert!(state.parked);
8102                    }
8103                }
8104            }
8105        }
8106    }
8107
8108    #[tokio::test]
8109    async fn reviewed_branch_hold_is_read_before_landing_decision() {
8110        for (base, control) in [
8111            (false, "land_approval"),
8112            (true, "land_approval"),
8113            (false, "hold_contested_merge"),
8114        ] {
8115            let (_tmp, mut state, _) = landing_git_fixture().await;
8116            state.config.graph.land_approval = base;
8117            state.config.graph.hold_contested_merge = false;
8118            state.reviewed_commits = Some(Vec::new());
8119            if control == "hold_contested_merge" {
8120                state.reviews = repair_state(false, true).reviews;
8121            }
8122            std::fs::write(
8123                state.repo.join("magi.toml"),
8124                format!("[graph]\n{control} = {}\n", !base),
8125            )
8126            .unwrap();
8127            git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8128            git::git(&state.repo, &["commit", "-m", "approval policy"])
8129                .await
8130                .unwrap();
8131            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8132            state.land_sync_pending = false;
8133            state.pr_pushed_head = Some(head.clone());
8134            state.gate_head = Some(head.clone());
8135            let forge = Scripted::new(
8136                vec![seen(&head, Checks::Green, "CLEAN", false)],
8137                vec![(true, "")],
8138            );
8139            land_with(&mut state, URL, &forge).await.unwrap();
8140            assert!(
8141                state.land_approval.is_some(),
8142                "base={base}, control={control}"
8143            );
8144            assert!(!forge.calls().contains(&"merge"));
8145            if !base {
8146                let question = ask::Questions::open()
8147                    .get(&state.land_approval.as_ref().unwrap().question)
8148                    .unwrap();
8149                assert!(question.detail.contains(&format!(
8150                    "{control}=true from the reviewed branch's magi.toml"
8151                )));
8152            }
8153        }
8154    }
8155
8156    #[tokio::test]
8157    async fn reviewed_branch_hold_asks_and_survives_removal_and_resume() {
8158        let (_tmp, mut state, _) = landing_git_fixture().await;
8159        state.config.graph.land_approval = false;
8160        state.config.graph.hold_contested_merge = false;
8161        state.reviewed_commits = Some(Vec::new());
8162        std::fs::write(
8163            state.repo.join("magi.toml"),
8164            "[graph]\nland_approval = true\n[verify]\ngate = ['untrusted']\n",
8165        )
8166        .unwrap();
8167        git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8168        git::git(&state.repo, &["commit", "-m", "require approval"])
8169            .await
8170            .unwrap();
8171        let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8172        let gate = state.config.verify.gate.clone();
8173        state.observe_branch_owner_holds(&head).await;
8174        assert_eq!(state.config.verify.gate, gate);
8175        assert_eq!(approval_reasons(&state), [ApprovalReason::EveryMerge]);
8176        git::git(&state.repo, &["rm", "magi.toml"]).await.unwrap();
8177        git::git(&state.repo, &["commit", "-m", "remove config"])
8178            .await
8179            .unwrap();
8180        let later = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8181        state = serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
8182        state.observe_branch_owner_holds(&later).await;
8183        assert_eq!(state.branch_owner_holds[0].head, head);
8184        state.land_sync_pending = false;
8185        state.pr_pushed_head = Some(later.clone());
8186        state.gate_head = Some(later.clone());
8187        let forge = Scripted::new(
8188            vec![seen(&later, Checks::Green, "CLEAN", false)],
8189            vec![(true, "")],
8190        );
8191        land_with(&mut state, URL, &forge).await.unwrap();
8192        assert!(!forge.calls().contains(&"merge"));
8193        let question = ask::Questions::open()
8194            .get(&state.land_approval.as_ref().unwrap().question)
8195            .unwrap();
8196        assert!(question.detail.contains("Approval required because:"));
8197        assert!(
8198            question
8199                .detail
8200                .contains("land_approval=true from the reviewed branch's magi.toml")
8201        );
8202    }
8203
8204    #[tokio::test]
8205    async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8206        let mut state = repair_state(false, true);
8207        state.reviews[0].clean = true;
8208        let mut finding = contested().findings.remove(0);
8209        finding.severity = crate::verdict::Severity::Minor;
8210        state.reviews[0].reviews[0].findings.push(finding);
8211        assert!(state.open_findings().is_empty());
8212        let mut pr = green_pr();
8213        pr.review_comments.clear();
8214        for language in ["en", "ja"] {
8215            state.config.graph.language = language.into();
8216            let html = approval_panel(&state, &pr, "", "", &[], "subject");
8217            assert!(html.contains("[R3-1-1] minor"));
8218            assert!(html.contains(if language == "ja" {
8219                "内部指摘の解決を意味するものではありません"
8220            } else {
8221                "This does not mean internal findings are resolved"
8222            }));
8223            assert!(
8224                html.find(if language == "ja" {
8225                    "承認が必要な理由:"
8226                } else {
8227                    "Approval required because:"
8228                })
8229                .unwrap()
8230                    < html.find("[R3-1-1]").unwrap()
8231            );
8232        }
8233        state.config.graph.language = "en".into();
8234        let observed = seen("abc", Checks::Green, "CLEAN", false);
8235        assert_eq!(
8236            approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8237                .await
8238                .unwrap(),
8239            ApprovalGate::Pending
8240        );
8241        let id = state.land_approval.as_ref().unwrap().question.clone();
8242        let q = ask::Questions::open().get(&id).unwrap();
8243        assert!(q.detail.starts_with("Approval required because:"));
8244        assert!(q.detail.contains("final reviewer reject"));
8245        assert!(q.detail.contains("[R3-1-1] minor"));
8246        assert!(
8247            q.detail
8248                .contains("No adoption or resolution evidence recorded")
8249        );
8250        state.config.graph.land_approval = false;
8251        state.config.graph.hold_contested_merge = false;
8252        state.reviews.clear();
8253        assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8254        assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8255        assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8256        let forge = Scripted::new(vec![observed], vec![]);
8257        land_with(&mut state, URL, &forge).await.unwrap();
8258        assert!(!forge.calls().contains(&"merge"));
8259        assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8260        assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8261    }
8262
8263    #[tokio::test]
8264    async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8265        let mut state = repair_state(false, true);
8266        state.config.graph.land_approval = false;
8267        let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8268        let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8269        let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8270        state.reviews[0].reviews[0].summary = long_reason.clone();
8271        for i in 1..=10 {
8272            let mut finding = contested().findings.remove(0);
8273            finding.id = format!("R1-1-{i}");
8274            finding.severity = if i == 9 {
8275                crate::verdict::Severity::Major
8276            } else {
8277                crate::verdict::Severity::Minor
8278            };
8279            finding.title = long_title.clone();
8280            finding.detail = long_detail.clone();
8281            state.reviews[0].reviews[0].findings.push(finding);
8282        }
8283        state.reviews[0].fix = Some(crate::run::FixRecord {
8284            agent: "fixer".into(),
8285            addressed: vec!["R1-1-9".into()],
8286            rejected: vec![crate::verdict::Rejection {
8287                id: "R1-1-10".into(),
8288                why: long_reason.clone(),
8289            }],
8290            notes: long_detail.clone(),
8291            committed: false,
8292            failed: None,
8293            duration_ms: 0,
8294            continuation: None,
8295        });
8296        assert_eq!(
8297            approval_reasons(&state),
8298            [
8299                ApprovalReason::OpenBlockingFindings,
8300                ApprovalReason::FinalReject
8301            ]
8302        );
8303        let excerpt = approval_excerpt(&long_reason, 300);
8304        assert_eq!(excerpt.chars().count(), 300);
8305        assert!(excerpt.ends_with('…'));
8306        let boundary = "拒".repeat(300);
8307        assert_eq!(approval_excerpt(&boundary, 300), boundary);
8308        for language in ["en", "ja"] {
8309            state.config.graph.language = language.into();
8310            for revote in [
8311                None,
8312                Some(crate::verdict::ReviewVote::Reject),
8313                Some(crate::verdict::ReviewVote::Approve),
8314            ] {
8315                state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8316                state.reviews[0].reconsideration.clear();
8317                if let Some(vote) = revote {
8318                    state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8319                    state.reviews[0]
8320                        .reconsideration
8321                        .push(crate::run::ReviewRevoteRecord {
8322                            reviewer: 1,
8323                            agent: "alpha".into(),
8324                            vote: Some(vote),
8325                            reason: long_reason.clone(),
8326                            failed: None,
8327                        });
8328                }
8329                let evidence = approval_evidence(&state);
8330                let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8331                assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8332                assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8333                assert_eq!(
8334                    approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8335                    final_vote == crate::verdict::ReviewVote::Reject
8336                );
8337                assert!(evidence.contains(&excerpt));
8338                assert!(evidence.contains("[R1-1-8] minor"));
8339                assert!(evidence.contains("[R1-1-9] major"));
8340                assert!(evidence.contains("[R1-1-10] minor"));
8341                for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8342                    assert!(!evidence.contains(tail));
8343                }
8344                assert!(evidence.contains(if language == "ja" {
8345                    "採用申告(解決未確認)"
8346                } else {
8347                    "claimed addressed (resolution unconfirmed)"
8348                }));
8349                assert!(evidence.contains(if language == "ja" {
8350                    "不採用理由"
8351                } else {
8352                    "declined"
8353                }));
8354                let major_evidence = evidence
8355                    .split("[R1-1-9] major")
8356                    .nth(1)
8357                    .unwrap()
8358                    .split("[R1-1-10]")
8359                    .next()
8360                    .unwrap();
8361                assert!(major_evidence.contains(if language == "ja" {
8362                    "採用申告(解決未確認)"
8363                } else {
8364                    "claimed addressed (resolution unconfirmed)"
8365                }));
8366                let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8367                assert!(last_evidence.contains(if language == "ja" {
8368                    "不採用理由"
8369                } else {
8370                    "declined"
8371                }));
8372                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8373                assert!(html.contains(&esc(&evidence)));
8374                assert_eq!(
8375                    approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8376                        .await
8377                        .unwrap(),
8378                    ApprovalGate::Pending
8379                );
8380                let id = &state.land_approval.take().unwrap().question;
8381                let q = ask::Questions::open().get(id).unwrap();
8382                assert!(q.detail.starts_with(&evidence));
8383            }
8384            state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8385        }
8386    }
8387
8388    #[test]
8389    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8390        for (major, reject) in [(true, false), (false, true), (true, true)] {
8391            let state = repair_state(major, reject);
8392            assert!(repair_available(&state));
8393            assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8394        }
8395        assert!(!repair_available(&repair_state(false, false)));
8396        let mut clean = repair_state(true, false);
8397        clean.reviews[0].clean = true;
8398        assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8399        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8400    }
8401
8402    #[tokio::test]
8403    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8404        let mut state = repair_state(true, false);
8405        let pr = green_pr();
8406        approval_gate(&mut state, &pr, "feat: x", None, "abc")
8407            .await
8408            .unwrap();
8409        let store = ask::Questions::open();
8410        let id = state.land_approval.as_ref().unwrap().question.clone();
8411        // Simulate an unanswered two-choice question created by an older build.
8412        store
8413            .update(&id, |q| {
8414                q.choices = vec![APPROVE.into(), HOLD.into()];
8415                Ok(())
8416            })
8417            .unwrap();
8418        let upgraded = store.get(&id).unwrap();
8419        assert_eq!(upgraded.choices, approval_choices(&state));
8420        assert!(!upgraded.actions.contains_key(HOLD));
8421        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
8422        assert!(
8423            store
8424                .list()
8425                .iter()
8426                .any(|q| q.id == id && q.choices == upgraded.choices)
8427        );
8428        assert!(
8429            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
8430        );
8431        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
8432        store
8433            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
8434            .unwrap();
8435        // An earlier build may have recorded the repair answer and stopped
8436        // before any driver consumed it. Reconcile that durable decision too.
8437        store
8438            .update(&id, |q| {
8439                q.actions.clear();
8440                Ok(())
8441            })
8442            .unwrap();
8443        assert_eq!(
8444            store.get(&id).unwrap().chosen_action(),
8445            Some(&ask::ChoiceAction::Resume {
8446                run: state.id.clone()
8447            })
8448        );
8449        let binding = state.land_approval.clone();
8450        assert_eq!(
8451            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8452                .await
8453                .unwrap(),
8454            ApprovalGate::Repair
8455        );
8456        assert!(state.land_approval.is_none());
8457        assert_eq!(state.merge_repairs.len(), 1);
8458        let request = &state.merge_repairs[0];
8459        assert_eq!(request.question, id);
8460        assert_eq!(request.head, "abc");
8461        assert_eq!(request.pr, pr.url);
8462        assert_eq!(
8463            request.reviews[0].reviews[0].summary,
8464            "precise rejection reason"
8465        );
8466        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
8467        assert_eq!(
8468            request.reviews[0].reviews[0].findings[0].detail,
8469            state.reviews[0].reviews[0].findings[0].detail
8470        );
8471        // A repeated consumption of the same answer never dispatches another repair.
8472        state.land_approval = binding;
8473        assert_eq!(
8474            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8475                .await
8476                .unwrap(),
8477            ApprovalGate::Repair
8478        );
8479        assert_eq!(state.merge_repairs.len(), 1);
8480        state.land_approval = None;
8481        state.config.graph.land_approval = false;
8482        state.config.graph.hold_contested_merge = false;
8483        state.reviews.clear();
8484        assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
8485        assert_eq!(
8486            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8487                .await
8488                .unwrap(),
8489            ApprovalGate::Pending
8490        );
8491        let fresh_id = &state.land_approval.as_ref().unwrap().question;
8492        assert_ne!(fresh_id, &id);
8493        let fresh = store.get(fresh_id).unwrap();
8494        assert!(
8495            fresh
8496                .detail
8497                .contains("fresh owner approval after merge repair")
8498        );
8499        assert!(fresh.detail.contains("No review recorded"));
8500        assert!(!fresh.detail.contains("precise rejection reason"));
8501    }
8502
8503    #[tokio::test]
8504    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
8505        for choice in [REPAIR, HOLD] {
8506            let mut state = repair_state(false, true);
8507            let observed = seen("abc", Checks::Green, "CLEAN", false);
8508            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
8509                .await
8510                .unwrap();
8511            let id = state.land_approval.as_ref().unwrap().question.clone();
8512            ask::Questions::open()
8513                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
8514                .unwrap();
8515            let forge = Scripted::new(vec![observed], vec![(true, "")]);
8516            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8517                .await
8518                .unwrap();
8519            assert!(
8520                forge
8521                    .argvs()
8522                    .iter()
8523                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
8524            );
8525            assert!(!forge.calls().contains(&"fix"));
8526            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
8527            assert_ne!(state.status, RunStatus::Merged);
8528        }
8529    }
8530
8531    #[test]
8532    fn only_the_merge_choice_merges_and_silence_holds() {
8533        let table = [
8534            (None, Approval::Hold),
8535            (Some("merge"), Approval::Merge),
8536            (Some(" merge\n"), Approval::Merge),
8537            (Some("hold"), Approval::Hold),
8538            (Some(""), Approval::Hold),
8539            (Some("yes"), Approval::Hold),
8540        ];
8541        for (answer, want) in table {
8542            assert_eq!(
8543                approval(answer),
8544                want,
8545                "answer {answer:?} must resolve to {want:?}"
8546            );
8547        }
8548    }
8549
8550    #[tokio::test]
8551    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
8552        let mut state = landing_state();
8553        state.config.graph.land_approval = true;
8554        let pr = green_pr();
8555
8556        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8557            .await
8558            .unwrap();
8559        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
8560        assert!(
8561            !state.parked,
8562            "approval_gate itself never sets `parked`; only its caller does"
8563        );
8564
8565        let store = ask::Questions::open();
8566        let filed: Vec<_> = store
8567            .list()
8568            .into_iter()
8569            .filter(|q| q.run == state.id)
8570            .collect();
8571        assert_eq!(filed.len(), 1, "exactly one question is filed");
8572        assert_eq!(filed[0].node, APPROVAL_NODE);
8573        assert_eq!(filed[0].choices, approval_choices(&state));
8574        assert!(filed[0].status.open());
8575
8576        // A second visit - standing in for a resumed run whose slot the
8577        // daemon handed to something else while nobody had answered - must
8578        // find the same question rather than filing a second one.
8579        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8580            .await
8581            .unwrap();
8582        assert_eq!(again, ApprovalGate::Pending);
8583        let still_one = store
8584            .list()
8585            .into_iter()
8586            .filter(|q| q.run == state.id)
8587            .count();
8588        assert_eq!(
8589            still_one, 1,
8590            "asking twice must not double-file the question"
8591        );
8592    }
8593
8594    #[tokio::test]
8595    async fn approving_the_existing_question_is_read_back_as_approved() {
8596        crate::run::pin_test_home();
8597        let mut state = run_state();
8598        state.config.graph.land_approval = true;
8599        let pr = green_pr();
8600        assert_eq!(
8601            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8602                .await
8603                .unwrap(),
8604            ApprovalGate::Pending
8605        );
8606
8607        let store = ask::Questions::open();
8608        let mut q = store
8609            .list()
8610            .into_iter()
8611            .find(|q| q.run == state.id)
8612            .expect("filed above");
8613        let talks = crate::talk::Talks::open();
8614        let cfg = crate::config::Config {
8615            agents: vec![crate::config::AgentSpec {
8616                id: "mock".into(),
8617                kind: crate::config::AgentKind::Command,
8618                model: None,
8619                command: vec!["true".into()],
8620                extra_args: Vec::new(),
8621                env: Default::default(),
8622                prompt_delivery: None,
8623                max_turn_secs: None,
8624            }],
8625            ..crate::config::Config::default()
8626        };
8627        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
8628        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
8629        crate::talk::drain(&mut talk, &talks).unwrap();
8630        assert_eq!(
8631            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8632                .await
8633                .unwrap(),
8634            ApprovalGate::Pending,
8635            "consultation cannot approve or hold the merge"
8636        );
8637        q = store.get(&q.id).unwrap();
8638        assert!(q.answer.is_none());
8639        talk.turns.push(crate::talk::Turn {
8640            sender: None,
8641            breaks: Some(Vec::new()),
8642            messages: Vec::new(),
8643            posted_by: None,
8644            who: crate::talk::Who::Operator,
8645            body: "merge abc".into(),
8646            at: Timestamp::now(),
8647            attachments: Vec::new(),
8648            usage: None,
8649        });
8650        talks.put(&mut talk).unwrap();
8651        crate::consult::validate_answer(
8652            &q,
8653            &talks,
8654            &talk.id,
8655            crate::queue::CHAT_NODE,
8656            APPROVE,
8657            Some("merge abc"),
8658        )
8659        .unwrap();
8660        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8661        store.put(&mut q).unwrap();
8662
8663        assert_eq!(
8664            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8665                .await
8666                .unwrap(),
8667            ApprovalGate::Approved
8668        );
8669    }
8670
8671    #[tokio::test]
8672    async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
8673        crate::run::pin_test_home();
8674        let store = ask::Questions::open();
8675        for via_list in [false, true] {
8676            let mut state = repair_state(false, true);
8677            let pr = green_pr();
8678            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8679                .await
8680                .unwrap();
8681            let id = state.land_approval.as_ref().unwrap().question.clone();
8682            store
8683                .update(&id, |q| {
8684                    q.actions.insert(
8685                        HOLD.to_owned(),
8686                        ask::ChoiceAction::Resume {
8687                            run: state.id.clone(),
8688                        },
8689                    );
8690                    Ok(())
8691                })
8692                .unwrap();
8693            let q = if via_list {
8694                store.list().into_iter().find(|q| q.id == id).unwrap()
8695            } else {
8696                store.get(&id).unwrap()
8697            };
8698            assert!(!q.actions.contains_key(HOLD));
8699            store
8700                .update(&id, |q| {
8701                    // Check the persisted migration, without another read upgrade.
8702                    assert!(!q.actions.contains_key(HOLD));
8703                    assert!(!upgrade_repair_choice(q, &state));
8704                    q.answer(ask::Answer::Choice(HOLD.to_owned()))
8705                })
8706                .unwrap();
8707            assert!(store.get(&id).unwrap().chosen_action().is_none());
8708            assert_eq!(
8709                approval_gate(&mut state, &pr, "feat: x", None, "abc")
8710                    .await
8711                    .unwrap(),
8712                ApprovalGate::Held
8713            );
8714        }
8715    }
8716
8717    #[tokio::test]
8718    async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
8719        crate::run::pin_test_home();
8720        let store = ask::Questions::open();
8721
8722        let mut held_state = run_state();
8723        held_state.config.graph.land_approval = true;
8724        let pr = green_pr();
8725        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
8726            .await
8727            .unwrap();
8728        let mut q = store
8729            .list()
8730            .into_iter()
8731            .find(|q| q.run == held_state.id)
8732            .expect("filed above");
8733        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
8734        store.put(&mut q).unwrap();
8735        assert_eq!(
8736            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
8737                .await
8738                .unwrap(),
8739            ApprovalGate::Held
8740        );
8741
8742        let mut abandoned_state = run_state();
8743        abandoned_state.config.graph.land_approval = true;
8744        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
8745            .await
8746            .unwrap();
8747        let mut q = store
8748            .list()
8749            .into_iter()
8750            .find(|q| q.run == abandoned_state.id)
8751            .expect("filed above");
8752        q.abandon("no answer within the timeout");
8753        store.put(&mut q).unwrap();
8754        assert_eq!(
8755            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
8756                .await
8757                .unwrap(),
8758            ApprovalGate::Pending,
8759            "an expired approval must ask again without merging"
8760        );
8761        assert_eq!(
8762            store.get(&q.id).unwrap().status,
8763            ask::QuestionStatus::Abandoned
8764        );
8765        assert_ne!(
8766            abandoned_state.land_approval.as_ref().unwrap().question,
8767            q.id
8768        );
8769    }
8770
8771    fn contested() -> ContestedHandoff {
8772        let finding = |id: &str, n: u32| crate::verdict::Finding {
8773            id: id.to_owned(),
8774            severity: crate::verdict::Severity::Major,
8775            file: Some("src/a.rs".to_owned()),
8776            line: Some(n),
8777            title: format!("problem {id}"),
8778            detail: String::new(),
8779        };
8780        ContestedHandoff {
8781            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
8782            rejecters: vec![(1, "alpha".to_owned())],
8783        }
8784    }
8785
8786    #[test]
8787    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
8788        let mut state = run_state();
8789        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
8790        state.contested_handoff = Some(contested());
8791        assert!(contested_to_ask(&state).is_some());
8792        state.config.graph.hold_contested_merge = false;
8793        assert!(
8794            contested_to_ask(&state).is_none(),
8795            "the switch restores today"
8796        );
8797    }
8798
8799    #[test]
8800    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
8801        let q = ask::Question::new(
8802            "run-1".to_owned(),
8803            APPROVAL_NODE.to_owned(),
8804            "land".to_owned(),
8805            "Merge?".to_owned(),
8806            String::new(),
8807            vec![APPROVE.to_owned(), HOLD.to_owned()],
8808        );
8809        let none = deputy_brief(&q, None);
8810        assert!(none.contains("could not be read"), "{none}");
8811        assert!(none.contains("Silence is a hold"), "{none}");
8812
8813        let mut state = run_state();
8814        state.pr = Some(crate::run::PrRecord {
8815            url: "https://example.test/pull/7".to_owned(),
8816            number: 7,
8817            state: "open".to_owned(),
8818            checks: "green".to_owned(),
8819            round: 0,
8820            rounds: 3,
8821            red_at_merge: Vec::new(),
8822        });
8823        state.contested_handoff = Some(contested());
8824        let b = deputy_brief(&q, Some(&state));
8825        assert!(b.contains("https://example.test/pull/7"), "{b}");
8826        assert!(b.contains("not recorded in this legacy question"), "{b}");
8827        assert!(!b.contains("This question was filed although"));
8828        state.contested_handoff = None;
8829        assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
8830    }
8831
8832    #[test]
8833    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
8834        for lang in ["en", "ja"] {
8835            let mut cfg = crate::config::Config::default();
8836            cfg.graph.language = lang.to_owned();
8837            let w = words(&cfg.graph.language);
8838            let text = w.approval_detail(
8839                "https://github.com/yukimemi/magi/pull/42",
8840                "main",
8841                "feat: x",
8842                Some(&contested()),
8843            );
8844            assert!(text.contains("pull/42"), "{text}");
8845            assert!(
8846                text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
8847                "{text}"
8848            );
8849            assert!(text.contains("R3-1-5"), "{text}");
8850            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
8851            assert!(text.contains("2"), "the rest are counted: {text}");
8852            assert!(text.contains("#1 (alpha)"), "{text}");
8853        }
8854        let plain = words("en").approval_detail("u", "main", "s", None);
8855        assert!(!plain.contains("reject"), "{plain}");
8856    }
8857
8858    #[tokio::test]
8859    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
8860        crate::run::pin_test_home();
8861        let mut state = run_state();
8862        state.config.graph.land_approval = false;
8863        state.contested_handoff = Some(contested());
8864        let pr = green_pr();
8865        let c = contested_to_ask(&state);
8866        assert_eq!(
8867            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8868                .await
8869                .unwrap(),
8870            ApprovalGate::Pending,
8871            "silence is a hold"
8872        );
8873        let store = ask::Questions::open();
8874        let filed: Vec<_> = store
8875            .list()
8876            .into_iter()
8877            .filter(|q| q.run == state.id)
8878            .collect();
8879        assert_eq!(filed.len(), 1);
8880        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
8881
8882        assert_eq!(
8883            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8884                .await
8885                .unwrap(),
8886            ApprovalGate::Pending
8887        );
8888        let mut q = store
8889            .list()
8890            .into_iter()
8891            .find(|q| q.run == state.id)
8892            .unwrap();
8893        assert_eq!(q.id, filed[0].id, "the same question after a resume");
8894        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8895        store.put(&mut q).unwrap();
8896        assert_eq!(
8897            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8898                .await
8899                .unwrap(),
8900            ApprovalGate::Approved
8901        );
8902    }
8903
8904    #[test]
8905    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
8906        let rows = parse_numstat(NUMSTAT);
8907        assert_eq!(
8908            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
8909            ["src/web.rs", "src/land.rs", "assets/logo.png"]
8910        );
8911        assert_eq!(rows[2].added, None, "a binary file has no line counts");
8912    }
8913    #[test]
8914    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
8915        // Reported from a real run: the merge question arrived in English on a
8916        // repository with `language = "ja"`. magi's own strings have to follow
8917        // that setting too - "it is a literal in Rust" is not an answer.
8918        let mut state = run_state();
8919        state.config.graph.language = "ja".to_owned();
8920        let pr = green_pr();
8921        let commits = ["c1".to_owned()];
8922
8923        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
8924        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
8925        assert!(ja.contains("squash されるコミット"), "{ja}");
8926        assert!(ja.contains("レビューコメント"), "{ja}");
8927        assert!(ja.contains("差分"), "{ja}");
8928        assert!(
8929            !ja.contains("Commits being squashed"),
8930            "no English left over"
8931        );
8932
8933        let w = words("ja");
8934        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
8935        assert!(
8936            w.approval_detail("http://x/1", "main", "feat: x", None)
8937                .contains("パネル")
8938        );
8939
8940        // The evidence itself is language-neutral and must survive either way.
8941        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
8942        assert!(ja.contains("feat: x"), "nor is the merge subject");
8943
8944        // English stays the default, and a language magi cannot check falls
8945        // back to it rather than shipping a guess.
8946        state.config.graph.language = "en".to_owned();
8947        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
8948        assert!(en.contains("Commits being squashed"), "{en}");
8949        assert_eq!(words("Klingon").html_lang, "en");
8950    }
8951
8952    /// A `gh pr list` result naming exactly one pull request whose base and
8953    /// merge time both fit the run is exactly the case
8954    /// [`find_external_merge`] exists to act on.
8955    #[test]
8956    fn pick_open_pr_classifies_by_count_and_base() {
8957        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
8958        assert_eq!(
8959            pick_open_pr(one, "main").unwrap(),
8960            OpenPr::One {
8961                url: "https://x/pull/58".into(),
8962                title: "t".into()
8963            }
8964        );
8965        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
8966        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
8967        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
8968                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
8969        assert_eq!(
8970            pick_open_pr(two, "main").unwrap(),
8971            OpenPr::Many(vec!["u1".into(), "u2".into()])
8972        );
8973        // A fork's same-named branch is not a pull request for this head.
8974        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
8975        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
8976        assert!(pick_open_pr("not json", "main").is_err());
8977        // An incomplete record is an error, never "nothing open".
8978        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
8979        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
8980    }
8981
8982    #[test]
8983    fn pick_merged_pr_picks_the_unique_match() {
8984        let json = r#"[
8985            {"url": "https://github.com/o/r/pull/42", "number": 42,
8986             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
8987        ]"#;
8988        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
8989        let found = pick_merged_pr(json, "main", created_at)
8990            .expect("valid json")
8991            .expect("one unambiguous match");
8992        assert_eq!(found.url, "https://github.com/o/r/pull/42");
8993        assert_eq!(found.number, 42);
8994    }
8995
8996    /// Two candidates surviving the filter is exactly as uninformative as
8997    /// zero — a branch name can be reused across runs — so neither is
8998    /// preferred over the other and nothing is recorded automatically.
8999    #[test]
9000    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
9001        let json = r#"[
9002            {"url": "https://github.com/o/r/pull/42", "number": 42,
9003             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
9004            {"url": "https://github.com/o/r/pull/43", "number": 43,
9005             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
9006        ]"#;
9007        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9008        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9009    }
9010
9011    /// A pull request that targets a different base branch cannot be this
9012    /// run's, whatever its head branch is named — a reused branch name from
9013    /// an unrelated task must not be recorded as this run's merge.
9014    #[test]
9015    fn pick_merged_pr_ignores_a_different_base_branch() {
9016        let json = r#"[
9017            {"url": "https://github.com/o/r/pull/42", "number": 42,
9018             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
9019        ]"#;
9020        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9021        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9022    }
9023
9024    /// A pull request merged before this run was even created cannot be this
9025    /// run's winner, no matter how its head branch is spelled.
9026    #[test]
9027    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
9028        let json = r#"[
9029            {"url": "https://github.com/o/r/pull/42", "number": 42,
9030             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
9031        ]"#;
9032        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9033        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9034    }
9035
9036    #[test]
9037    fn slug_of_pr_url_reads_host_owner_and_repo() {
9038        assert_eq!(
9039            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
9040            Some("github.com/yukimemi/shun")
9041        );
9042    }
9043
9044    #[test]
9045    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
9046        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
9047        assert_eq!(slug_of_pr_url("not a url at all"), None);
9048        assert_eq!(slug_of_pr_url("https://github.com"), None);
9049    }
9050
9051    #[test]
9052    fn slug_of_repo_url_reads_host_owner_and_repo() {
9053        assert_eq!(
9054            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
9055            Some("github.com/yukimemi/magi")
9056        );
9057        assert_eq!(slug_of_repo_url("https://github.com"), None);
9058    }
9059
9060    #[test]
9061    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
9062        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
9063            .expect("same repo, different case");
9064    }
9065
9066    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
9067    /// own in-progress run and rewrote its status from a pull request in a
9068    /// completely different repository. This is the guard that must catch
9069    /// that even when an explicit (but wrong) id is given.
9070    #[test]
9071    fn ensure_same_repo_refuses_a_different_repo() {
9072        let err =
9073            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
9074        let msg = format!("{err:#}");
9075        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9076        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
9077    }
9078
9079    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
9080    /// instance mirroring a `github.com` repository's name, say) must not be
9081    /// treated as the same repository just because the trailing path
9082    /// matches.
9083    #[test]
9084    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
9085        let err = ensure_same_repo(
9086            "github.com/yukimemi/magi",
9087            "github.example.com/yukimemi/magi",
9088        )
9089        .unwrap_err();
9090        let msg = format!("{err:#}");
9091        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9092        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
9093    }
9094
9095    /// No winner decided yet means there is no branch to ask GitHub about at
9096    /// all — `find_external_merge` must return `None` without ever spawning
9097    /// `gh`, which this proves by never providing a real repository to spawn
9098    /// it in.
9099    #[tokio::test]
9100    async fn find_external_merge_returns_none_without_a_winner() {
9101        let state = RunState::new(
9102            PathBuf::from("/no/such/repo"),
9103            "main".to_owned(),
9104            "0000000000000000000000000000000000000000".to_owned(),
9105            "irrelevant".to_owned(),
9106            crate::config::Config::default(),
9107        );
9108        assert_eq!(find_external_merge(&state).await.unwrap(), None);
9109    }
9110
9111    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
9112        let mut run = RunState::new(
9113            PathBuf::from(repo),
9114            "main".to_owned(),
9115            "abcdef1234".to_owned(),
9116            "x".to_owned(),
9117            crate::config::Config::default(),
9118        );
9119        run.id = id.to_owned();
9120        run.status = status;
9121        run.driver_exited = true;
9122        run.pr = Some(crate::run::PrRecord {
9123            number: url.rsplit('/').next().unwrap().parse().unwrap(),
9124            url: url.to_owned(),
9125            state: state.to_owned(),
9126            checks: "red".to_owned(),
9127            round: 0,
9128            rounds: 2,
9129            red_at_merge: Vec::new(),
9130        });
9131        run.save_under(home).unwrap();
9132    }
9133
9134    fn recorded(home: &Path, id: &str) -> String {
9135        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9136        serde_json::from_str::<RunState>(&body)
9137            .unwrap()
9138            .pr
9139            .unwrap()
9140            .state
9141    }
9142
9143    const PR: &str = "https://github.com/o/r/pull/7";
9144
9145    #[test]
9146    fn write_through_updates_predecessors_and_siblings_only() {
9147        let tmp = tempfile::tempdir().unwrap();
9148        let h = tmp.path();
9149        pr_run(
9150            h,
9151            "20261004-100000-aaaa",
9152            "/repo/r",
9153            RunStatus::Superseded,
9154            PR,
9155            "open",
9156        );
9157        pr_run(
9158            h,
9159            "20261004-100100-bbbb",
9160            "/repo/r",
9161            RunStatus::Blocked,
9162            PR,
9163            "open",
9164        );
9165        // Not terminal: a driver may be writing it.
9166        pr_run(
9167            h,
9168            "20261004-100200-cccc",
9169            "/repo/r",
9170            RunStatus::Landing,
9171            PR,
9172            "open",
9173        );
9174        // Another repository's pull request with the same number.
9175        pr_run(
9176            h,
9177            "20261004-100300-dddd",
9178            "/repo/other",
9179            RunStatus::Blocked,
9180            "https://github.com/o/other/pull/7",
9181            "open",
9182        );
9183        // A different pull request of the same repository.
9184        pr_run(
9185            h,
9186            "20261004-100400-eeee",
9187            "/repo/r",
9188            RunStatus::Blocked,
9189            "https://github.com/o/r/pull/8",
9190            "open",
9191        );
9192        pr_run(
9193            h,
9194            "20261004-100500-ffff",
9195            "/repo/r",
9196            RunStatus::Merged,
9197            PR,
9198            "open",
9199        );
9200        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9201
9202        assert_eq!(
9203            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9204            2
9205        );
9206        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9207        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9208        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9209        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9210        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9211        // The source's own record is the caller's to write.
9212        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9213        // Idempotent.
9214        assert_eq!(
9215            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9216            0
9217        );
9218        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9219        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9220    }
9221
9222    #[test]
9223    fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9224        let home = tempfile::tempdir().unwrap();
9225        let live_id = "20261004-100000-aaaa";
9226        let unknown_id = "20261004-100100-bbbb";
9227        for id in [live_id, unknown_id] {
9228            pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9229            let mut s = RunState::load_under(id, home.path()).unwrap();
9230            s.driver_exited = false;
9231            // Model legacy driver evidence, not a newly reserved unstarted run.
9232            s.driver_claimed = None;
9233            if id == live_id {
9234                s.driver_pid = Some(std::process::id());
9235                s.driver_started_at = crate::proc::process_started_at(std::process::id());
9236            }
9237            s.save_under(home.path()).unwrap();
9238            if id == unknown_id {
9239                assert_eq!(
9240                    s.liveness_under(home.path(), false),
9241                    crate::run::Liveness::Unknown
9242                );
9243            }
9244        }
9245        let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9246        assert_eq!(apply_pr_states(home.path(), &known), 0);
9247        for id in [live_id, unknown_id] {
9248            assert_eq!(recorded(home.path(), id), "open");
9249        }
9250    }
9251
9252    #[test]
9253    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9254        let tmp = tempfile::tempdir().unwrap();
9255        let h = tmp.path();
9256        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9257        pr_run(
9258            h,
9259            "20261004-100000-aaaa",
9260            "/repo/r",
9261            RunStatus::Superseded,
9262            &url(1),
9263            "open",
9264        );
9265        pr_run(
9266            h,
9267            "20261004-100100-bbbb",
9268            "/repo/r",
9269            RunStatus::Blocked,
9270            &url(2),
9271            "open",
9272        );
9273        pr_run(
9274            h,
9275            "20261004-100200-cccc",
9276            "/repo/r",
9277            RunStatus::Ready,
9278            &url(3),
9279            "open",
9280        );
9281        pr_run(
9282            h,
9283            "20261004-100300-dddd",
9284            "/repo/r",
9285            RunStatus::Ready,
9286            &url(4),
9287            "open",
9288        );
9289        pr_run(
9290            h,
9291            "20261004-100400-eeee",
9292            "/repo/r",
9293            RunStatus::Implementing,
9294            &url(1),
9295            "open",
9296        );
9297        assert_eq!(stale_open_prs(h).len(), 4);
9298
9299        let mut known = BTreeMap::new();
9300        known.insert(url(1), PrLifecycle::Merged);
9301        known.insert(url(2), PrLifecycle::Closed);
9302        known.insert(url(3), PrLifecycle::Open);
9303        // #4: the forge could not be read, so it has no answer.
9304        assert_eq!(apply_pr_states(h, &known), 2);
9305        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9306        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9307        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9308        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9309        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9310        assert_eq!(apply_pr_states(h, &known), 0);
9311    }
9312
9313    // --- the land loop against a scripted forge -------------------------
9314
9315    use std::collections::VecDeque;
9316    use std::sync::Mutex;
9317
9318    /// Answers views from a script (the last one repeats), merges from a
9319    /// queue, and records every call so a test can assert the order.
9320    struct Scripted {
9321        views: Mutex<VecDeque<Seen>>,
9322        merges: Mutex<VecDeque<(bool, String)>>,
9323        fix: Mutex<Option<Fixed>>,
9324        log: Mutex<Vec<&'static str>>,
9325        argvs: Mutex<Vec<Vec<String>>>,
9326        required: Mutex<Option<BTreeSet<String>>>,
9327        /// Set once a merge answered ok: the forge then reports `merged`,
9328        /// unless `queued` says the merge only entered a queue.
9329        merged: Mutex<bool>,
9330        queued: Mutex<bool>,
9331        /// Views fail once a merge answered ok.
9332        unreadable_after_merge: Mutex<bool>,
9333        /// Workflow runs asked to rerun, and whether that call fails.
9334        reruns: Mutex<Vec<String>>,
9335        rerun_fails: Mutex<bool>,
9336        /// Unix seconds; every poll moves it on by `POLL`.
9337        clock: Mutex<i64>,
9338    }
9339
9340    impl Scripted {
9341        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9342            Self {
9343                views: Mutex::new(views.into()),
9344                merges: Mutex::new(
9345                    merges
9346                        .into_iter()
9347                        .map(|(ok, m)| (ok, m.to_owned()))
9348                        .collect(),
9349                ),
9350                fix: Mutex::new(None),
9351                log: Mutex::new(Vec::new()),
9352                argvs: Mutex::new(Vec::new()),
9353                required: Mutex::new(None),
9354                merged: Mutex::new(false),
9355                queued: Mutex::new(false),
9356                unreadable_after_merge: Mutex::new(false),
9357                reruns: Mutex::new(Vec::new()),
9358                rerun_fails: Mutex::new(false),
9359                clock: Mutex::new(1_000),
9360            }
9361        }
9362        fn argvs(&self) -> Vec<Vec<String>> {
9363            self.argvs.lock().unwrap().clone()
9364        }
9365        fn calls(&self) -> Vec<&'static str> {
9366            self.log.lock().unwrap().clone()
9367        }
9368    }
9369
9370    impl Forge for Scripted {
9371        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9372            self.log.lock().unwrap().push("view");
9373            if *self.unreadable_after_merge.lock().unwrap()
9374                && !self.argvs.lock().unwrap().is_empty()
9375            {
9376                anyhow::bail!("forge unreachable");
9377            }
9378            let mut v = self.views.lock().unwrap();
9379            let mut seen = if v.len() > 1 {
9380                v.pop_front().unwrap()
9381            } else {
9382                v[0].clone()
9383            };
9384            if *self.merged.lock().unwrap() {
9385                seen.pr.state = PrLifecycle::Merged;
9386            }
9387            Ok(seen)
9388        }
9389        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9390            self.log.lock().unwrap().push("merge");
9391            self.argvs.lock().unwrap().push(argv.to_vec());
9392            let out = self
9393                .merges
9394                .lock()
9395                .unwrap()
9396                .pop_front()
9397                .expect("unscripted merge");
9398            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9399            {
9400                *self.merged.lock().unwrap() = true;
9401            }
9402            Ok(out)
9403        }
9404        async fn poll(&self) {
9405            self.log.lock().unwrap().push("poll");
9406            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9407        }
9408        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9409            self.log.lock().unwrap().push("rerun");
9410            self.reruns.lock().unwrap().push(run.to_owned());
9411            if *self.rerun_fails.lock().unwrap() {
9412                anyhow::bail!("gh run rerun: no");
9413            }
9414            Ok(())
9415        }
9416        fn now(&self) -> i64 {
9417            *self.clock.lock().unwrap()
9418        }
9419        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9420            self.required.lock().unwrap().clone()
9421        }
9422        async fn fix(
9423            &self,
9424            _state: &mut RunState,
9425            _pr: &PrState,
9426            _round: usize,
9427            _budget: usize,
9428            _reason: &str,
9429            _logs: &str,
9430        ) -> Result<Fixed> {
9431            self.log.lock().unwrap().push("fix");
9432            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
9433        }
9434    }
9435
9436    const REFUSED: &str =
9437        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
9438
9439    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
9440        let mut pr = green_pr();
9441        pr.checks = checks;
9442        pr.blocking = Blocking::of(merge_state);
9443        if !comments {
9444            pr.review_comments.clear();
9445        }
9446        Seen {
9447            pr,
9448            title: "feat: x".to_owned(),
9449            failing_urls: Vec::new(),
9450            head: head.to_owned(),
9451            rollup_head: head.to_owned(),
9452            merge_state: merge_state.to_owned(),
9453            contexts: Vec::new(),
9454            base: "main".to_owned(),
9455        }
9456    }
9457
9458    fn landing_state() -> RunState {
9459        crate::run::pin_test_home();
9460        let mut state = run_state();
9461        state.config.graph.land_approval = false;
9462        state
9463    }
9464
9465    #[test]
9466    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
9467        assert!(!awaiting_new_head(None, "aaa"));
9468        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
9469        assert!(awaiting_new_head(Some("abc123"), "def456"));
9470        assert!(awaiting_new_head(Some("abc123"), ""));
9471    }
9472
9473    #[test]
9474    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
9475        let open = |c, m: &str| seen("a", c, m, false);
9476        let table = [
9477            (None, false, Refused::Pending),
9478            (
9479                Some(open(Checks::Pending, "BLOCKED")),
9480                false,
9481                Refused::Pending,
9482            ),
9483            (
9484                Some(open(Checks::Unknown, "BLOCKED")),
9485                false,
9486                Refused::Pending,
9487            ),
9488            (
9489                Some(open(Checks::Green, "UNKNOWN")),
9490                false,
9491                Refused::Pending,
9492            ),
9493            (Some(open(Checks::Green, "")), false, Refused::Pending),
9494            (
9495                Some(open(Checks::Green, "BLOCKED")),
9496                false,
9497                Refused::Recheck,
9498            ),
9499            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
9500        ];
9501        for (after, rechecked, want) in table {
9502            assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
9503        }
9504        let mut closed = open(Checks::Green, "CLEAN");
9505        closed.pr.state = PrLifecycle::Closed;
9506        assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
9507    }
9508
9509    #[tokio::test]
9510    async fn a_normal_landing_merges_on_the_first_look() {
9511        let mut state = landing_state();
9512        let forge = Scripted::new(
9513            vec![seen("a", Checks::Green, "CLEAN", false)],
9514            vec![(true, "")],
9515        );
9516        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9517            .await
9518            .unwrap();
9519        // One fresh read, then the head-bound merge.
9520        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9521        assert_eq!(state.status, RunStatus::Merged);
9522    }
9523
9524    #[tokio::test]
9525    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
9526        let mut state = landing_state();
9527        let forge = Scripted::new(
9528            vec![seen("a", Checks::Green, "CLEAN", false)],
9529            std::iter::repeat_n((true, ""), 100).collect(),
9530        );
9531        *forge.queued.lock().unwrap() = true;
9532        let task = async {
9533            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9534                .await
9535                .unwrap();
9536        };
9537        // Still open after the command succeeded: it keeps watching and
9538        // never records a merge (it stops at the wait ceiling instead).
9539        task.await;
9540        assert_ne!(state.status, RunStatus::Merged);
9541    }
9542
9543    #[tokio::test]
9544    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
9545        let mut state = landing_state();
9546        let forge = Scripted::new(
9547            vec![seen("a", Checks::Green, "CLEAN", false)],
9548            std::iter::repeat_n((true, ""), 100).collect(),
9549        );
9550        *forge.unreadable_after_merge.lock().unwrap() = true;
9551        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9552            .await
9553            .ok();
9554        assert_ne!(state.status, RunStatus::Merged);
9555    }
9556
9557    #[tokio::test]
9558    async fn a_task_public_head_is_awaited_on_land_entry() {
9559        let mut state = landing_state();
9560        state.pr_head = Some("magi/task-abcd-1234".into());
9561        state.pr_pushed_head = Some("new".into());
9562        let forge = Scripted::new(
9563            vec![
9564                seen("old", Checks::Green, "CLEAN", false),
9565                seen("new", Checks::Green, "CLEAN", false),
9566            ],
9567            vec![(true, "")],
9568        );
9569        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9570            .await
9571            .unwrap();
9572        assert_eq!(
9573            forge.calls(),
9574            ["view", "poll", "view", "view", "merge", "view"]
9575        );
9576        assert_eq!(state.status, RunStatus::Merged);
9577    }
9578
9579    #[tokio::test]
9580    async fn a_superseded_public_head_owner_cannot_reenter_land() {
9581        let mut state = landing_state();
9582        state.pr_taken_over_by = Some("next".into());
9583        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
9584        assert!(
9585            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9586                .await
9587                .is_err()
9588        );
9589        assert!(forge.calls().is_empty());
9590    }
9591
9592    #[tokio::test]
9593    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
9594        let mut state = landing_state();
9595        let forge = Scripted::new(
9596            vec![
9597                seen("old", Checks::Green, "CLEAN", true),
9598                // The forge has not moved to the new head yet: still green.
9599                seen("old", Checks::Green, "CLEAN", true),
9600                seen("new", Checks::Pending, "BLOCKED", true),
9601                seen("new", Checks::Green, "CLEAN", true),
9602            ],
9603            vec![(true, "")],
9604        );
9605        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9606            head: "NEW".to_owned(),
9607        });
9608        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9609            .await
9610            .unwrap();
9611        assert_eq!(
9612            forge.calls(),
9613            [
9614                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
9615                "view"
9616            ]
9617        );
9618        assert_eq!(state.status, RunStatus::Merged);
9619    }
9620
9621    #[tokio::test]
9622    async fn a_head_that_never_arrives_stops_naming_both_commits() {
9623        let mut state = landing_state();
9624        let forge = Scripted::new(
9625            vec![
9626                seen("old", Checks::Green, "CLEAN", true),
9627                seen("someone-elses", Checks::Green, "CLEAN", true),
9628            ],
9629            vec![],
9630        );
9631        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9632            head: "mine".to_owned(),
9633        });
9634        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9635            .await
9636            .unwrap();
9637        assert!(!forge.calls().contains(&"merge"));
9638        let why = state.merge.as_ref().unwrap().detail.clone();
9639        assert!(
9640            why.contains("mine") && why.contains("someone-elses"),
9641            "{why}"
9642        );
9643        assert_eq!(state.status, RunStatus::Blocked);
9644    }
9645
9646    #[tokio::test]
9647    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
9648        let mut state = landing_state();
9649        let forge = Scripted::new(
9650            vec![
9651                seen("a", Checks::Green, "CLEAN", false),
9652                seen("a", Checks::Green, "CLEAN", false),
9653                seen("a", Checks::Pending, "BLOCKED", false),
9654                seen("a", Checks::Pending, "BLOCKED", false),
9655                seen("a", Checks::Green, "CLEAN", false),
9656            ],
9657            vec![(false, REFUSED), (true, "")],
9658        );
9659        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9660            .await
9661            .unwrap();
9662        assert_eq!(
9663            forge.calls(),
9664            [
9665                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
9666                "view"
9667            ]
9668        );
9669        assert_eq!(state.status, RunStatus::Merged);
9670    }
9671
9672    #[tokio::test]
9673    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
9674        let mut state = landing_state();
9675        let forge = Scripted::new(
9676            vec![
9677                seen("a", Checks::Green, "CLEAN", false),
9678                seen("a", Checks::Green, "BLOCKED", false),
9679            ],
9680            vec![(false, REFUSED), (false, REFUSED)],
9681        );
9682        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9683            .await
9684            .unwrap();
9685        // One re-look is allowed for the forge's own lag, then it is final.
9686        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
9687        let why = state.merge.as_ref().unwrap().detail.clone();
9688        assert!(
9689            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
9690            "{why}"
9691        );
9692        assert_eq!(state.status, RunStatus::Blocked);
9693    }
9694
9695    #[test]
9696    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
9697        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
9698        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
9699        // The pull request is still on the commit before the push.
9700        assert_eq!(bound_head("old", "old", Some("new")), None);
9701        // The checks are the previous commit's.
9702        assert_eq!(bound_head("new", "old", Some("new")), None);
9703        assert_eq!(bound_head("new", "old", None), None);
9704        // Nothing readable.
9705        assert_eq!(bound_head("", "", None), None);
9706        assert_eq!(bound_head("", "", Some("new")), None);
9707        assert_eq!(bound_head("abc", "", None), None);
9708    }
9709
9710    fn view_json(head: &str) -> String {
9711        format!(
9712            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
9713            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
9714            "reviews":[],"comments":[]}}"#
9715        )
9716    }
9717
9718    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
9719        format!(
9720            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
9721            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
9722            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
9723            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
9724        )
9725    }
9726
9727    #[test]
9728    fn rollup_is_bound_to_the_commit_in_the_same_node() {
9729        // The view already points at the new head, but the node still answers
9730        // for the old commit with its red check: the head stays unbound.
9731        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
9732        assert_eq!(s.rollup_head, "old");
9733        assert_eq!(s.pr.checks, Checks::Red);
9734        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
9735        assert_eq!(s.failing_urls.len(), 1);
9736    }
9737
9738    #[test]
9739    fn checks_come_from_the_node_not_the_view() {
9740        let view = view_json("new").replace(
9741            r#""reviews""#,
9742            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
9743        );
9744        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
9745        assert_eq!(s.pr.checks, Checks::Green);
9746        assert!(s.pr.failing.is_empty());
9747        assert_eq!(
9748            bound_head(&s.head, &s.rollup_head, Some("new")),
9749            Some("new")
9750        );
9751    }
9752
9753    #[test]
9754    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
9755        for node in [
9756            None,
9757            Some("not json".to_owned()),
9758            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
9759            Some(node_json("new", "SUCCESS", true)),
9760        ] {
9761            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
9762            assert!(s.rollup_head.is_empty());
9763            assert_eq!(s.pr.checks, Checks::Unknown);
9764            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
9765        }
9766    }
9767
9768    #[test]
9769    fn the_merge_command_is_pinned_to_the_observed_head() {
9770        let argv = merge_argv_at(7, "feat: x", "deadbeef");
9771        let at = argv
9772            .iter()
9773            .position(|a| a == "--match-head-commit")
9774            .unwrap();
9775        assert_eq!(argv[at + 1], "deadbeef");
9776    }
9777
9778    #[tokio::test]
9779    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
9780        let mut state = landing_state();
9781        // The pull request is on the pushed head but the rollup is still the
9782        // previous commit's red, non-required result.
9783        let mut stale = seen("new", Checks::Red, "CLEAN", false);
9784        stale.rollup_head = "old".to_owned();
9785        let forge = Scripted::new(
9786            vec![seen("old", Checks::Green, "CLEAN", true), stale],
9787            vec![],
9788        );
9789        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9790            head: "new".to_owned(),
9791        });
9792        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9793            .await
9794            .unwrap();
9795        assert!(!forge.calls().contains(&"merge"));
9796        assert_eq!(state.status, RunStatus::Blocked);
9797        let why = state.merge.as_ref().unwrap().detail.clone();
9798        assert!(why.contains("new") && why.contains("old"), "{why}");
9799    }
9800
9801    #[test]
9802    fn a_refusal_read_against_another_commits_checks_is_pending() {
9803        let mut after = seen("a", Checks::Green, "BLOCKED", false);
9804        after.rollup_head = "old".to_owned();
9805        assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
9806    }
9807
9808    #[tokio::test]
9809    async fn a_matching_head_with_red_non_required_checks_still_merges() {
9810        let mut state = landing_state();
9811        let forge = Scripted::new(
9812            vec![seen("a", Checks::Red, "CLEAN", false)],
9813            vec![(true, "")],
9814        );
9815        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9816            .await
9817            .unwrap();
9818        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9819        assert_eq!(state.status, RunStatus::Merged);
9820    }
9821
9822    #[tokio::test]
9823    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
9824        let mut state = landing_state();
9825        let forge = Scripted::new(
9826            vec![
9827                seen("a", Checks::Green, "CLEAN", false),
9828                seen("a", Checks::Green, "CLEAN", false),
9829                // Re-viewed after the refusal: someone pushed.
9830                seen("b", Checks::Green, "BLOCKED", false),
9831                seen("b", Checks::Green, "CLEAN", false),
9832            ],
9833            vec![(false, REFUSED), (true, "")],
9834        );
9835        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9836            .await
9837            .unwrap();
9838        assert_eq!(
9839            forge.calls(),
9840            [
9841                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
9842            ]
9843        );
9844        assert_eq!(state.status, RunStatus::Merged);
9845    }
9846
9847    fn merged_view(head: &str) -> Seen {
9848        let mut m = seen(head, Checks::Green, "CLEAN", false);
9849        m.pr.state = PrLifecycle::Merged;
9850        m
9851    }
9852
9853    fn has(argv: &[String], flag: &str) -> bool {
9854        argv.iter().any(|a| a == flag)
9855    }
9856
9857    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
9858        let at = argv.iter().position(|a| a == flag)?;
9859        argv.get(at + 1).map(String::as_str)
9860    }
9861
9862    const URL: &str = "https://github.com/o/r/pull/42";
9863
9864    #[tokio::test]
9865    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
9866        let mut state = landing_state();
9867        let forge = Scripted::new(
9868            vec![
9869                seen("abc", Checks::Green, "CLEAN", false),
9870                seen("abc", Checks::Green, "CLEAN", false),
9871            ],
9872            vec![(true, "")],
9873        );
9874        land_with(&mut state, URL, &forge).await.unwrap();
9875        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9876        let argv = &forge.argvs()[0];
9877        assert!(has(argv, "--squash") && has(argv, "--subject"));
9878        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
9879        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
9880        assert_eq!(state.status, RunStatus::Merged);
9881        assert!(state.land_armed_head.is_none());
9882    }
9883
9884    #[tokio::test]
9885    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
9886        let mut state = landing_state();
9887        state.land_armed_head = Some("a".to_owned());
9888        let forge = Scripted::new(
9889            vec![seen("a", Checks::Green, "CLEAN", false)],
9890            vec![(true, ""), (true, "")],
9891        );
9892        land_with(&mut state, URL, &forge).await.unwrap();
9893        let argvs = forge.argvs();
9894        assert!(has(&argvs[0], "--disable-auto"));
9895        assert!(!has(&argvs[1], "--auto"));
9896        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
9897        assert_eq!(state.status, RunStatus::Merged);
9898        assert!(state.land_armed_head.is_none());
9899    }
9900
9901    #[tokio::test]
9902    async fn an_approval_never_carries_over_to_a_new_head() {
9903        crate::run::pin_test_home();
9904        let mut state = run_state();
9905        state.config.graph.land_approval = true;
9906        let pr = green_pr();
9907        let store = ask::Questions::open();
9908
9909        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
9910            .await
9911            .unwrap();
9912        let mut q = store
9913            .list()
9914            .into_iter()
9915            .find(|q| q.run == state.id)
9916            .unwrap();
9917        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9918        store.put(&mut q).unwrap();
9919        assert_eq!(
9920            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
9921                .await
9922                .unwrap(),
9923            ApprovalGate::Approved,
9924            "the same head keeps its approval"
9925        );
9926
9927        // A new head is a new question, not the old word.
9928        assert_eq!(
9929            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
9930                .await
9931                .unwrap(),
9932            ApprovalGate::Pending
9933        );
9934        let all: Vec<_> = store
9935            .list()
9936            .into_iter()
9937            .filter(|q| q.run == state.id)
9938            .collect();
9939        assert_eq!(all.len(), 2);
9940
9941        // A question recorded before heads were tracked is not reused either.
9942        state.land_approval = None;
9943        assert_eq!(
9944            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
9945                .await
9946                .unwrap(),
9947            ApprovalGate::Pending
9948        );
9949        let open = store
9950            .list()
9951            .into_iter()
9952            .filter(|q| q.run == state.id && q.status.open())
9953            .count();
9954        assert_eq!(open, 1, "the superseded question was retired");
9955    }
9956
9957    #[tokio::test]
9958    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
9959        let mut state = landing_state();
9960        let forge = Scripted::new(
9961            vec![
9962                seen("a", Checks::Green, "CLEAN", false),
9963                // The fresh read before the merge: someone pushed.
9964                seen("b", Checks::Green, "CLEAN", false),
9965            ],
9966            vec![(true, "")],
9967        );
9968        land_with(&mut state, URL, &forge).await.unwrap();
9969        let argvs = forge.argvs();
9970        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
9971        assert!(!has(&argvs[0], "--auto"));
9972        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
9973        assert_eq!(state.status, RunStatus::Merged);
9974    }
9975
9976    fn passing(label: &str) -> CheckInfo {
9977        CheckInfo {
9978            label: label.to_owned(),
9979            verdict: Verdict::Pass,
9980            required: Some(false),
9981            run: None,
9982        }
9983    }
9984
9985    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
9986        (label.to_owned(), run.map(str::to_owned))
9987    }
9988
9989    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
9990        LandReruns {
9991            head: head.to_owned(),
9992            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
9993        }
9994    }
9995
9996    #[test]
9997    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
9998        let f = [failing("test (macos)", Some("7"))];
9999        assert_eq!(
10000            rerun_step(&f, None, "h", 100),
10001            RerunStep::Rerun(vec!["7".to_owned()])
10002        );
10003        let rec = reran("h", &[("7", 100)]);
10004        let grace = crate::release_watch::RERUN_GRACE;
10005        assert_eq!(
10006            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
10007            RerunStep::Grace
10008        );
10009        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
10010    }
10011
10012    #[test]
10013    fn a_failure_without_a_run_id_goes_to_the_fixer() {
10014        let f = [failing("a", Some("7")), failing("ci/external", None)];
10015        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
10016        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
10017    }
10018
10019    #[test]
10020    fn a_moved_head_forgets_the_reruns() {
10021        let f = [failing("a", Some("7"))];
10022        let rec = reran("old", &[("7", 100)]);
10023        assert_eq!(
10024            rerun_step(&f, Some(&rec), "new", 101),
10025            RerunStep::Rerun(vec!["7".to_owned()])
10026        );
10027    }
10028
10029    #[test]
10030    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
10031        let f = [
10032            failing("a", Some("7")),
10033            failing("b", Some("7")),
10034            failing("c", Some("8")),
10035        ];
10036        assert_eq!(
10037            rerun_step(&f, None, "h", 0),
10038            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
10039        );
10040        let rec = reran("h", &[("7", 0)]);
10041        assert_eq!(
10042            rerun_step(&f, Some(&rec), "h", 1),
10043            RerunStep::Rerun(vec!["8".to_owned()])
10044        );
10045    }
10046
10047    #[test]
10048    fn only_blocking_failures_are_candidates_for_a_rerun() {
10049        let check = |label: &str, verdict, required, run: &str| CheckInfo {
10050            label: label.to_owned(),
10051            verdict,
10052            required,
10053            run: Some(run.to_owned()),
10054        };
10055        let got = rerunnable(&[
10056            check("passed", Verdict::Pass, Some(true), "1"),
10057            check("optional", Verdict::Fail, Some(false), "2"),
10058            check("required", Verdict::Fail, Some(true), "3"),
10059            check("unknown", Verdict::Fail, None, "4"),
10060        ]);
10061        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
10062        assert_eq!(labels, ["required", "unknown"]);
10063    }
10064
10065    fn red(head: &str, run: Option<&str>) -> Seen {
10066        let mut s = seen(head, Checks::Red, "BLOCKED", false);
10067        s.pr.failing = vec!["test (macos)".to_owned()];
10068        s.contexts = vec![CheckInfo {
10069            label: "test (macos)".to_owned(),
10070            verdict: Verdict::Fail,
10071            required: Some(true),
10072            run: run.map(str::to_owned),
10073        }];
10074        s
10075    }
10076
10077    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
10078
10079    #[tokio::test]
10080    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
10081        let mut state = landing_state();
10082        let forge = Scripted::new(
10083            vec![
10084                red("a", Some("7")),
10085                seen("a", Checks::Pending, "BLOCKED", false),
10086                seen("a", Checks::Green, "CLEAN", false),
10087            ],
10088            vec![(true, "")],
10089        );
10090        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10091        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10092        assert!(!forge.calls().contains(&"fix"));
10093        assert_eq!(state.status, RunStatus::Merged);
10094        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10095        let argvs = forge.argvs();
10096        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
10097        assert!(
10098            state
10099                .events
10100                .iter()
10101                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
10102        );
10103    }
10104
10105    #[tokio::test]
10106    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
10107        let mut state = landing_state();
10108        let forge = Scripted::new(
10109            vec![
10110                red("a", Some("7")),
10111                seen("a", Checks::Green, "CLEAN", false),
10112            ],
10113            vec![(true, "")],
10114        );
10115        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10116        // The red view repeats for the grace, then the fixer is asked once.
10117        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10118        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
10119        let calls = forge.calls();
10120        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
10121        let fix_at = calls.iter().position(|c| *c == "fix");
10122        assert!(fix_at.is_none_or(|f| f > rerun_at));
10123    }
10124
10125    #[tokio::test]
10126    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
10127        let mut state = landing_state();
10128        let mut v = red("a", Some("7"));
10129        v.pr.checks = Checks::Pending;
10130        v.contexts.push(CheckInfo {
10131            label: "slow".to_owned(),
10132            verdict: Verdict::Pending,
10133            required: Some(true),
10134            run: Some("8".to_owned()),
10135        });
10136        let forge = Scripted::new(
10137            vec![v, seen("a", Checks::Green, "CLEAN", false)],
10138            vec![(true, "")],
10139        );
10140        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10141        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10142        assert_eq!(state.status, RunStatus::Merged);
10143    }
10144
10145    #[test]
10146    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10147        let mut v = red("a", Some("7"));
10148        v.contexts.push(CheckInfo {
10149            label: "same run, still going".to_owned(),
10150            verdict: Verdict::Pending,
10151            required: Some(true),
10152            run: Some("7".to_owned()),
10153        });
10154        assert!(rerunnable(&v.contexts).is_empty());
10155    }
10156
10157    #[tokio::test]
10158    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10159        let mut state = landing_state();
10160        state.config.graph.land_rounds = 0;
10161        let forge = Scripted::new(
10162            vec![
10163                red("a", Some("7")),
10164                seen("a", Checks::Green, "CLEAN", false),
10165            ],
10166            vec![(true, "")],
10167        );
10168        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10169        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10170        assert_eq!(state.status, RunStatus::Merged);
10171        assert!(!forge.calls().contains(&"fix"));
10172        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10173    }
10174
10175    #[tokio::test]
10176    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
10177        let mut state = landing_state();
10178        state.config.graph.land_rounds = 1;
10179        let forge = Scripted::new(
10180            vec![
10181                seen("a", Checks::Green, "CLEAN", true),
10182                red("b", Some("7")),
10183                seen("b", Checks::Green, "CLEAN", false),
10184            ],
10185            vec![(true, "")],
10186        );
10187        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10188            head: "b".to_owned(),
10189        });
10190        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10191        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10192        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
10193        assert_eq!(state.pr.as_ref().unwrap().round, 1);
10194        assert_eq!(state.status, RunStatus::Merged);
10195        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
10196        assert_eq!(
10197            value_of(&forge.argvs()[0], "--match-head-commit"),
10198            Some("b")
10199        );
10200    }
10201
10202    #[tokio::test]
10203    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
10204        let mut state = landing_state();
10205        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10206        *forge.rerun_fails.lock().unwrap() = true;
10207        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10208        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10209        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
10210        assert_eq!(
10211            forge.calls().iter().filter(|c| **c == "fix").count(),
10212            1,
10213            "{:?}",
10214            forge.calls()
10215        );
10216    }
10217
10218    #[tokio::test]
10219    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
10220        let mut state = landing_state();
10221        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10222        // The record was written just before the previous pass died.
10223        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
10224        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10225        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10226        assert!(forge.reruns.lock().unwrap().is_empty());
10227    }
10228
10229    #[tokio::test]
10230    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
10231        let mut state = landing_state();
10232        state.config.graph.rerun_flaky_checks = false;
10233        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10234        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10235        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10236        assert!(forge.reruns.lock().unwrap().is_empty());
10237        assert!(forge.calls().contains(&"fix"));
10238
10239        let mut state = landing_state();
10240        let forge = Scripted::new(vec![red("a", None)], vec![]);
10241        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10242        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10243        assert!(forge.reruns.lock().unwrap().is_empty());
10244        assert!(forge.calls().contains(&"fix"));
10245    }
10246
10247    fn names(xs: &[&str]) -> BTreeSet<String> {
10248        xs.iter().map(|x| (*x).to_owned()).collect()
10249    }
10250
10251    #[test]
10252    fn a_required_check_the_rollup_never_listed_is_named() {
10253        let req = names(&["build"]);
10254        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
10255        assert!(why.contains("never reported: build"), "{why}");
10256        assert!(!why.contains("probably waiting for a review"), "{why}");
10257    }
10258
10259    #[test]
10260    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
10261        let why = waiting_on("BLOCKED", &[passing("review")], None);
10262        assert!(why.contains("could not be read"), "{why}");
10263        assert!(!why.contains("probably waiting for a review"), "{why}");
10264    }
10265
10266    #[test]
10267    fn all_required_reported_keeps_the_review_guess() {
10268        let req = names(&["build"]);
10269        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10270        assert!(why.contains("probably waiting for a review"), "{why}");
10271        assert!(!why.contains("never reported"), "{why}");
10272    }
10273
10274    #[test]
10275    fn required_names_match_the_rollup_ignoring_case_only() {
10276        let req = names(&["Build"]);
10277        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10278        assert!(!why.contains("never reported"), "{why}");
10279    }
10280
10281    #[test]
10282    fn required_contexts_are_read_from_protection_and_rulesets() {
10283        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
10284        assert_eq!(
10285            parse_classic_required(classic),
10286            Some(names(&["build", "lint"]))
10287        );
10288        let rules = r#"[{"type":"pull_request","parameters":{}},
10289            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
10290        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
10291        assert_eq!(parse_ruleset_required("nope"), None);
10292        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
10293    }
10294
10295    #[test]
10296    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
10297        let shown = BTreeSet::new();
10298        let ok = seen("a", Checks::Green, "CLEAN", false);
10299        let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
10300        assert!(guard(Some(&ok)));
10301        assert!(!guard(None));
10302        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
10303        let mut stale = ok.clone();
10304        stale.rollup_head = "old".to_owned();
10305        assert!(!guard(Some(&stale)));
10306        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
10307        assert!(!guard(Some(&merged_view("a"))));
10308    }
10309
10310    #[test]
10311    fn the_rollup_node_carries_whether_each_check_is_required() {
10312        let node = node_json("new", "SUCCESS", false)
10313            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
10314        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
10315        assert_eq!(s.contexts.len(), 1);
10316        assert_eq!(s.contexts[0].required, Some(true));
10317        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
10318        assert_eq!(s.contexts[0].required, None);
10319    }
10320
10321    #[tokio::test]
10322    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
10323        let mut state = landing_state();
10324        state.land_armed_head = Some("a".to_owned());
10325        let forge = Scripted::new(
10326            vec![seen("a", Checks::Green, "CLEAN", true)],
10327            vec![(false, "disable exploded")],
10328        );
10329        land_with(&mut state, URL, &forge).await.unwrap();
10330        assert!(!forge.calls().contains(&"fix"));
10331        assert_eq!(state.status, RunStatus::Blocked);
10332        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
10333        let why = state.merge.as_ref().unwrap().detail.clone();
10334        assert!(why.contains("disable exploded"), "{why}");
10335    }
10336}