Skip to main content

magi/
land.rs

1//! Landing the winner: watch the pull request, fix what it complains about,
2//! and merge it.
3//!
4//! Opening the pull request used to be where magi stopped and the operator
5//! started: watch the checks, read what the review bots found, push a fix,
6//! wait again, merge. That loop is mechanical, it takes an hour of wall-clock
7//! time per pull request, and doing it by hand six times in one session is how
8//! a queue that drains unattended stops being unattended. So it lives here.
9//!
10//! # Shape
11//!
12//! [`PrState`] is one observation of a pull request and [`decide`] is the whole
13//! policy as a *pure* function of it. Nothing in [`decide`] talks to `gh`,
14//! which is what makes "green with an unresolved comment is a fix, not a merge"
15//! an assertion in a test rather than a claim in a comment. [`land`] is the
16//! only part that performs I/O: observe, decide, act, repeat.
17//!
18//! # What it refuses to do
19//!
20//! Merging is the one irreversible thing magi can do to a repository, so the
21//! loop is built to stop rather than to guess:
22//!
23//! * A red pull request is never merged. When the budget runs out the pull
24//!   request is left open with a comment naming what is still failing, because
25//!   a magi that force-merges a red pull request is worse than one that stops.
26//! * A pull request whose checks cannot be read at all (`gh` reported no
27//!   rollup) is not merged either. Landing is for repositories with CI; with no
28//!   signal there is nothing to be green.
29//! * A pull request a human merged or closed underneath us is
30//!   [`Step::Done`] - the person won, and their decision is not an error.
31//!
32//! # Why `--subject` is not optional
33//!
34//! A candidate branch holds one commit whose subject is
35//! `magi: candidate A (uncommitted work)`, and `gh pr merge --squash` prefers a
36//! single commit's message over the pull request title. Merging without
37//! [`merge_argv`]'s explicit `--subject` therefore writes a `main` history that
38//! says nothing about what landed. `AGENTS.md` records the trap; this module is
39//! where it is prevented.
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::MergeMode;
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{
58    ContestedHandoff, LandApproval, LandReruns, MergeOutcome, RunState, RunStatus, tail,
59};
60
61/// How often the pull request is re-read while its checks are still running.
62///
63/// Thirty seconds: a CI matrix takes minutes, so anything shorter is spent
64/// entirely on `gh` invocations, and anything much longer adds latency to every
65/// single round of a loop that already waits for agents.
66pub const POLL: Duration = Duration::from_secs(30);
67
68/// How long one wait may last before landing gives up on the checks finishing.
69///
70/// A workflow that has not settled in forty-five minutes is stuck on a runner
71/// queue, a missing approval, or a hung job - none of which more polling fixes,
72/// and all of which a person needs to see.
73pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
74
75/// How long the checks may stay unreadable before landing gives up on them.
76///
77/// GitHub registers a workflow run some seconds after the branch is pushed, so
78/// immediately after a pull request is opened "no checks" and "no CI in this
79/// repository" look identical. Measured on run 01c2: magi opened pull request
80/// 22, read `unknown` four seconds later, refused to merge on a guess and
81/// marked the run blocked - and every check on that pull request was green
82/// minutes afterwards, with the whole competition then re-run from scratch for
83/// a task that was already finished. Three minutes is well past the observed
84/// registration delay and still bounded, so a repository that genuinely has no
85/// checks costs one three-minute wait and then says so.
86pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
87
88/// Bytes of failing log kept per check. The fixer needs the assertion and the
89/// frame around it, not the forty thousand lines of `cargo` output above it.
90const LOG_TAIL: usize = 4_000;
91
92/// Failing checks whose logs are fetched. Beyond a handful the failures share a
93/// cause, and fetching each one costs a `gh` round trip.
94const MAX_LOGS: usize = 3;
95
96/// Marker carried by every comment magi posts on a pull request.
97///
98/// Without it magi's own "still failing" comment is indistinguishable from a
99/// reviewer's, and the next observation would hand magi's own prose to the
100/// fixer as a finding.
101pub const MARKER: &str = "<!-- magi:land -->";
102
103/// Markers a bot puts in a comment to say that the comment is not a review.
104///
105/// CodeRabbit labels its own machinery in HTML comments - the trigger notice,
106/// the walkthrough summary, the "thanks for using" footer - and its actual
107/// findings arrive as *inline* review comments with a path and a line. Taking
108/// the bot at its word is more honest than guessing from prose, and it is the
109/// difference between a fix round that has something to fix and one that asks
110/// an agent to act on a quota notice.
111const NOT_A_REVIEW: [&str; 3] = [
112    "skip review by coderabbit.ai",
113    "summarize by coderabbit.ai",
114    "<!-- tips_start -->",
115];
116
117/// Where a pull request is in its life.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrLifecycle {
120    /// Still ours to land.
121    Open,
122    /// Already merged, by us or by a person.
123    Merged,
124    /// Closed without merging.
125    Closed,
126}
127
128/// The aggregate verdict of a pull request's checks.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Checks {
131    /// At least one check has not finished.
132    Pending,
133    /// Every check passed (a skipped check counts as passed: the review
134    /// workflow skips release and bot pull requests by design).
135    Green,
136    /// At least one check finished without passing.
137    Red,
138    /// Nothing readable - no rollup at all, or a status magi does not know.
139    Unknown,
140}
141
142impl PrLifecycle {
143    /// Stable lower-case name, as the API and the reports spell it.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::Open => "open",
147            Self::Merged => "merged",
148            Self::Closed => "closed",
149        }
150    }
151}
152
153impl Checks {
154    /// Stable lower-case name, as the API and the reports spell it.
155    pub fn as_str(self) -> &'static str {
156        match self {
157            Self::Pending => "pending",
158            Self::Green => "green",
159            Self::Red => "red",
160            Self::Unknown => "unknown",
161        }
162    }
163}
164
165/// One outstanding review comment, human or bot.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ReviewComment {
168    /// Login of whoever wrote it.
169    pub author: String,
170    /// File it was left on, for inline review comments.
171    pub path: Option<String>,
172    /// Line it was left on, when the comment is inline and still anchored.
173    pub line: Option<u64>,
174    /// The comment itself, as written.
175    pub body: String,
176}
177
178/// One observation of a pull request.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct PrState {
181    /// Pull request url, as `gh` reports it.
182    pub url: String,
183    /// Pull request number.
184    pub number: u64,
185    /// Open, merged, or closed.
186    pub state: PrLifecycle,
187    /// Aggregate check verdict.
188    pub checks: Checks,
189    /// Names of the checks that finished without passing.
190    pub failing: Vec<String>,
191    /// Comments that still want an answer, human and bot.
192    pub review_comments: Vec<ReviewComment>,
193    /// Whether the forge itself considers the failures blocking.
194    pub blocking: Blocking,
195}
196
197/// Whether a failing check actually stands between the pull request and
198/// `main`, according to the forge.
199///
200/// The rollup lists every check equally, so `coverage` going red on a
201/// repository that deliberately does not require it looked exactly like a
202/// broken build - and magi answered by spending a fix round on a change that
203/// was fine. Pull request 37 had to be merged by hand for that reason: the
204/// only red check was `editorconfig`, which was failing because the *action*
205/// could not fetch its own binary, and which the repository does not require.
206///
207/// `mergeStateStatus` is where GitHub applies the required-check set, so it
208/// is the one field that can tell the difference.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Blocking {
211    /// Required checks are satisfied and the branch merges cleanly.
212    No,
213    /// Something required is failing or missing.
214    Yes,
215    /// The branch no longer merges: the base moved under it.
216    Conflict,
217    /// The forge did not say - an older `gh`, or a token without the scope.
218    /// Treated as `Yes`, because refusing to guess is the rule everywhere
219    /// else in this module.
220    Unsaid,
221}
222
223impl Blocking {
224    /// Read `mergeStateStatus`, which is upper-case in `gh`'s output.
225    fn of(raw: &str) -> Self {
226        match raw.to_ascii_uppercase().as_str() {
227            // Mergeable. `UNSTABLE` is the interesting one: mergeable, with a
228            // non-required check failing or still running.
229            "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
230            "DIRTY" => Self::Conflict,
231            "" | "UNKNOWN" => Self::Unsaid,
232            // BLOCKED, BEHIND, DRAFT: something has to change first.
233            _ => Self::Yes,
234        }
235    }
236
237    /// Does this stand between the pull request and the base branch?
238    #[must_use]
239    pub fn stops_a_merge(self) -> bool {
240        !matches!(self, Self::No)
241    }
242}
243
244/// What the loop decided to do next. Pure, so the policy is testable.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Step {
247    /// Checks are still running; re-read the pull request after [`POLL`].
248    Wait,
249    /// The base moved and the branch no longer merges: rebase it.
250    ///
251    /// Not a fix round. Nothing is wrong with the change - a competition
252    /// that runs for two hours against a repository merging pull requests
253    /// all day conflicts on the way in, and that is arithmetic rather than a
254    /// defect. Pull requests 35 and 37 were both rebased by hand for exactly
255    /// this.
256    Rebase,
257    /// Red checks or unresolved comments; run a fix round.
258    Fix {
259        /// What is unhappy, in one line, for the run log and the fix prompt.
260        reason: String,
261    },
262    /// Green and nothing outstanding; merge it.
263    Merge,
264    /// The pull request left our hands.
265    Done {
266        /// Did it land, or was it closed?
267        merged: bool,
268    },
269    /// Stop and leave the pull request to a person.
270    GiveUp {
271        /// Why magi stopped, in one line.
272        reason: String,
273    },
274}
275
276/// The outcome to record when `gh pr merge` exits non-zero, given what the
277/// pull request looked like immediately afterwards.
278///
279/// `gh pr merge` merges server-side first and only then does local work -
280/// deleting the branch, switching back to a base branch - so a non-zero exit
281/// does not mean the merge did not happen. In a jj-colocated repository it
282/// reliably does not mean that: git HEAD is detached, and `--delete-branch`
283/// ends with "could not determine current branch: not on any branch" *after*
284/// the merge has landed. Run ec12 merged pull request 28 into `main` and
285/// recorded `ok: false`, and its task was held waiting for a merge that was
286/// already done.
287///
288/// So the forge is asked, and its answer wins - the same authority [`decide`]
289/// gives the pull request's own state over everything else. The recorded
290/// detail carries both facts, because "the command failed and the merge
291/// happened anyway" is exactly what someone reading the run later needs to
292/// know.
293///
294/// `None` means the merge really did not happen, including when the pull
295/// request could not be read at all: an unreadable answer is not evidence of
296/// success.
297pub(crate) fn merged_after_all(
298    argv: &[String],
299    stderr: &str,
300    after: Option<PrLifecycle>,
301) -> Option<MergeOutcome> {
302    if after? != PrLifecycle::Merged {
303        return None;
304    }
305    Some(MergeOutcome {
306        mode: MergeMode::Pr,
307        ok: true,
308        detail: format!(
309            "gh {} (the command reported `{}`, but the pull request is merged)",
310            argv.join(" "),
311            stderr.trim()
312        ),
313        empty: false,
314    })
315}
316
317/// Decide the next step. No I/O.
318///
319/// `round` counts the fix rounds already spent, so `round == budget` means the
320/// budget is gone. A wait never spends a round: waiting is free, and a slow CI
321/// must not consume the allowance meant for actual fixes.
322///
323/// The order of the tests is the policy:
324///
325/// 1. **The pull request's own state wins.** A merge or a close that happened
326///    underneath us is the end of the story regardless of what the checks say.
327/// 2. **Pending beats red.** A check that is still running may yet fail, and one
328///    fix round that addresses every failure is cheaper than two that each
329///    address half - the fix pushes and restarts the whole suite anyway.
330/// 3. **Comments outrank green.** An unresolved comment holds the merge even
331///    when CI is happy; that is what a review is for.
332/// 4. **Unreadable is not absent.** Checks that cannot be read yet are waited
333///    on for [`CHECKS_GRACE`], because a pull request opened a moment ago has
334///    not been given its workflow runs yet. Past the grace they are treated as
335///    genuinely missing and magi stops rather than merge on a guess.
336pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
337    decide_with(pr, round, budget, waited, CiExpectation::Expected)
338}
339
340/// Whether the repository's CI is expected to report on this pull request.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum CiExpectation {
343    /// Checks will come: wait for them, judge them (the default, and what
344    /// [`decide`] always uses).
345    Expected,
346    /// No check will ever report (`[release] mode = "local"` on a repository
347    /// whose Actions never run). Waiting out [`CHECKS_GRACE`] or reading
348    /// `unknown` as a refusal would stall forever, so the checks are read as
349    /// absent and the pull request is ready as soon as it merges cleanly.
350    Absent,
351}
352
353/// [`decide`] with the CI expectation made explicit. `Expected` is exactly
354/// [`decide`]; `Absent` reads the absence of CI as the reason to proceed, and
355/// still stops for a conflict (the base moved), which a rebase cures and no
356/// check state does.
357pub fn decide_with(
358    pr: &PrState,
359    round: usize,
360    budget: usize,
361    waited: Duration,
362    ci: CiExpectation,
363) -> Step {
364    match pr.state {
365        PrLifecycle::Merged => return Step::Done { merged: true },
366        PrLifecycle::Closed => return Step::Done { merged: false },
367        PrLifecycle::Open => {}
368    }
369
370    // Before the checks: every check on a branch that cannot land is an
371    // answer about a state that cannot land.
372    if pr.blocking == Blocking::Conflict {
373        return Step::Rebase;
374    }
375
376    if ci == CiExpectation::Absent {
377        return Step::Merge;
378    }
379
380    let spent = round >= budget;
381    match pr.checks {
382        Checks::Pending => Step::Wait,
383        Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
384        Checks::Unknown => Step::GiveUp {
385            reason: format!(
386                "no check status is readable on the pull request after {} minute(s); \
387                 refusing to merge on a guess",
388                CHECKS_GRACE.as_secs() / 60
389            ),
390        },
391        // Red, but the forge says it does not stand in the way: the failing
392        // checks are ones this repository chose not to require. Spending a fix
393        // round on them asks an agent to repair something nobody is gating on
394        // - and pull request 37's only red check was an *action* that could
395        // not fetch its own binary. Merge, and name them so the record is
396        // honest about what was red when it landed.
397        Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
398        Checks::Red => {
399            let what = format!(
400                "{} check(s) failing: {}",
401                pr.failing.len(),
402                pr.failing.join(", ")
403            );
404            if spent {
405                Step::GiveUp {
406                    reason: format!("{what} — still red after {budget} fix round(s)"),
407                }
408            } else {
409                Step::Fix { reason: what }
410            }
411        }
412        Checks::Green if pr.review_comments.is_empty() => Step::Merge,
413        Checks::Green => {
414            let what = format!(
415                "checks are green but {} review comment(s) are unresolved: {}",
416                pr.review_comments.len(),
417                authors(&pr.review_comments)
418            );
419            if spent {
420                Step::GiveUp {
421                    reason: format!("{what} — still unresolved after {budget} fix round(s)"),
422                }
423            } else {
424                Step::Fix { reason: what }
425            }
426        }
427    }
428}
429
430/// Distinct comment authors, in the order they first appear.
431fn authors(comments: &[ReviewComment]) -> String {
432    let mut seen: Vec<&str> = Vec::new();
433    for c in comments {
434        if !seen.contains(&c.author.as_str()) {
435            seen.push(&c.author);
436        }
437    }
438    seen.join(", ")
439}
440
441/// The argv magi merges with, minus the program name.
442///
443/// `--subject` is the point of this function existing: see the module docs.
444pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
445    vec![
446        "pr".to_owned(),
447        "merge".to_owned(),
448        number.to_string(),
449        "--squash".to_owned(),
450        "--delete-branch".to_owned(),
451        "--subject".to_owned(),
452        subject.to_owned(),
453    ]
454}
455
456/// [`merge_argv`] pinned to the commit the decision was made about.
457///
458/// `--match-head-commit` makes the forge refuse the merge if the branch moved
459/// after it was observed, so a push landing between the look and the merge is
460/// never merged unseen.
461pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
462    let mut argv = merge_argv(number, subject);
463    argv.push("--match-head-commit".to_owned());
464    argv.push(head.to_owned());
465    argv
466}
467
468/// Take auto-merge back. magi no longer arms auto-merge, but a run recorded by
469/// an older build may still have one standing on the forge, so the disarm
470/// paths (and `RunState::land_armed_head`) stay. Run before anything that changes the head, so an
471/// armed merge never outlives the commit that was approved for it.
472pub fn disable_automerge_argv(number: u64) -> Vec<String> {
473    ["pr", "merge", &number.to_string(), "--disable-auto"]
474        .map(str::to_owned)
475        .to_vec()
476}
477
478/// May the direct merge go ahead, judged from a fresh read?
479///
480/// The pull request must still be open on the approved head, the checks must
481/// have been read for that very head, and the policy must still say merge.
482/// Pure, so the head guard is asserted without a forge.
483fn direct_merge_is_safe(
484    fresh: Option<&Seen>,
485    approved_head: &str,
486    shown: &BTreeSet<String>,
487    round: usize,
488    budget: usize,
489    waited: Duration,
490) -> bool {
491    let Some(fresh) = fresh else {
492        return false;
493    };
494    if fresh.pr.state != PrLifecycle::Open {
495        return false;
496    }
497    let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
498        return false;
499    };
500    if !bound.eq_ignore_ascii_case(approved_head) {
501        return false;
502    }
503    let mut pr = fresh.pr.clone();
504    pr.review_comments.retain(|c| !shown.contains(&c.body));
505    decide(&pr, round, budget, waited) == Step::Merge
506}
507
508/// What GitHub is still waiting for, for the stop reason when an armed merge
509/// does not happen in time. No I/O.
510///
511/// Required checks that are pending or failing are named. A check whose
512/// required-ness could not be read is never assumed optional: every unsettled
513/// check is listed and the reason says so.
514fn waiting_on(
515    merge_state: &str,
516    contexts: &[CheckInfo],
517    required_set: Option<&BTreeSet<String>>,
518) -> String {
519    let state = if merge_state.is_empty() {
520        "unknown"
521    } else {
522        merge_state
523    };
524    let tag = |c: &CheckInfo| match c.verdict {
525        Verdict::Fail => "failed",
526        _ => "pending",
527    };
528    let unsettled: Vec<&CheckInfo> = contexts
529        .iter()
530        .filter(|c| c.verdict != Verdict::Pass)
531        .collect();
532    let required: Vec<String> = unsettled
533        .iter()
534        .filter(|c| c.required == Some(true))
535        .map(|c| format!("{} ({})", c.label, tag(c)))
536        .collect();
537    let unknown: Vec<String> = unsettled
538        .iter()
539        .filter(|c| c.required.is_none())
540        .map(|c| format!("{} ({})", c.label, tag(c)))
541        .collect();
542    // Required contexts the rollup never listed: nothing reported them, so no
543    // pending/failing entry exists to name. Matched case-insensitively against
544    // the labels as the rollup spells them, and no further.
545    let never: Vec<&str> = required_set
546        .map(|set| {
547            set.iter()
548                .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
549                .map(String::as_str)
550                .collect()
551        })
552        .unwrap_or_default();
553    let mut out = format!("merge state: {state}");
554    if !never.is_empty() {
555        let _ = write!(
556            out,
557            "; required checks never reported: {}",
558            never.join(", ")
559        );
560    }
561    if !required.is_empty() {
562        let _ = write!(
563            out,
564            "; required checks not passing: {}",
565            required.join(", ")
566        );
567    }
568    if !unknown.is_empty() {
569        let _ = write!(
570            out,
571            "; whether these are required could not be read, so they may be: {}",
572            unknown.join(", ")
573        );
574    }
575    if required.is_empty() && unknown.is_empty() && never.is_empty() {
576        if required_set.is_some() {
577            out.push_str(
578                "; no required check is pending, failing or unreported, so GitHub is probably \
579                 waiting for a review or another branch rule",
580            );
581        } else {
582            out.push_str(
583                "; the required check list could not be read, so a required check that was \
584                 never reported cannot be ruled out",
585            );
586        }
587    }
588    out
589}
590
591/// The squash subject to merge under.
592///
593/// The pull request title, unless it is empty or is a candidate branch's commit
594/// subject that leaked into the title - in which case the task's own first line
595/// is used, because `magi: candidate A (uncommitted work)` in `main` tells a
596/// reader nothing about what landed.
597pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
598    let title = pr_title.trim();
599    if !title.is_empty() && !title.starts_with("magi: candidate") {
600        return title.to_owned();
601    }
602    let first = instruction
603        .lines()
604        .map(str::trim)
605        .find(|l| !l.is_empty())
606        .unwrap_or("magi: land the winning candidate");
607    first.trim_start_matches(['#', ' ']).to_owned()
608}
609
610/// The choice that lets the merge happen, verbatim as the owner taps it.
611pub const APPROVE: &str = "merge";
612
613/// The choice that leaves the pull request open.
614pub const HOLD: &str = "hold";
615
616/// A bounded repair followed by a new review and a fresh owner decision.
617pub const REPAIR: &str = "fix findings and re-review";
618
619/// Whether review evidence offers a meaningful repair choice. This does not
620/// decide whether owner approval is required.
621pub fn repair_available(state: &RunState) -> bool {
622    state.reviews.last().is_some_and(|r| {
623        state.open_findings().iter().any(|f| f.severity.blocks())
624            || r.final_votes()
625                .iter()
626                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
627    })
628}
629
630/// All applicable reasons for consulting the owner, independent of repair choices.
631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632pub enum ApprovalReason {
633    /// Universal owner approval is enabled for every merge.
634    EveryMerge,
635    /// Contested-merge approval is enabled and blocking findings remain open.
636    OpenBlockingFindings,
637    /// Contested-merge approval is enabled and a final reviewer vote rejects.
638    FinalReject,
639    /// An existing owner decision must be honored even if controls change.
640    ExistingDecision,
641    /// Merge repair requires a fresh owner decision, even on the same head.
642    AfterRepair,
643}
644
645/// Disabling fresh approval controls never bypasses an existing owner decision
646/// or the fresh decision owed after repair, even if repair kept the same head.
647pub fn approval_reasons(state: &RunState) -> Vec<ApprovalReason> {
648    let mut reasons = Vec::new();
649    if state.owner_hold(crate::config::OwnerHold::LandApproval) {
650        reasons.push(ApprovalReason::EveryMerge);
651    }
652    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
653        if state.open_findings().iter().any(|f| f.severity.blocks()) {
654            reasons.push(ApprovalReason::OpenBlockingFindings);
655        }
656        if state.reviews.last().is_some_and(|r| {
657            r.final_votes()
658                .iter()
659                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
660        }) {
661            reasons.push(ApprovalReason::FinalReject);
662        }
663    }
664    if state.land_approval.is_some() {
665        reasons.push(ApprovalReason::ExistingDecision);
666    }
667    if !state.merge_repairs.is_empty() {
668        reasons.push(ApprovalReason::AfterRepair);
669    }
670    reasons
671}
672
673/// Bound free-form approval evidence by characters, including an explicit
674/// ellipsis when abbreviated. Keep UTF-8 text intact in questions and panels.
675fn approval_excerpt(text: &str, max_chars: usize) -> String {
676    if text.chars().count() <= max_chars {
677        text.to_owned()
678    } else {
679        let mut excerpt: String = text.chars().take(max_chars.saturating_sub(1)).collect();
680        excerpt.push('…');
681        excerpt
682    }
683}
684
685/// Persisted verbatim in the question and panel at creation. Deputies and
686/// resumed questions use that snapshot, never reconstruct past policy from
687/// today's settings. Fixer claims and successful gates are not resolutions.
688fn approval_evidence(state: &RunState) -> String {
689    const TITLE_CHARS: usize = 100;
690    const REASON_CHARS: usize = 300;
691    let reason = |text: &str| approval_excerpt(text, REASON_CHARS);
692    let ja = words(&state.config.graph.language).html_lang == "ja";
693    let mut s = if ja {
694        "承認が必要な理由:\n"
695    } else {
696        "Approval required because:\n"
697    }
698    .to_owned();
699    for reason in approval_reasons(state) {
700        let label = match (reason, ja) {
701            (ApprovalReason::EveryMerge, false) => {
702                "land_approval=true: owner approval for every merge"
703            }
704            (ApprovalReason::EveryMerge, true) => {
705                "land_approval=true: すべてのマージに所有者の承認が必要"
706            }
707            (ApprovalReason::OpenBlockingFindings, false) => {
708                "hold_contested_merge=true: open Major-or-above internal findings"
709            }
710            (ApprovalReason::OpenBlockingFindings, true) => {
711                "hold_contested_merge=true: Major 以上の内部指摘が未解決"
712            }
713            (ApprovalReason::FinalReject, false) => {
714                "hold_contested_merge=true: final reviewer reject after reconsideration (even without Major findings)"
715            }
716            (ApprovalReason::FinalReject, true) => {
717                "hold_contested_merge=true: 再考後の最終票が reject(Major 指摘がなくても承認が必要)"
718            }
719            (ApprovalReason::ExistingDecision, false) => {
720                "existing owner decision: changing controls cannot bypass it"
721            }
722            (ApprovalReason::ExistingDecision, true) => {
723                "既存の所有者判断: 設定変更では迂回できません"
724            }
725            (ApprovalReason::AfterRepair, false) => {
726                "fresh owner approval after merge repair, even with an unchanged head"
727            }
728            (ApprovalReason::AfterRepair, true) => {
729                "マージ修復後の新しい所有者承認(head に変更がなくても必要)"
730            }
731        };
732        let control = match reason {
733            ApprovalReason::EveryMerge => Some(crate::config::OwnerHold::LandApproval),
734            ApprovalReason::OpenBlockingFindings | ApprovalReason::FinalReject => {
735                Some(crate::config::OwnerHold::HoldContestedMerge)
736            }
737            _ => None,
738        };
739        let source = control
740            .filter(|c| !c.base_value(&state.config.graph))
741            .and_then(|c| state.branch_owner_holds.iter().find(|h| h.control == c));
742        if let Some(source) = source {
743            if ja {
744                let _ = writeln!(
745                    s,
746                    "- {}=true(レビュー対象ブランチの {} 由来, {})",
747                    source.control.key(),
748                    source.file,
749                    source.head
750                );
751            } else {
752                let _ = writeln!(
753                    s,
754                    "- {}=true from the reviewed branch's {} ({})",
755                    source.control.key(),
756                    source.file,
757                    source.head
758                );
759            }
760        }
761        let _ = writeln!(s, "- {label}");
762    }
763    s.push_str(if ja {
764        "\n再考後の各レビュアーの最終票:\n"
765    } else {
766        "\nFinal reviewer votes after reconsideration:\n"
767    });
768    if let Some(round) = state.reviews.last() {
769        let votes = round.final_votes();
770        for r in &round.reviews {
771            let vote = votes
772                .iter()
773                .find(|(seat, _, _)| *seat == r.reviewer)
774                .map(|(_, _, v)| v.label())
775                .unwrap_or(if ja { "記録なし" } else { "not recorded" });
776            let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
777            let rv = round
778                .reconsideration
779                .iter()
780                .find(|rv| rv.reviewer == r.reviewer);
781            if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
782                let initial = r.vote.map(|v| v.label()).unwrap_or("?");
783                let _ = writeln!(
784                    s,
785                    "  {}: {initial} → {vote}",
786                    if ja { "再考" } else { "reconsideration" }
787                );
788                let _ = writeln!(
789                    s,
790                    "  {}: {}",
791                    if ja { "理由" } else { "reason" },
792                    reason(if rv.reason.trim().is_empty() {
793                        if ja { "記録なし" } else { "not recorded" }
794                    } else {
795                        &rv.reason
796                    })
797                );
798            } else {
799                let _ = writeln!(
800                    s,
801                    "  {}: {}",
802                    if ja {
803                        "初回票を保持・理由"
804                    } else {
805                        "initial vote retained; reason"
806                    },
807                    reason(if r.summary.trim().is_empty() {
808                        if ja { "記録なし" } else { "not recorded" }
809                    } else {
810                        &r.summary
811                    })
812                );
813                if let Some(rv) = rv {
814                    let _ = writeln!(
815                        s,
816                        "  {}: {}",
817                        if ja {
818                            "再投票の回答なし"
819                        } else {
820                            "no reconsideration vote"
821                        },
822                        reason(rv.failed.as_deref().unwrap_or(if ja {
823                            "理由の記録なし"
824                        } else {
825                            "reason not recorded"
826                        }))
827                    );
828                }
829            }
830            if let Some(failed) = &r.failed {
831                let _ = writeln!(
832                    s,
833                    "  {}: {}",
834                    if ja { "回答なし" } else { "no answer" },
835                    reason(failed)
836                );
837            }
838        }
839    } else {
840        s.push_str(if ja {
841            "レビュー記録なし\n"
842        } else {
843            "No review recorded\n"
844        });
845    }
846    s.push_str(if ja { "\n内部レビュー指摘(最終ラウンド): 採用申告や gate 成功だけでは個別指摘の解決確認にはなりません。\n" } else { "\nInternal review findings (last round): adoption claims or a passing gate alone do not confirm individual resolution.\n" });
847    let findings = state.last_round_findings();
848    for f in &findings {
849        let _ = writeln!(
850            s,
851            "[{}] {} {}:{}: {}\n{}",
852            f.id,
853            f.severity,
854            f.file.as_deref().unwrap_or("?"),
855            f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
856            approval_excerpt(&f.title, TITLE_CHARS),
857            reason(&f.detail)
858        );
859        let mut evidence = false;
860        for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
861            state
862                .operator_fixes
863                .iter()
864                .filter_map(|request| request.fix.as_ref()),
865        ) {
866            if fix.addressed.contains(&f.id) {
867                evidence = true;
868                let _ = writeln!(
869                    s,
870                    "  {} ({}): {}",
871                    if ja {
872                        "採用申告(解決未確認)"
873                    } else {
874                        "claimed addressed (resolution unconfirmed)"
875                    },
876                    fix.agent,
877                    reason(&fix.notes)
878                );
879            }
880            for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
881                evidence = true;
882                let _ = writeln!(
883                    s,
884                    "  {}: {}",
885                    if ja { "不採用理由" } else { "declined" },
886                    reason(&rejection.why)
887                );
888            }
889        }
890        if !evidence {
891            s.push_str(if ja {
892                "  採用・解決確認の記録なし\n"
893            } else {
894                "  No adoption or resolution evidence recorded\n"
895            });
896        }
897    }
898    if findings.is_empty() {
899        s.push_str(if ja {
900            "最終ラウンドに指摘の記録なし\n"
901        } else {
902            "No findings recorded in the last round\n"
903        });
904    }
905    s
906}
907
908/// Shared choices for web, CLI and consultation questions.
909pub fn approval_choices(state: &RunState) -> Vec<String> {
910    let mut choices = vec![
911        APPROVE.to_owned(),
912        HOLD.to_owned(),
913        REPAIR.to_owned(),
914        crate::merge_decision::WITHDRAW.to_owned(),
915        crate::merge_decision::DISMISS.to_owned(),
916    ];
917    choices.extend(
918        state
919            .config
920            .agents
921            .iter()
922            .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
923    );
924    choices
925}
926
927/// Extend unanswered legacy cards in place. Answered cards keep their choices;
928/// a legacy repair answer gains only its missing dispatch action.
929pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
930    if q.node != APPROVAL_NODE
931        || !state
932            .land_approval
933            .as_ref()
934            .is_some_and(|a| a.question == q.id)
935    {
936        return false;
937    }
938    let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
939    if q.status.open() {
940        // Older cards attached Resume to hold; a hold must remain answerable
941        // without dispatching a question action.
942        q.actions.remove(HOLD);
943        for choice in approval_choices(state) {
944            if !q.choices.contains(&choice) {
945                q.choices.push(choice);
946            }
947        }
948        if q.merge_target.is_none() {
949            q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
950                pr: pr.url.clone(),
951                head: state.land_approval.as_ref().unwrap().head.clone(),
952                language: state.config.graph.language.clone(),
953            });
954        }
955    }
956    for choice in q.choices.clone() {
957        if (q.status.open()
958            && crate::merge_decision::stops(&choice)
959            && choice != HOLD
960            && choice != crate::merge_decision::DISMISS)
961            || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
962        {
963            q.actions.insert(
964                choice,
965                ask::ChoiceAction::Resume {
966                    run: state.id.clone(),
967                },
968            );
969        }
970    }
971    before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
972}
973
974/// Whether `quote` is a verbatim part of the owner's `message` and nothing in
975/// the whole message carries a hedge, a condition, a negation, a question or a
976/// retraction. Used by `deputy::destructive`.
977///
978/// The whole message is read, not just the quote's sentence: a condition or a
979/// second thought in another sentence ("Merge it. Only if CI passes.") makes
980/// the approval conditional all the same. Doubt anywhere is `false`.
981pub fn unhedged(message: &str, quote: &str) -> bool {
982    let quote = quote.trim();
983    !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
984}
985
986/// Hedging, conditional, negated or interrogative wording. ASCII words are
987/// matched as whole words so `note` is not `not`. A bare negation or stop word
988/// (`no`, `stop`, `nope`, ...) anywhere in the message voids the approval.
989fn hedged(text: &str) -> bool {
990    const WORDS: &[&str] = &[
991        "maybe",
992        "probably",
993        "perhaps",
994        "might",
995        "if",
996        "unless",
997        "not",
998        "no",
999        "nope",
1000        "stop",
1001        "dont",
1002        "abort",
1003        "revert",
1004        "undo",
1005        "never",
1006        "wait",
1007        "hold",
1008        "cancel",
1009        "but",
1010        "think",
1011        "guess",
1012        "suppose",
1013        "unsure",
1014        "yet",
1015        "except",
1016        "only",
1017        "cannot",
1018        "should",
1019        "once",
1020        "provided",
1021        "providing",
1022        "when",
1023        "whenever",
1024        "after",
1025        "until",
1026        "before",
1027        "assuming",
1028        "given",
1029        "while",
1030        "whether",
1031        "depending",
1032        "pending",
1033    ];
1034    const JA: &[&str] = &[
1035        "かも",
1036        "たぶん",
1037        "多分",
1038        "なら",
1039        "たら",
1040        "ちょっと待",
1041        "しないで",
1042        "しない",
1043        "保留",
1044        "まだ",
1045        "ただし",
1046        "やめ",
1047        "いや",
1048        "止め",
1049        "だめ",
1050        "ダメ",
1051        "じゃない",
1052        "ではない",
1053        "ですか",
1054        "かな",
1055        "でしょう",
1056        "思う",
1057        "でも",
1058        "けど",
1059        "ただ",
1060        "次第",
1061        "場合",
1062        "限り",
1063        "条件",
1064        "とき",
1065        "まで",
1066        "後で",
1067    ];
1068    if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
1069        return true;
1070    }
1071    text.to_lowercase()
1072        .replace('\u{2019}', "'")
1073        .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
1074        .filter(|w| !w.is_empty())
1075        .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
1076}
1077
1078/// Wording that takes back what the rest of the message said. Bare negation
1079/// and stop words are `hedged`'s whole-word list, not substrings here.
1080fn retracts(message: &str) -> bool {
1081    let lower = message.to_lowercase();
1082    [
1083        "やっぱ",
1084        "待って",
1085        "撤回",
1086        "never mind",
1087        "actually",
1088        "on second thought",
1089    ]
1090    .iter()
1091    .any(|w| lower.contains(w))
1092        || lower
1093            .split(|c: char| !c.is_ascii_alphabetic())
1094            .any(|w| w == "wait")
1095}
1096
1097/// Graph node recorded on the approval question.
1098///
1099/// The phone keys its high-stakes card off this rather than off the choice
1100/// strings, so renaming a button cannot silently downgrade the card that
1101/// guards the one irreversible action magi takes.
1102pub const APPROVAL_NODE: &str = "land-approval";
1103
1104/// Unified diff lines carried in the panel before it is truncated.
1105///
1106/// Four hundred: the panel is read on a 390px phone, where a diff line often
1107/// wraps to two rows, so this is already a few thousand rows of scrolling -
1108/// past that nobody is reading, and the bytes still count against the panel's
1109/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
1110/// cut and which worktree holds the whole patch.
1111pub const DIFF_MAX_LINES: usize = 400;
1112
1113/// What the owner's answer to the approval question means.
1114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1115pub enum Approval {
1116    /// The owner said [`APPROVE`]. Merge.
1117    Merge,
1118    /// Repair the existing PR, then ask again; never approval to merge.
1119    Repair,
1120    /// Anything else, including silence. Leave the pull request open.
1121    Hold,
1122}
1123
1124/// Read the owner's answer, where `None` is an unanswered question.
1125///
1126/// Silence is a hold. A timed-out question means the owner never saw it or
1127/// never decided, and defaulting an irreversible merge to "yes" would make this
1128/// gate worse than no gate at all: it would merge unattended while claiming to
1129/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
1130/// function does not recognise holds too.
1131pub fn approval(answer: Option<&str>) -> Approval {
1132    match answer {
1133        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1134        Some(a) if a.trim() == REPAIR => Approval::Repair,
1135        _ => Approval::Hold,
1136    }
1137}
1138
1139/// What [`approval_gate`] found on one check of the owner's merge decision.
1140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1141enum ApprovalGate {
1142    /// The owner said [`APPROVE`]. Merge.
1143    Approved,
1144    /// The owner explicitly requested repair, without authorizing a merge.
1145    Repair,
1146    /// The owner said anything else, the question timed out, or it was
1147    /// closed with no decision recorded.
1148    Held,
1149    /// Filed and still waiting - the caller parks rather than blocking on it.
1150    Pending,
1151}
1152
1153/// Escape text for HTML, including both quote characters.
1154///
1155/// Every string in the panel is agent-influenced: a branch name, a file path, a
1156/// commit subject, a review comment. The sandboxed frame stops such text from
1157/// *running*, but it does not stop a `<` from ending the document early or a
1158/// `"` from ending an attribute and inventing a new one - the panel would then
1159/// render a lie, or not render at all. Both quotes are escaped because the same
1160/// function is used inside attributes, where remembering which quote style the
1161/// caller used is one mistake away from an injected attribute.
1162fn esc(s: &str) -> String {
1163    let mut out = String::with_capacity(s.len());
1164    for c in s.chars() {
1165        match c {
1166            '&' => out.push_str("&amp;"),
1167            '<' => out.push_str("&lt;"),
1168            '>' => out.push_str("&gt;"),
1169            '"' => out.push_str("&quot;"),
1170            '\'' => out.push_str("&#39;"),
1171            _ => out.push(c),
1172        }
1173    }
1174    out
1175}
1176
1177/// One row of the diffstat table.
1178#[derive(Debug, Clone, PartialEq, Eq)]
1179struct StatRow {
1180    path: String,
1181    /// `None` for a binary file, which `git` reports as `-`.
1182    added: Option<u64>,
1183    removed: Option<u64>,
1184}
1185
1186impl StatRow {
1187    /// Lines touched, for sorting. A binary file counts as zero rather than as
1188    /// unknown, which puts it at the bottom where it needs no attention.
1189    fn churn(&self) -> u64 {
1190        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1191    }
1192}
1193
1194/// Parse `git diff --numstat` into rows, biggest churn first.
1195///
1196/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
1197/// terminal width, so counting its characters would print fabricated numbers in
1198/// the one table an operator approves an irreversible action from.
1199fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1200    let mut rows: Vec<StatRow> = numstat
1201        .lines()
1202        .filter_map(|line| {
1203            let mut parts = line.splitn(3, '\t');
1204            let added = parts.next()?.trim();
1205            let removed = parts.next()?.trim();
1206            let path = parts.next()?.trim();
1207            if path.is_empty() {
1208                return None;
1209            }
1210            Some(StatRow {
1211                path: path.to_owned(),
1212                added: added.parse().ok(),
1213                removed: removed.parse().ok(),
1214            })
1215        })
1216        .collect();
1217    // Path breaks the tie so the same change always renders the same table; an
1218    // operator comparing two panels should not see rows shuffle.
1219    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1220    rows
1221}
1222
1223/// How one diff line is shown: a gutter character, a style, and the body to
1224/// print - which is the line minus its marker, so the marker appears exactly
1225/// once, in the gutter.
1226///
1227/// The gutter is why this exists at all. The operator may be colour blind, or
1228/// reading in sunlight with the screen dimmed, so an added line is never
1229/// distinguished by its background alone: `+` and `-` sit in a fixed column,
1230/// the same mark they already read in a terminal.
1231fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1232    if line.starts_with("+++") || line.starts_with("---") {
1233        (" ", "color:#57606a;font-weight:600", line)
1234    } else if let Some(body) = line.strip_prefix('+') {
1235        ("+", "background:#e6ffec;color:#0a3622", body)
1236    } else if let Some(body) = line.strip_prefix('-') {
1237        ("-", "background:#ffebe9;color:#5c1a17", body)
1238    } else if line.starts_with("@@") {
1239        ("~", "background:#eef2ff;color:#3730a3", line)
1240    } else if let Some(body) = line.strip_prefix(' ') {
1241        (" ", "", body)
1242    } else {
1243        (" ", "color:#57606a;font-weight:600", line)
1244    }
1245}
1246
1247/// The handful of words the approval panel says in its own voice.
1248///
1249/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
1250/// the operator asked why the merge question spoke English on a repository
1251/// configured for Japanese, and "because that string is a literal in Rust" is
1252/// not an answer. Only the languages magi can actually check are translated;
1253/// anything else falls back to English rather than shipping a guess, and that
1254/// fallback is deliberate.
1255struct Words {
1256    html_lang: &'static str,
1257    task: &'static str,
1258    what_changed: &'static str,
1259    review_verdict: &'static str,
1260    reviewer: &'static str,
1261    reviewer_no_answer: &'static str,
1262    checks: &'static str,
1263    nothing_failing: &'static str,
1264    files_changed: &'static str,
1265    commits: &'static str,
1266    no_commits: &'static str,
1267    comments: &'static str,
1268    no_comments: &'static str,
1269    diff: &'static str,
1270    truncated: &'static str,
1271    lands_as: &'static str,
1272}
1273
1274const EN: Words = Words {
1275    html_lang: "en",
1276    task: "Task",
1277    what_changed: "What changed",
1278    review_verdict: "Review verdict",
1279    reviewer: "Reviewer",
1280    reviewer_no_answer: "produced no answer",
1281    checks: "Checks",
1282    nothing_failing: "Nothing failing.",
1283    files_changed: "file(s) changed",
1284    commits: "Commits being squashed",
1285    no_commits: "No commit subjects could be read from the branch.",
1286    comments: "External PR review comments",
1287    no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1288    diff: "Diff",
1289    truncated: "Truncated",
1290    lands_as: "They land as one commit titled",
1291};
1292
1293const JA: Words = Words {
1294    html_lang: "ja",
1295    task: "タスク",
1296    what_changed: "変更内容",
1297    review_verdict: "レビューの結論",
1298    reviewer: "レビュアー",
1299    reviewer_no_answer: "回答なし",
1300    checks: "チェック",
1301    nothing_failing: "失敗しているものはありません。",
1302    files_changed: "ファイル変更",
1303    commits: "squash されるコミット",
1304    no_commits: "ブランチからコミット件名を読めませんでした。",
1305    comments: "外部 PR レビューコメント",
1306    no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1307    diff: "差分",
1308    truncated: "省略",
1309    lands_as: "これらは次の件名の1コミットとして入ります:",
1310};
1311
1312impl Words {
1313    /// The clause after the merge subject. Split out because word order moves:
1314    /// Japanese puts the subject before the verb, so a shared template with a
1315    /// hole in the middle would read as machine translation.
1316    fn lands_as_tail(&self) -> &'static str {
1317        if self.html_lang == "ja" {
1318            "。この件名も承認の対象です。"
1319        } else {
1320            ", which you are approving too."
1321        }
1322    }
1323
1324    /// The question's own one-line summary, which is what a phone shows first.
1325    fn approval_summary(&self, number: u64, subject: &str) -> String {
1326        if self.html_lang == "ja" {
1327            format!("プルリクエスト #{number} をマージ: {subject}")
1328        } else {
1329            format!("merge pull request #{number}: {subject}")
1330        }
1331    }
1332
1333    /// The body under the summary, above the panel.
1334    fn approval_detail(
1335        &self,
1336        url: &str,
1337        base: &str,
1338        subject: &str,
1339        contested: Option<&ContestedHandoff>,
1340    ) -> String {
1341        let body = if self.html_lang == "ja" {
1342            format!(
1343                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1344                 できる状態です。差分の要約・パッチ・squash されるコミットは\
1345                 下のパネルにあります。"
1346            )
1347        } else {
1348            format!(
1349                "{url} is green and ready to squash into `{base}` as `{subject}`. \
1350                 The panel holds the diffstat, the patch and the commits being squashed."
1351            )
1352        };
1353        match contested {
1354            Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1355            None => body,
1356        }
1357    }
1358
1359    /// Why this question exists although merge approvals are off: the open
1360    /// blocking findings and who rejected. Short enough for a phone.
1361    fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1362        const SHOWN: usize = 5;
1363        const TITLE_CHARS: usize = 100;
1364        let ja = self.html_lang == "ja";
1365        let mut out = if ja {
1366            format!(
1367                "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1368                 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1369            )
1370        } else {
1371            format!(
1372                "{url} is held for approval although merge approvals are off: the \
1373                 review ended with blocking findings still open and a reviewer \
1374                 voting reject.\n"
1375            )
1376        };
1377        for f in c.findings.iter().take(SHOWN) {
1378            let at = match (&f.file, f.line) {
1379                (Some(file), Some(line)) => format!("{file}:{line}"),
1380                (Some(file), None) => file.clone(),
1381                _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1382            };
1383            let title: String = f.title.chars().take(TITLE_CHARS).collect();
1384            let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1385        }
1386        if c.findings.len() > SHOWN {
1387            let more = c.findings.len() - SHOWN;
1388            let _ = writeln!(
1389                out,
1390                "{}",
1391                if ja {
1392                    format!("- ほか {more} 件")
1393                } else {
1394                    format!("- and {more} more")
1395                }
1396            );
1397        }
1398        let seats: Vec<String> = c
1399            .rejecters
1400            .iter()
1401            .map(|(seat, agent)| format!("#{seat} ({agent})"))
1402            .collect();
1403        let _ = write!(
1404            out,
1405            "{} {}",
1406            if ja {
1407                "却下したレビュアー:"
1408            } else {
1409                "Rejected by reviewer:"
1410            },
1411            seats.join(", ")
1412        );
1413        out
1414    }
1415
1416    /// The truncation note, written whole in each language for the same reason.
1417    fn truncated_note(
1418        &self,
1419        omitted: usize,
1420        total: usize,
1421        shown: usize,
1422        where_: &str,
1423        base: &str,
1424        head: &str,
1425    ) -> String {
1426        if self.html_lang == "ja" {
1427            format!(
1428                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1429                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1430                 プルリクエストにあります。"
1431            )
1432        } else {
1433            format!(
1434                "{omitted} of {total} diff lines omitted after the first {shown}. \
1435                 The whole patch is in <code>{where_}</code> \
1436                 (<code>git diff {base}...{head}</code>) and on the pull request."
1437            )
1438        }
1439    }
1440}
1441
1442/// Pick the panel's language. Codes and names both, because `[graph] language`
1443/// has always accepted either.
1444fn words(language: &str) -> &'static Words {
1445    if crate::lang::is_japanese(language) {
1446        &JA
1447    } else {
1448        &EN
1449    }
1450}
1451
1452/// The approval panel's html: what is about to land, and the evidence for it.
1453///
1454/// Pure, so the whole document is asserted in tests without `gh`, without a
1455/// network and without a repository. The caller gathers `diffstat`
1456/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
1457/// being squashed) and `subject` (what the squash will be called) from the
1458/// winner's worktree.
1459///
1460/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
1461/// content security policy blocks all three: anything of the sort here would be
1462/// dead markup that misleads the next reader into thinking it works.
1463pub fn approval_panel(
1464    state: &RunState,
1465    pr: &PrState,
1466    diffstat: &str,
1467    diff: &str,
1468    commits: &[String],
1469    subject: &str,
1470) -> String {
1471    let rows = parse_numstat(diffstat);
1472    let w = words(&state.config.graph.language);
1473    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1474
1475    let _ = writeln!(
1476        h,
1477        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1478         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1479        w.html_lang
1480    );
1481    let _ = writeln!(
1482        h,
1483        "<title>merge #{} — {}</title>\n</head>",
1484        pr.number,
1485        esc(subject)
1486    );
1487    h.push_str(
1488        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1489         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1490         word-break:break-word\">\n",
1491    );
1492
1493    // The decision, in the words the operator is approving.
1494    let _ = writeln!(
1495        h,
1496        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1497         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1498         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1499         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1500         <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1501        pr.number,
1502        esc(&state.base_branch),
1503        esc(subject),
1504        esc(&state.id),
1505        esc(&pr.url),
1506        esc(&pr.url),
1507    );
1508
1509    let _ = writeln!(
1510        h,
1511        "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1512        esc(&approval_evidence(state))
1513    );
1514
1515    // The task, verbatim: the operator's own words for what was asked, so the
1516    // panel does not make them reconstruct the request from a diffstat.
1517    let _ = writeln!(
1518        h,
1519        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1520         <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1521        w.task,
1522        esc(&state.instruction)
1523    );
1524
1525    // The winner's own account of what it did and why, when there is one.
1526    if let Some(summary) = state
1527        .winner()
1528        .map(|c| c.summary.as_str())
1529        .filter(|s| !s.is_empty())
1530    {
1531        let _ = writeln!(
1532            h,
1533            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1534             <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1535            w.what_changed,
1536            esc(summary)
1537        );
1538    }
1539
1540    // Only the last round's reviewer prose is current. Its votes and
1541    // remaining findings are shown explicitly in the evidence above.
1542    if let Some(round) = state.reviews.last() {
1543        let _ = writeln!(
1544            h,
1545            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1546            w.review_verdict
1547        );
1548        for r in &round.reviews {
1549            // A seat the review loop counted as answered has real prose in
1550            // `summary`; one it counted against `incomplete` (see
1551            // `graph::Runner::review_loop`) never produced any and left it
1552            // empty - which must not be read back as a blank verdict, since
1553            // an empty box here looks like "nothing to say" rather than
1554            // "never answered".
1555            let body = match &r.failed {
1556                Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1557                None => esc(&r.summary),
1558            };
1559            let _ = writeln!(
1560                h,
1561                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1562                 border-radius:6px\">\
1563                 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1564                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1565                w.reviewer,
1566                r.reviewer,
1567                esc(&r.agent),
1568                body,
1569            );
1570        }
1571    }
1572
1573    let _ = writeln!(
1574        h,
1575        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1576        w.checks,
1577        esc(pr.checks.as_str())
1578    );
1579    if pr.failing.is_empty() {
1580        let _ = writeln!(
1581            h,
1582            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1583            w.nothing_failing
1584        );
1585    } else {
1586        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1587        for f in &pr.failing {
1588            let _ = writeln!(h, "<li>{}</li>", esc(f));
1589        }
1590        h.push_str("</ul>\n");
1591    }
1592
1593    // Diffstat as a real table, so a phone reads what moved without scrolling
1594    // sideways through a terminal bar chart.
1595    let _ = writeln!(
1596        h,
1597        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1598        rows.len(),
1599        w.files_changed
1600    );
1601    h.push_str(
1602        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1603         <thead><tr>\
1604         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1605         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1606         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1607         </th></tr></thead>\n<tbody>\n",
1608    );
1609    let mut total_added = 0u64;
1610    let mut total_removed = 0u64;
1611    for r in &rows {
1612        total_added += r.added.unwrap_or(0);
1613        total_removed += r.removed.unwrap_or(0);
1614        let cell = |n: Option<u64>| match n {
1615            Some(n) => n.to_string(),
1616            None => "bin".to_owned(),
1617        };
1618        let _ = writeln!(
1619            h,
1620            "<tr>\
1621             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1622             font-family:ui-monospace,monospace\">{}</td>\
1623             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1624             color:#0a3622\">{}</td>\
1625             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1626             color:#5c1a17\">{}</td></tr>",
1627            esc(&r.path),
1628            cell(r.added),
1629            cell(r.removed),
1630        );
1631    }
1632    let _ = writeln!(
1633        h,
1634        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1635         <td style=\"padding:4px 2px\">total</td>\
1636         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1637         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1638         </tr></tfoot>\n</table>"
1639    );
1640
1641    // The commits being squashed, and the subject that replaces them.
1642    let _ = writeln!(
1643        h,
1644        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1645        w.commits
1646    );
1647    if commits.is_empty() {
1648        h.push_str(&format!(
1649            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1650            w.no_commits
1651        ));
1652    } else {
1653        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1654        for c in commits {
1655            let _ = writeln!(h, "<li>{}</li>", esc(c));
1656        }
1657        h.push_str("</ol>\n");
1658    }
1659    let _ = writeln!(
1660        h,
1661        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1662        w.lands_as,
1663        esc(subject),
1664        w.lands_as_tail()
1665    );
1666
1667    // The review comments that shaped this branch, and who asked for them.
1668    let _ = writeln!(
1669        h,
1670        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1671        w.comments
1672    );
1673    if pr.review_comments.is_empty() {
1674        h.push_str(&format!(
1675            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1676            w.no_comments
1677        ));
1678    } else {
1679        for c in &pr.review_comments {
1680            let anchor = match (&c.path, c.line) {
1681                (Some(p), Some(l)) => format!("{p}:{l}"),
1682                (Some(p), None) => p.clone(),
1683                _ => "pull request thread".to_owned(),
1684            };
1685            let _ = writeln!(
1686                h,
1687                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1688                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1689                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1690                esc(&c.author),
1691                esc(&anchor),
1692                esc(&tail(&c.body, 800)),
1693            );
1694        }
1695    }
1696
1697    // The patch itself.
1698    let total = diff.lines().count();
1699    let shown = total.min(DIFF_MAX_LINES);
1700    let _ = writeln!(
1701        h,
1702        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1703        w.diff
1704    );
1705    h.push_str(
1706        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1707         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1708    );
1709    for line in diff.lines().take(shown) {
1710        let (gutter, style, body) = diff_row(line);
1711        let _ = writeln!(
1712            h,
1713            "<div style=\"display:flex;{style}\">\
1714             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1715             border-right:1px solid #d0d7de\">{gutter}</span>\
1716             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1717            esc(body),
1718        );
1719    }
1720    h.push_str("</div>\n");
1721    if total > shown {
1722        let omitted = total - shown;
1723        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1724        let where_ = state.winner().map_or_else(
1725            || state.repo.display().to_string(),
1726            |w| w.worktree.display().to_string(),
1727        );
1728        let _ = writeln!(
1729            h,
1730            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1731             font-size:13px\">{}: {}</p>",
1732            w.truncated,
1733            w.truncated_note(
1734                omitted,
1735                total,
1736                shown,
1737                &esc(&where_),
1738                &esc(&state.base_branch),
1739                &esc(head),
1740            ),
1741        );
1742    }
1743
1744    h.push_str("</body>\n</html>\n");
1745    h
1746}
1747
1748/// Historical contested hand-off evidence for the question detail. Approval
1749/// itself is decided by `approval_reasons`, including reject-only cases.
1750fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1751    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
1752        state.contested_handoff.clone()
1753    } else {
1754        None
1755    }
1756}
1757
1758/// What a merge-approval question's deputy is told: the pull request, the run,
1759/// what each answer does, and the evidence saved when the question was filed.
1760///
1761/// A snapshot taken when the deputy is attached, so it says so and points at
1762/// `magi show` / `gh pr view` for anything current. `state` is `None` for a run
1763/// that cannot be read; what is missing is named as missing, and no past
1764/// approval basis is rebuilt from today's state.
1765pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1766    let mut s = format!(
1767        "This is the merge approval for run {run} (`magi show {run}`). The question's \
1768         own text above names the pull request. Answering `{APPROVE}` squash-merges \
1769         it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1770         request open. Silence is a hold: the owner not answering never merges. Only \
1771         the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1772         own words, merges. Whether their wording is a clear, unconditional instruction \
1773         is your judgement alone: if it is doubtful, conditional, retracted or a \
1774         question, do not settle - ask back with `magi ask --thread`. Doubt and \
1775         silence are a hold.\n\n\
1776         This brief is a snapshot from when you were attached: check `magi show {run}` \
1777         and `gh pr view` (read-only) before telling the owner anything current. \
1778         You run with permission to write the question record, and what keeps you \
1779         from touching anything else is this brief and your instructions - so do \
1780         not change files, branches or the pull request.",
1781        run = q.run
1782    );
1783    if let Some(target) = &q.merge_target {
1784        s.push_str(&format!("\n\nThis decision names PR {} at head `{}`. Merge through chat requires the owner's latest whole message and quote to be exactly `merge {}`. Discussion alone never approves. For hold, repair, withdrawal or replacement, use the exact offered token from the owner's latest whole message. Withdrawal explicitly closes the PR and ends the task. Replacement closes the old PR before dispatching a new attempt with the selected implementer; do not start a separate task yourself.", target.pr, target.head, target.head));
1785    }
1786    if q.choices.iter().any(|c| c == REPAIR) {
1787        s.push_str("\n\n`fix findings and re-review` requests one repair and bounded re-review on the existing PR. It never approves a merge. Settle this choice only when the owner's whole latest message is exactly this label; otherwise discuss it with `--thread`. The driver performs the repair; do not edit or queue another task. Updated review and gate results require a fresh merge decision.");
1788    }
1789    if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1790    {
1791        s.push_str("\n\nSaved approval evidence (at question creation):\n");
1792        s.push_str(&q.detail);
1793    } else {
1794        s.push_str("\n\nApproval reasons and final review evidence were not recorded in this legacy question. Do not infer its original trigger from current settings; consult the saved panel and review history.");
1795    }
1796    let Some(state) = state else {
1797        s.push_str(
1798            "\n\nThe run's record could not be read, so the pull request, the panel \
1799             summary and any contested findings are not known to you beyond the \
1800             question's own text. Say so to the owner rather than guessing.",
1801        );
1802        return s;
1803    };
1804    if let Some(pr) = &state.pr {
1805        s.push_str(&format!(
1806            "\n\nPull request #{} {} (recorded state: {}, last seen).",
1807            pr.number, pr.url, pr.state
1808        ));
1809    }
1810    s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1811    if let Some(w) = state.winner() {
1812        s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1813    }
1814
1815    s
1816}
1817
1818// Releasing a task permits work to resume; it does not renew merge consent.
1819// Check the persisted operator decision both when reusing a card and at the
1820// final merge boundary, since a hold/release can arrive between those reads.
1821fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1822    if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1823        return Ok(false);
1824    }
1825    let Some(task_id) = state
1826        .origin
1827        .as_ref()
1828        .and_then(|origin| origin.task.as_ref())
1829    else {
1830        return Ok(false);
1831    };
1832    let task = crate::queue::Queue::open()
1833        .get(task_id)
1834        .context("cannot verify operator decisions for the merge approval")?;
1835    Ok(task
1836        .operator_decided_at
1837        .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1838}
1839
1840/// Ask the owner before merging, with the whole case attached as a panel.
1841///
1842/// The evidence is gathered from the winner's own worktree with the `git` CLI,
1843/// never from the network, so a phone on a slow link gets the diff magi is
1844/// looking at rather than a link it has to go and open.
1845///
1846/// Never blocks. `land` used to sit inside [`ask::ask_and_wait`]'s poll loop
1847/// for up to a day right here, which held the whole run's task claim - and
1848/// the daemon's one slot with it - for exactly as long as the owner took to
1849/// notice their phone. [`ApprovalGate::Pending`] is the answer that lets the
1850/// caller park the run and hand the slot back instead: the question is on
1851/// disk either way, so nothing about the wait itself changes, only who is
1852/// blocked on it.
1853///
1854/// Idempotent across resumes: called again for a run already waiting on its
1855/// own question, this finds that question by [`crate::ask::Questions::list`]
1856/// rather than filing a second one - asking twice would double the
1857/// notification for one decision, and leave the first question's panel an
1858/// orphan nobody's answer ever reaches.
1859async fn approval_gate(
1860    state: &mut RunState,
1861    pr: &PrState,
1862    subject: &str,
1863    contested: Option<&ContestedHandoff>,
1864    head: &str,
1865) -> Result<ApprovalGate> {
1866    let store = ask::Questions::open();
1867    // An answer belongs to the commit its panel showed. A question recorded
1868    // for another head - or none recorded at all, as for a question filed
1869    // before heads were tracked - is never reused: a fix or rebase push made
1870    // a commit the owner has not seen, and their word does not carry over.
1871    let reusable = state.land_approval.as_ref().and_then(|a| {
1872        store
1873            .list()
1874            .into_iter()
1875            .find(|q| q.id == a.question)
1876            .filter(|q| {
1877                q.status != ask::QuestionStatus::Abandoned
1878                    && (a.head.eq_ignore_ascii_case(head)
1879                        || q.resolution()
1880                            .as_deref()
1881                            .is_some_and(crate::merge_decision::stops))
1882            })
1883    });
1884    let reusable = match reusable {
1885        Some(q) if merge_consent_superseded(state, &q)? => None,
1886        other => other,
1887    };
1888    if reusable.is_none() {
1889        for stale in store
1890            .list()
1891            .into_iter()
1892            .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1893        {
1894            let why =
1895                "the pull request head or operator decision changed; asked again for approval";
1896            if let Err(e) = store.update(&stale.id, |q| {
1897                q.abandon(why);
1898                Ok(())
1899            }) {
1900                tracing::warn!("could not retire the superseded approval question: {e:#}");
1901            }
1902        }
1903    }
1904
1905    let q = match reusable {
1906        Some(mut q) => {
1907            if upgrade_repair_choice(&mut q, state) {
1908                q = store
1909                    .update(&q.id, |current| {
1910                        upgrade_repair_choice(current, state);
1911                        Ok(())
1912                    })?
1913                    .0;
1914            }
1915            q
1916        }
1917        None => {
1918            let worktree = match state.winner() {
1919                Some(w) => w.worktree.clone(),
1920                None => state.repo.clone(),
1921            };
1922            // The diff is built from the commit being approved, not from the
1923            // branch name, which may already point somewhere else.
1924            let head = if head.is_empty() {
1925                state
1926                    .winner()
1927                    .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1928            } else {
1929                head.to_owned()
1930            };
1931            // The diff is against what the remote's base holds, never the
1932            // local branch of that name; unreadable means less evidence.
1933            let remote = &state.config.merge.remote;
1934            let tracking = format!("{remote}/{}", state.base_branch);
1935            let base = if git::rev_exists(&worktree, &tracking).await {
1936                tracking
1937            } else {
1938                String::new()
1939            };
1940            let range = format!("{base}...{head}");
1941            // A failed `git` must not decide the merge: the panel degrades to
1942            // less evidence and the owner still chooses. Merging because the
1943            // diff could not be read would be the worst of both.
1944            let numstat = if base.is_empty() {
1945                String::new()
1946            } else {
1947                git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1948                    .await
1949                    .map(|o| o.stdout)
1950                    .unwrap_or_default()
1951            };
1952            let diff = if base.is_empty() {
1953                String::new()
1954            } else {
1955                git::diff(&worktree, &base, &head).await.unwrap_or_default()
1956            };
1957            let commits: Vec<String> = if base.is_empty() {
1958                Vec::new()
1959            } else {
1960                git::git_raw(
1961                    &worktree,
1962                    &[
1963                        "log",
1964                        "--reverse",
1965                        "--format=%s",
1966                        &format!("{base}..{head}"),
1967                    ],
1968                )
1969                .await
1970                .map(|o| o.stdout)
1971                .unwrap_or_default()
1972                .lines()
1973                .filter(|l| !l.trim().is_empty())
1974                .map(str::to_owned)
1975                .collect()
1976            };
1977
1978            let w = words(&state.config.graph.language);
1979            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1980            let mut fresh = ask::Question::new(
1981                state.id.clone(),
1982                APPROVAL_NODE.to_owned(),
1983                "land".to_owned(),
1984                w.approval_summary(pr.number, subject),
1985                format!(
1986                    "{}\n\n{}",
1987                    approval_evidence(state),
1988                    w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1989                ),
1990                approval_choices(state),
1991            );
1992            fresh.merge_target = Some(crate::merge_decision::Target {
1993                pr: pr.url.clone(),
1994                head: head.clone(),
1995                language: state.config.graph.language.clone(),
1996            });
1997            if fresh.choices.iter().any(|c| c == REPAIR) {
1998                fresh.actions.insert(
1999                    REPAIR.to_owned(),
2000                    ask::ChoiceAction::Resume {
2001                        run: state.id.clone(),
2002                    },
2003                );
2004                fresh.detail.push_str("\n\n`fix findings and re-review` runs one repair and bounded review on this PR; it does not approve a merge. A fresh decision follows the updated review and gate.");
2005            }
2006            for choice in fresh.choices.clone() {
2007                if crate::merge_decision::stops(&choice)
2008                    && choice != HOLD
2009                    && choice != crate::merge_decision::DISMISS
2010                {
2011                    fresh.actions.insert(
2012                        choice,
2013                        ask::ChoiceAction::Resume {
2014                            run: state.id.clone(),
2015                        },
2016                    );
2017                }
2018            }
2019            store
2020                .put_panel(&mut fresh, &html, &[])
2021                .context("write the merge approval panel")?;
2022            store
2023                .put_executable(&mut fresh)
2024                .context("file the merge approval question")?;
2025            state.land_approval = Some(LandApproval {
2026                question: fresh.id.clone(),
2027                head: head.clone(),
2028            });
2029            state.event(
2030                "land",
2031                format!("asking for merge approval ({})", fresh.short()),
2032            );
2033            state.save()?;
2034            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
2035                // A broken webhook is not a reason to lose the merge: the
2036                // question is already on disk and the web UI already shows
2037                // it, so the operator still has a way in.
2038                tracing::warn!(
2039                    "could not notify about merge approval question {}: {e:#} - \
2040                     the web UI is the only surface for it now",
2041                    fresh.short()
2042                );
2043            }
2044            fresh
2045        }
2046    };
2047
2048    Ok(match q.status {
2049        ask::QuestionStatus::Open => ApprovalGate::Pending,
2050        // Nobody answered before `state.config.graph.answer_timeout` passed,
2051        // or the question was closed with no decision recorded underneath
2052        // this run - either way there is nothing left to wait on.
2053        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
2054        // The merge gate does not speak `--thread`: an owner who talked back
2055        // instead of choosing never reaches `Answered`, so this arm only
2056        // ever sees an actual decision.
2057        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
2058            Approval::Merge => ApprovalGate::Approved,
2059            Approval::Repair if !state.reviews.is_empty() => {
2060                if !state.merge_repairs.iter().any(|r| r.question == q.id) {
2061                    state.merge_repairs.push(crate::run::MergeRepair {
2062                        question: q.id.clone(),
2063                        head: head.to_owned(),
2064                        pr: pr.url.clone(),
2065                        reviews: state.reviews.clone(),
2066                        gate: state.gate.clone(),
2067                        gate_head: state.gate_head.clone(),
2068                        gate_fixes: state.gate_fixes.clone(),
2069                        pre_gate: state.pre_gate.clone(),
2070                        phase: crate::run::MergeRepairPhase::Requested,
2071                        failure: None,
2072                    });
2073                    state.land_approval = None;
2074                    state.event(
2075                        "repair",
2076                        "owner requested fix findings and re-review; merge is not approved",
2077                    );
2078                    state.save()?;
2079                }
2080                ApprovalGate::Repair
2081            }
2082            Approval::Repair | Approval::Hold => ApprovalGate::Held,
2083        },
2084    })
2085}
2086
2087/// Fold a rollup's entries into one verdict plus the failing checks' labels.
2088/// No I/O. An empty rollup is `Unknown`, never green.
2089fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
2090    let mut failing = Vec::new();
2091    let mut pending = false;
2092    let mut unknown = false;
2093    for check in rollup {
2094        match check.verdict() {
2095            Verdict::Pass => {}
2096            Verdict::Pending => pending = true,
2097            Verdict::Fail => failing.push(check.label()),
2098            Verdict::Unknown => unknown = true,
2099        }
2100    }
2101    let checks = if rollup.is_empty() {
2102        Checks::Unknown
2103    } else if pending {
2104        Checks::Pending
2105    } else if !failing.is_empty() {
2106        Checks::Red
2107    } else if unknown {
2108        Checks::Unknown
2109    } else {
2110        Checks::Green
2111    };
2112    (checks, failing)
2113}
2114
2115/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
2116/// output into a [`PrState`]. No I/O.
2117pub fn parse_pr(json: &str) -> Result<PrState> {
2118    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2119    let state = match raw.state.to_ascii_uppercase().as_str() {
2120        "OPEN" => PrLifecycle::Open,
2121        "MERGED" => PrLifecycle::Merged,
2122        "CLOSED" => PrLifecycle::Closed,
2123        other => bail!("unknown pull request state `{other}`"),
2124    };
2125
2126    let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2127
2128    let mut review_comments = Vec::new();
2129    for r in raw.reviews {
2130        push_if_outstanding(
2131            &mut review_comments,
2132            ReviewComment {
2133                author: r.author.login,
2134                path: None,
2135                line: None,
2136                body: r.body,
2137            },
2138        );
2139    }
2140    for c in raw.comments {
2141        push_if_outstanding(
2142            &mut review_comments,
2143            ReviewComment {
2144                author: c.author.login,
2145                path: None,
2146                line: None,
2147                body: c.body,
2148            },
2149        );
2150    }
2151
2152    Ok(PrState {
2153        url: raw.url,
2154        number: raw.number,
2155        state,
2156        checks,
2157        failing,
2158        review_comments,
2159        blocking: Blocking::of(&raw.merge_state_status),
2160    })
2161}
2162
2163/// One rollup entry as the release watcher reads it.
2164#[derive(Debug, Clone, PartialEq, Eq)]
2165pub(crate) struct CheckView {
2166    pub name: String,
2167    pub verdict: Verdict,
2168    /// Workflow run behind the check, when its url names one.
2169    pub run: Option<String>,
2170    pub url: Option<String>,
2171}
2172
2173/// A pull request's lifecycle, head commit and per-check verdicts, without
2174/// [`rollup_verdict`]'s aggregation (a pending check anywhere hides a failure
2175/// from it, which is exactly what the release watcher must not inherit).
2176#[derive(Debug, Clone, PartialEq, Eq)]
2177pub(crate) struct RollupView {
2178    pub url: String,
2179    pub number: u64,
2180    pub state: PrLifecycle,
2181    pub head: String,
2182    pub checks: Vec<CheckView>,
2183}
2184
2185/// Parse `gh pr view --json url,number,state,headRefOid,statusCheckRollup`
2186/// output. No I/O.
2187pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2188    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2189    let state = match raw.state.to_ascii_uppercase().as_str() {
2190        "OPEN" => PrLifecycle::Open,
2191        "MERGED" => PrLifecycle::Merged,
2192        "CLOSED" => PrLifecycle::Closed,
2193        other => bail!("unknown pull request state `{other}`"),
2194    };
2195    let checks = raw
2196        .status_check_rollup
2197        .iter()
2198        .map(|c| CheckView {
2199            name: c.label(),
2200            verdict: c.verdict(),
2201            run: c.url().and_then(run_of),
2202            url: c.url().map(str::to_owned),
2203        })
2204        .collect();
2205    Ok(RollupView {
2206        url: raw.url,
2207        number: raw.number,
2208        state,
2209        head: raw.head_ref_oid,
2210        checks,
2211    })
2212}
2213
2214/// Read just a pull request's lifecycle state - open, merged, or closed -
2215/// with none of the checks/reviews/comments [`land`] itself needs to decide
2216/// what to do next.
2217///
2218/// For a caller that only ever wants one fact and must not risk anything
2219/// else: `magi fold --merged` uses this to confirm a URL the operator hands
2220/// it is actually a merged pull request *before* touching a run's state, so a
2221/// typo or a still-open PR fails loudly instead of quietly recording a merge
2222/// that never happened.
2223pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2224    let view = gh(
2225        repo,
2226        &[
2227            "pr".to_owned(),
2228            "view".to_owned(),
2229            pr_url.to_owned(),
2230            "--json".to_owned(),
2231            "state".to_owned(),
2232        ],
2233    )
2234    .await?;
2235    if !view.0 {
2236        bail!("gh pr view {pr_url}: {}", view.1);
2237    }
2238    // `parse_pr` reads every other field of `GhPr` as its serde default
2239    // (empty string, empty vec, zero) when this narrower `--json` selection
2240    // does not carry them - harmless, since only `.state` is read back.
2241    Ok(parse_pr(&view.1)?.state)
2242}
2243
2244/// A pull request the operator merged outside of `land::land`'s own loop,
2245/// found by asking GitHub about the run's own winning branch rather than
2246/// requiring the operator to go and find the URL themselves.
2247#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2248pub struct ExternalMerge {
2249    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
2250    pub url: String,
2251    /// The pull request's number.
2252    pub number: u64,
2253}
2254
2255#[derive(Debug, Deserialize)]
2256#[serde(rename_all = "camelCase")]
2257struct GhMergedPr {
2258    url: String,
2259    number: u64,
2260    merged_at: String,
2261    base_ref_name: String,
2262}
2263
2264/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
2265/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
2266/// decide whether exactly one of the pull requests it lists could actually
2267/// be *this* run's.
2268///
2269/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
2270/// from the run's own short id, so a collision with some other, unrelated
2271/// task's merged pull request from a same-named branch is rare but not
2272/// impossible once branches are deleted and ids run out. Filtering on
2273/// `base_ref_name` (the branch this run actually targets) and `merged_at`
2274/// (which cannot predate the run itself) rules that case out. More than one
2275/// survivor is exactly as uninformative as zero — something this run cannot
2276/// tell apart from another — so only a unique survivor is returned.
2277fn pick_merged_pr(
2278    json: &str,
2279    base_branch: &str,
2280    created_at: Timestamp,
2281) -> Result<Option<ExternalMerge>> {
2282    let raw: Vec<GhMergedPr> =
2283        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2284    let mut matches: Vec<ExternalMerge> = Vec::new();
2285    for pr in raw {
2286        if pr.base_ref_name != base_branch {
2287            continue;
2288        }
2289        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2290            continue;
2291        };
2292        if merged_at < created_at {
2293            continue;
2294        }
2295        matches.push(ExternalMerge {
2296            url: pr.url,
2297            number: pr.number,
2298        });
2299    }
2300    if matches.len() == 1 {
2301        Ok(matches.pop())
2302    } else {
2303        Ok(None)
2304    }
2305}
2306
2307/// What `gh pr list --head <branch> --base <base> --state open` found.
2308#[derive(Debug, Clone, PartialEq, Eq)]
2309pub enum OpenPr {
2310    /// Nothing open: the caller creates one.
2311    None,
2312    /// Exactly one: the caller adopts it instead of creating a second.
2313    One {
2314        /// The pull request's URL.
2315        url: String,
2316        /// Its current title.
2317        title: String,
2318    },
2319    /// More than one: magi does not pick between them.
2320    Many(Vec<String>),
2321}
2322
2323#[derive(Debug, Deserialize)]
2324#[serde(rename_all = "camelCase")]
2325struct GhOpenPr {
2326    // `url` and `baseRefName` are required: a record missing either must be a
2327    // parse error, not a pull request that silently fails the base filter and
2328    // reads as "none open" (which would go on to create a duplicate).
2329    url: String,
2330    #[serde(default)]
2331    title: String,
2332    base_ref_name: String,
2333    // A fork's branch of the same name is not the task head; `gh pr list --head`
2334    // matches by branch name alone, so the filter has to be ours.
2335    #[serde(default)]
2336    is_cross_repository: bool,
2337}
2338
2339/// Pure half of [`find_open_pr`]: classify the raw `--json
2340/// number,url,title,baseRefName` output. Entries whose base is not `base` are
2341/// dropped even though the query already filtered on it, so a stub or an old
2342/// `gh` that ignores `--base` cannot get a pull request into the wrong branch
2343/// adopted.
2344pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2345    let raw: Vec<GhOpenPr> =
2346        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2347    let mut hits: Vec<GhOpenPr> = raw
2348        .into_iter()
2349        .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2350        .collect();
2351    Ok(match hits.len() {
2352        0 => OpenPr::None,
2353        1 => {
2354            let p = hits.remove(0);
2355            OpenPr::One {
2356                url: p.url,
2357                title: p.title,
2358            }
2359        }
2360        _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2361    })
2362}
2363
2364/// Open pull requests whose head is `branch` and whose base is `base`. A
2365/// failing `gh` is an error carrying its own output, never "none": guessing
2366/// there is how a duplicate gets created.
2367pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2368    let (ok, out) = gh(
2369        repo,
2370        &[
2371            "pr".to_owned(),
2372            "list".to_owned(),
2373            "--head".to_owned(),
2374            branch.to_owned(),
2375            "--base".to_owned(),
2376            base.to_owned(),
2377            "--state".to_owned(),
2378            "open".to_owned(),
2379            "--json".to_owned(),
2380            "number,url,title,baseRefName,isCrossRepository".to_owned(),
2381        ],
2382    )
2383    .await?;
2384    if !ok {
2385        bail!("gh pr list failed: {out}");
2386    }
2387    pick_open_pr(&out, base)
2388}
2389
2390#[derive(Debug, Deserialize)]
2391#[serde(rename_all = "camelCase")]
2392struct GhPrHead {
2393    head_ref_name: String,
2394    base_ref_name: String,
2395    state: String,
2396    // Required, like `GhOpenPr`'s fields: a record that cannot say whether the
2397    // head lives in a fork must be a parse error, never "same repository".
2398    is_cross_repository: bool,
2399    // Required too: it is what ties the pull request to the commits that were
2400    // actually proven to be on the base.
2401    head_ref_oid: String,
2402}
2403
2404/// Why [`closable`] said no, and whether asking again later could say yes.
2405#[derive(Debug, Clone, PartialEq, Eq)]
2406pub struct Refusal {
2407    /// A later attempt may succeed (the head moved, the view was unreadable);
2408    /// `false` means this pull request is simply not the run's to close.
2409    pub retry: bool,
2410    /// What was wrong.
2411    pub why: String,
2412}
2413
2414impl Refusal {
2415    fn final_(why: String) -> Self {
2416        Self { retry: false, why }
2417    }
2418}
2419
2420/// Pure half of [`close_superseded_pr`]: read `gh pr view --json
2421/// headRefName,baseRefName,state,isCrossRepository` and say whether closing
2422/// is safe, `Err` carrying the reason when it is not.
2423///
2424/// Closing is outward-facing, so every property is checked on what the forge
2425/// says now, not on what magi recorded: the head must be exactly `branch` in
2426/// this repository (a fork's branch of the same name is somebody else's), the
2427/// base must be `base`, and the pull request must still be open.
2428pub fn closable(
2429    json: &str,
2430    branch: &str,
2431    base: &str,
2432    verified: &[String],
2433) -> std::result::Result<(), Refusal> {
2434    let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2435        retry: true,
2436        why: format!("could not read the pull request ({e})"),
2437    })?;
2438    if pr.head_ref_name != branch {
2439        return Err(Refusal::final_(format!(
2440            "its head is `{}`, not this run's `{branch}`",
2441            pr.head_ref_name
2442        )));
2443    }
2444    if pr.is_cross_repository {
2445        return Err(Refusal::final_("its head lives in a fork".to_owned()));
2446    }
2447    if pr.base_ref_name != base {
2448        return Err(Refusal::final_(format!(
2449            "it targets `{}`, not `{base}`",
2450            pr.base_ref_name
2451        )));
2452    }
2453    if !pr.state.eq_ignore_ascii_case("open") {
2454        return Err(Refusal::final_(format!(
2455            "it is already {}",
2456            pr.state.to_ascii_lowercase()
2457        )));
2458    }
2459    // Last, so a pull request that is not this run's at all is reported as
2460    // such. A head that is this branch but not a commit checked against the
2461    // base (somebody pushed since) may well get checked next time: retry.
2462    if !verified.contains(&pr.head_ref_oid) {
2463        return Err(Refusal {
2464            retry: true,
2465            why: format!(
2466                "its head {} is not a commit this run checked against the base",
2467                crate::already::short_sha(&pr.head_ref_oid)
2468            ),
2469        });
2470    }
2471    Ok(())
2472}
2473
2474/// Does `remote` point at something a forge could host - a URL or an scp-style
2475/// `user@host:path` - rather than a filesystem path or nothing at all? Judged
2476/// from the URL alone, so it answers the same on every machine, whatever `gh`
2477/// happens to be installed or logged in to.
2478async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2479    let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2480        return false;
2481    };
2482    is_forge_url(url.trim())
2483}
2484
2485fn is_forge_url(url: &str) -> bool {
2486    url.contains("://") && !url.starts_with("file://")
2487        || url
2488            .split_once(':')
2489            .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2490}
2491
2492/// Does this `gh` failure mean there is no GitHub to ask, as opposed to a
2493/// request that failed?
2494fn forge_unavailable(message: &str) -> bool {
2495    message.contains("known GitHub host") || message.contains("spawn gh")
2496}
2497
2498/// The comment left on a pull request closed because its change is already on
2499/// the base.
2500pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2501    let how = match evidence.proof {
2502        crate::already::Proof::PatchId => format!(
2503            "carried by commit {} on `{base}` with the same patch",
2504            evidence.names()
2505        ),
2506        crate::already::Proof::Ancestry => {
2507            format!("already in the history of `{base}` as {}", evidence.names())
2508        }
2509        crate::already::Proof::Tree => format!(
2510            "already part of `{base}` (merging this branch changes nothing at {})",
2511            crate::already::short_sha(&evidence.tip)
2512        ),
2513    };
2514    format!(
2515        "Closing: everything this branch adds is {how}, so there is nothing left to \
2516         land. This pull request was closed automatically after that was verified; \
2517         reopen it if you disagree."
2518    )
2519}
2520
2521/// Close the open pull request for `branch`, if there is one and it is
2522/// provably this run's, with a comment naming what supersedes it. `Ok(Ok(url))` is
2523/// the URL closed; `Ok(Err(why))` is a final "nothing to close" (no pull request,
2524/// not this run's, no forge); `Err` is anything a later attempt could resolve (a
2525/// failed `gh` call, a head that moved since it was checked), which the caller
2526/// must not treat as settled.
2527///
2528/// The recorded `state.pr` is preferred, else the forge is asked for an open
2529/// pull request on `branch`; either way the candidate is re-read and passed
2530/// through [`closable`] before anything is written; `verified` lists the
2531/// commits proven to be on the base, and the pull request's head must be one.
2532pub async fn close_superseded_pr(
2533    state: &mut RunState,
2534    branch: &str,
2535    evidence: &crate::already::Evidence,
2536    verified: &[String],
2537) -> Result<std::result::Result<String, String>> {
2538    let repo = state.repo.clone();
2539    let base = state.base_branch.clone();
2540    let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2541        Some(p) => p.url.clone(),
2542        // No recorded pull request. A forge that cannot be asked at all (no
2543        // GitHub remote, no `gh`) says nothing about this run, so that is
2544        // "none found"; any other lookup failure is an error, because "could
2545        // not look" is not "nothing there" and the caller must retry.
2546        None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2547            return Ok(Err(
2548                "the remote is not a forge, so there is no pull request".to_owned(),
2549            ));
2550        }
2551        None => match find_open_pr(&repo, branch, &base).await {
2552            Err(e) if forge_unavailable(&format!("{e:#}")) => {
2553                return Ok(Err(format!("no forge to ask: {e:#}")));
2554            }
2555            Err(e) => return Err(e),
2556            Ok(OpenPr::One { url, .. }) => url,
2557            Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2558            Ok(OpenPr::Many(urls)) => {
2559                return Ok(Err(format!(
2560                    "{} open pull requests name it; not choosing between them",
2561                    urls.len()
2562                )));
2563            }
2564        },
2565    };
2566    let (ok, view) = gh(
2567        &repo,
2568        &[
2569            "pr".to_owned(),
2570            "view".to_owned(),
2571            url.clone(),
2572            "--json".to_owned(),
2573            "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2574        ],
2575    )
2576    .await?;
2577    if !ok {
2578        bail!("gh pr view {url} failed: {view}");
2579    }
2580    if let Err(refusal) = closable(&view, branch, &base, verified) {
2581        // A pull request whose head cannot be tied to what was proven is not
2582        // one to walk away from: the caller keeps the run resumable.
2583        if refusal.retry {
2584            bail!("left {url} open: {}", refusal.why);
2585        }
2586        return Ok(Err(format!("left {url} open: {}", refusal.why)));
2587    }
2588    let (ok, out) = gh(
2589        &repo,
2590        &[
2591            "pr".to_owned(),
2592            "close".to_owned(),
2593            url.clone(),
2594            "--comment".to_owned(),
2595            superseded_comment(&base, evidence),
2596        ],
2597    )
2598    .await?;
2599    if !ok {
2600        bail!("gh pr close {url} failed: {out}");
2601    }
2602    if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2603        p.state = "closed".to_owned();
2604    }
2605    Ok(Ok(url))
2606}
2607
2608/// `gh pr edit <url> --title <title>`, for an adopted pull request whose title
2609/// differs from the one this run computed. Only the title: the body may have
2610/// been edited by the owner and cannot be compared.
2611pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2612    let (title, _) = crate::github_text::prepare(state, title, "");
2613    edit_pr_title(repo, url, &title).await
2614}
2615
2616/// [`set_pr_title`] for a title the owner explicitly chose to post unmodified
2617/// (`github_text::POST_ORIGINAL` or `github_text::USE_REWRITE`): the posting
2618/// gate is skipped on purpose.
2619pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2620    edit_pr_title(repo, url, title).await
2621}
2622
2623/// Post both fields of the owner's chosen rewrite to an adopted pull request,
2624/// unmodified and without the posting gate.
2625pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2626    let (ok, _) = gh(
2627        repo,
2628        &[
2629            "pr".to_owned(),
2630            "edit".to_owned(),
2631            url.to_owned(),
2632            "--title".to_owned(),
2633            title.to_owned(),
2634            "--body".to_owned(),
2635            body.to_owned(),
2636        ],
2637    )
2638    .await
2639    .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2640    if !ok {
2641        // Command output may echo the chosen text; keep it out of run events.
2642        bail!("gh pr edit failed while posting the chosen rewrite");
2643    }
2644    Ok(())
2645}
2646
2647async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2648    let (ok, out) = gh(
2649        repo,
2650        &[
2651            "pr".to_owned(),
2652            "edit".to_owned(),
2653            url.to_owned(),
2654            "--title".to_owned(),
2655            title.to_owned(),
2656        ],
2657    )
2658    .await?;
2659    if !ok {
2660        bail!("gh pr edit failed: {out}");
2661    }
2662    Ok(())
2663}
2664
2665/// Ask GitHub whether this run's winning candidate branch was actually merged
2666/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2667/// --merged` exists to close, minus the operator having to find the URL by
2668/// hand.
2669///
2670/// `Ok(None)` covers every case where nothing can be said with confidence: no
2671/// winner decided yet (nothing to check a branch for), no merged pull request
2672/// found, or [`pick_merged_pr`] found more than one candidate and would not
2673/// guess between them. Never wired to a weaker, URL-less signal like
2674/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2675/// separately, precisely because it cannot drive an automatic correction on
2676/// its own (see that function's own doc).
2677pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2678    let Some(winner) = state.winner() else {
2679        return Ok(None);
2680    };
2681    let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2682    let out = gh(
2683        &state.repo,
2684        &[
2685            "pr".to_owned(),
2686            "list".to_owned(),
2687            "--head".to_owned(),
2688            branch.clone(),
2689            "--state".to_owned(),
2690            "merged".to_owned(),
2691            "--json".to_owned(),
2692            "url,number,mergedAt,baseRefName".to_owned(),
2693        ],
2694    )
2695    .await?;
2696    if !out.0 {
2697        bail!("gh pr list --head {branch}: {}", out.1);
2698    }
2699    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2700}
2701
2702/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2703/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2704///
2705/// Deliberately never consulted by [`find_external_merge`]: a base branch
2706/// that has moved since the run started can make an old, abandoned branch
2707/// look like an ancestor of the *current* base for reasons that have nothing
2708/// to do with a merge (a later commit that happens to supersede it, an
2709/// unrelated squash), and there is no pull request URL here to confirm
2710/// against or to land through anyway. Its only honest use is a weaker
2711/// notice — "this looks merged, go check" — never an automatic rewrite of
2712/// `status`/`merge`.
2713pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2714    let out = tokio::process::Command::new("git")
2715        .args(["merge-base", "--is-ancestor", branch, base_branch])
2716        .current_dir(repo)
2717        .quiet()
2718        .stdin(std::process::Stdio::null())
2719        .output()
2720        .await
2721        .context("spawn git merge-base --is-ancestor")?;
2722    Ok(out.status.success())
2723}
2724
2725/// Parse `host/owner/repo` out of a forge URL, with no network access.
2726///
2727/// The host is part of the slug, not discarded: `owner/repo` alone would
2728/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2729/// repository, which is exactly the mix-up the same-repo guard exists to
2730/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2731/// shape at all.
2732fn forge_slug(url: &str) -> Option<(String, &str)> {
2733    let rest = url.rsplit("://").next()?;
2734    let (host, path) = rest.split_once('/')?;
2735    if host.is_empty() {
2736        return None;
2737    }
2738    Some((host.to_ascii_lowercase(), path))
2739}
2740
2741/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2742/// access - the first half of the same-repo guard [`correct_manual_merge`]
2743/// applies before it writes anything.
2744///
2745/// Returns `None` for anything that does not look like
2746/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2747/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2748pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2749    let (host, path) = forge_slug(url)?;
2750    let mut segments = path.split('/');
2751    let owner = segments.next()?;
2752    let repo = segments.next()?;
2753    let kind = segments.next()?;
2754    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2755        return None;
2756    }
2757    Some(format!("{host}/{owner}/{repo}"))
2758}
2759
2760/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2761/// suffix), the shape `gh repo view --json url` returns - the other half of
2762/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2763fn slug_of_repo_url(url: &str) -> Option<String> {
2764    let (host, path) = forge_slug(url)?;
2765    let mut segments = path.split('/');
2766    let owner = segments.next()?;
2767    let repo = segments.next()?;
2768    if owner.is_empty() || repo.is_empty() {
2769        return None;
2770    }
2771    Some(format!("{host}/{owner}/{repo}"))
2772}
2773
2774/// Refuse to correct a run against a pull request from a different
2775/// repository than the one it is recorded against.
2776///
2777/// This is the guard the shun/8c75 incident argued for: an operator ran
2778/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2779/// in a different repository, omitted the run id, and the id defaulted to
2780/// this machine's most recently created run - an unrelated, still-in-progress
2781/// run in a completely different repository - which then had its `status`
2782/// rewritten to `merged` from a pull request it had nothing to do with.
2783/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2784/// CLI help), but a mistyped or stale id could still name a run in a
2785/// different repository than the one the URL belongs to, so this checks that
2786/// independently rather than trusting the id alone.
2787///
2788/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2789/// mismatch names both slugs rather than just refusing, so an operator whose
2790/// local checkout's `origin` is a fork of the repository the pull request was
2791/// opened against (a legitimate setup this cannot tell apart from a genuine
2792/// mix-up) can judge for themselves rather than being blocked with no way to
2793/// see why.
2794pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2795    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2796        return Ok(());
2797    }
2798    bail!(
2799        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2800         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2801         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2802         verify by hand before treating this as a false positive)"
2803    );
2804}
2805
2806/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2807/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2808///
2809/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2810/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2811/// repository the same way GitHub itself would recognize it, so the
2812/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2813/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2814/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2815/// a same-named one on a GitHub Enterprise host.
2816async fn repo_slug(repo: &Path) -> Result<String> {
2817    let out = gh(
2818        repo,
2819        &[
2820            "repo".to_owned(),
2821            "view".to_owned(),
2822            "--json".to_owned(),
2823            "url".to_owned(),
2824        ],
2825    )
2826    .await?;
2827    if !out.0 {
2828        bail!("gh repo view --json url: {}", out.1);
2829    }
2830    #[derive(Debug, Deserialize)]
2831    struct GhRepo {
2832        url: String,
2833    }
2834    let parsed: GhRepo = serde_json::from_str(&out.1)
2835        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2836    slug_of_repo_url(&parsed.url)
2837        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2838}
2839
2840/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2841/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2842/// would have written them had magi opened and merged this pull request
2843/// itself.
2844///
2845/// This is `magi fold --merged`'s whole implementation, and also what the
2846/// web `fold-merged` route calls once it has a URL in hand — an operator
2847/// recovery path for a merge magi could not finish on its own: a PR title too
2848/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2849/// closed by hand with a pull request magi never opened and so never
2850/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2851/// directly keeps this one authoritative: a merged pull request decides
2852/// `Step::Done { merged: true }` on the very first read, before any of
2853/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2854/// safe to call here even though this pull request was never magi's own.
2855///
2856/// [`ensure_same_repo`] is checked before anything else: a pull request from
2857/// a different repository than the one `state` is recorded against is
2858/// refused outright, regardless of its lifecycle. This is the guard for a
2859/// URL an *operator* hands in - the CLI or the web route - where a stale or
2860/// mistyped run id could otherwise get corrected from an unrelated
2861/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2862/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2863/// goes through [`correct_confirmed_external_merge`] instead, which skips
2864/// this check: its `url` was never operator-supplied, it comes from
2865/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2866/// it is already guaranteed to name a pull request in that same repository -
2867/// re-deriving and re-checking the repository here would only be a second
2868/// `gh repo view` call that can fail for reasons that have nothing to do with
2869/// correctness (a rate limit, a network blip), turning a self-heal that would
2870/// otherwise have succeeded into a run left `Blocked` for another pass.
2871///
2872/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2873/// URL fails loudly without writing anything, rather than handing an open
2874/// pull request to the full autonomous loop by accident.
2875///
2876/// Correcting `status` this way does not run `bump::after_merge`
2877/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2878/// which this path never goes through. A release version bump the change
2879/// might have earned is therefore not filed automatically and has to be
2880/// requested by hand - recorded as an event on the run so the gap is visible
2881/// to whoever reads it later, not just wherever this was called from. Follow-up
2882/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2883///
2884/// Returns the status before and after, so every caller (CLI, janitor, web
2885/// route) can build its own log line or response from the same pair rather
2886/// than each re-deriving it.
2887pub async fn correct_manual_merge(
2888    state: &mut RunState,
2889    url: &str,
2890) -> Result<(RunStatus, RunStatus)> {
2891    let Some(pr_slug) = slug_of_pr_url(url) else {
2892        bail!(
2893            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2894             this pull request belongs to"
2895        );
2896    };
2897    let run_slug = repo_slug(&state.repo).await?;
2898    ensure_same_repo(&run_slug, &pr_slug)?;
2899    correct_merge(state, url).await
2900}
2901
2902/// The janitor's own entry point into the same correction
2903/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2904/// same-repo guard - see that function's own doc for why skipping it here is
2905/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2906/// calls this with a `url` [`find_external_merge`] already found by querying
2907/// `state.repo`'s own remote, so the guard could never do anything here but
2908/// fail on its own transient errors.
2909///
2910/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2911/// because it lives in a different module.
2912pub(crate) async fn correct_confirmed_external_merge(
2913    state: &mut RunState,
2914    url: &str,
2915) -> Result<(RunStatus, RunStatus)> {
2916    correct_merge(state, url).await
2917}
2918
2919/// Same pull request, same repository: the url, or the number within one repo.
2920fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2921    let Some(pr) = a.pr.as_ref() else {
2922        return false;
2923    };
2924    if !url.is_empty()
2925        && pr
2926            .url
2927            .trim_end_matches('/')
2928            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2929    {
2930        return true;
2931    }
2932    number > 0
2933        && pr.number == number
2934        && match (a.repo.canonicalize(), repo.canonicalize()) {
2935            (Ok(x), Ok(y)) => x == y,
2936            _ => a.repo == repo,
2937        }
2938}
2939
2940/// Rewrite `pr.state` to a final state on every run record under `home` that
2941/// `decide` picks, and report how many were rewritten.
2942///
2943/// Lifecycle writes through to other runs are deliberately narrow (task-head
2944/// ownership is recorded separately): only a **terminal** run (never one a
2945/// driver may still be writing), only a proven-stopped driver with no daemon
2946/// claim (the shared liveness judge), only a record
2947/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2948/// The record is read as folding reads it (no schema check: every bump so far
2949/// only added fields, and the whole struct round-trips) and written through
2950/// [`RunState::save_under`], the path every record uses, so nothing but
2951/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2952/// A run that cannot be read or written is skipped with a warning.
2953fn rewrite_open_prs(
2954    home: &Path,
2955    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2956) -> usize {
2957    let now = Timestamp::now();
2958    let mut changed = 0;
2959    for id in crate::run::list_ids_in(&home.join("runs")) {
2960        let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2961            continue;
2962        };
2963        let path = home.join("runs").join(&id).join("run.json");
2964        let Ok(body) = std::fs::read_to_string(&path) else {
2965            continue;
2966        };
2967        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2968            continue;
2969        };
2970        if !state.status.done()
2971            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2972            || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2973                != crate::run::Liveness::Dead
2974        {
2975            continue;
2976        }
2977        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2978            continue;
2979        };
2980        if let Some(pr) = state.pr.as_mut() {
2981            pr.state = to.as_str().to_owned();
2982        }
2983        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2984        state.event(
2985            "land",
2986            format!("recorded {url} as {}: another run settled it", to.as_str()),
2987        );
2988        match state.save_under(home) {
2989            Ok(()) => changed += 1,
2990            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2991        }
2992    }
2993    changed
2994}
2995
2996/// A run reached a final state for its pull request: tell every other terminal
2997/// run in the same repository that names the same pull request (handed-over
2998/// predecessors, blocked attempts, anything), so their records stop saying
2999/// `open`. Best effort; `run`'s own record is the caller's.
3000pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
3001    if to == PrLifecycle::Open {
3002        return;
3003    }
3004    let Some(home) = crate::run::try_home() else {
3005        return;
3006    };
3007    write_pr_state_through_in(&home, run, to);
3008}
3009
3010pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
3011    let Some(pr) = run.pr.as_ref() else {
3012        return 0;
3013    };
3014    let (url, number) = (pr.url.clone(), pr.number);
3015    rewrite_open_prs(home, &mut |other| {
3016        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
3017    })
3018}
3019
3020/// Terminal runs whose record still says their pull request is open, as
3021/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
3022pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
3023    let now = Timestamp::now();
3024    let mut out = Vec::new();
3025    for id in crate::run::list_ids_in(&home.join("runs")) {
3026        let path = home.join("runs").join(&id).join("run.json");
3027        let Ok(body) = std::fs::read_to_string(&path) else {
3028            continue;
3029        };
3030        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
3031            continue;
3032        };
3033        if let Some(pr) = state.pr.as_ref()
3034            && state.status.done()
3035            && pr.state == "open"
3036            && !pr.url.is_empty()
3037            && !crate::daemon::is_working_on(home, &id, now)
3038        {
3039            out.push((id, state.repo.clone(), pr.url.clone()));
3040        }
3041    }
3042    out
3043}
3044
3045/// Apply forge answers (`pr url -> state`) to every stale terminal record.
3046/// A url with no answer (the forge was unreadable) changes nothing.
3047pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
3048    rewrite_open_prs(home, &mut |s| {
3049        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
3050    })
3051}
3052
3053/// One-time (and idempotent) repair of records that froze an `open` pull
3054/// request: ask the forge about each distinct pull request that a terminal run
3055/// still calls open - at most `max_lookups` of them - and rewrite the merged
3056/// and closed ones. A genuinely open pull request is left alone, and a lookup
3057/// that fails is "unknown, change nothing". Returns `(records rewritten,
3058/// lookups that failed)`.
3059pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
3060    let mut known = BTreeMap::new();
3061    let mut failed = 0;
3062    let mut seen = BTreeSet::new();
3063    for (_, repo, url) in stale_open_prs(home) {
3064        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
3065            continue;
3066        }
3067        match lifecycle(&repo, &url).await {
3068            Ok(state) => {
3069                known.insert(url, state);
3070            }
3071            Err(e) => {
3072                tracing::warn!("repair pr state of {url}: {e:#}");
3073                failed += 1;
3074            }
3075        }
3076    }
3077    (apply_pr_states(home, &known), failed)
3078}
3079
3080async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
3081    match lifecycle(&state.repo, url).await? {
3082        PrLifecycle::Merged => {}
3083        other => bail!(
3084            "{url} is {}, not merged; refusing to record {} as merged on a guess",
3085            other.as_str(),
3086            state.id
3087        ),
3088    }
3089    let before = state.status;
3090    if let Err(e) = land(state, url).await {
3091        // `land` sets `status` to `Landing` and saves before its first read
3092        // of the pull request — see its own doc — so a failure here (a
3093        // transient `gh` hiccup between the two forge reads this function
3094        // makes) can leave the run stuck on that in-between value with
3095        // nothing left driving it. Land it on the same terminal shape an
3096        // automated `land` failure lands on instead of leaving it stuck.
3097        state.status = RunStatus::Blocked;
3098        state.event("fold", format!("manual-merge correction failed: {e:#}"));
3099        state.save()?;
3100        return Err(e).context(format!("confirming the merge of {url}"));
3101    }
3102    state.event(
3103        "fold",
3104        "operator recorded this pull request as a manual merge; this run never \
3105         re-entered `land`, so `bump::after_merge` did not run for it - a release \
3106         bump this change might warrant has to be filed by hand",
3107    );
3108    // Unlike the bump, the findings the merge left open are filed on this
3109    // path too: nothing else would ever carry them forward.
3110    if state.status == RunStatus::Merged {
3111        crate::followup::after_merge(state, url).await;
3112    }
3113    state.save()?;
3114    Ok((before, state.status))
3115}
3116
3117/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
3118/// comments. No I/O.
3119///
3120/// `gh pr view` does not surface inline comments, and inline is exactly where
3121/// both review bots put their findings - a landing loop that read only the
3122/// top-level thread would never see the thing it is supposed to fix.
3123pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3124    let raw: Vec<GhInline> =
3125        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3126    let mut out = Vec::new();
3127    for c in raw {
3128        push_if_outstanding(
3129            &mut out,
3130            ReviewComment {
3131                author: c.user.login,
3132                path: c.path,
3133                line: c.line,
3134                body: c.body,
3135            },
3136        );
3137    }
3138    Ok(out)
3139}
3140
3141/// Keep a comment only when it asks for something.
3142///
3143/// An inline comment always does: it names a file and a line. A top-level
3144/// comment is dropped when it is empty, when it is magi's own, or when it is
3145/// [noise](is_noise).
3146fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3147    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3148        return;
3149    }
3150    if comment.path.is_none() && is_noise(&comment.body) {
3151        return;
3152    }
3153    out.push(comment);
3154}
3155
3156/// Is this comment body machinery rather than a finding?
3157///
3158/// Two tests, both structural, because guessing from prose is how a "looks
3159/// good to me" turns into a fix round:
3160///
3161/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
3162///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
3163///    footer.
3164/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
3165///    horizontal rules, and the bot's own status banner are removed, every
3166///    remaining line is a task-list item. That is exactly the shape of the
3167///    comment the Claude review job posts while it is still working.
3168///
3169/// Anything else is input, including bot prose. A bot that writes a paragraph
3170/// has said something, and the fix prompt tells the fixer it may decline a
3171/// comment with an argument - a wasted sentence in a prompt is cheaper than a
3172/// missed finding.
3173pub fn is_noise(body: &str) -> bool {
3174    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3175        return true;
3176    }
3177    let mut content = false;
3178    for line in strip_blocks(body).lines() {
3179        let line = unquote(line);
3180        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3181            continue;
3182        }
3183        content = true;
3184        break;
3185    }
3186    !content
3187}
3188
3189/// Remove HTML comments and collapsed `<details>` blocks.
3190fn strip_blocks(body: &str) -> String {
3191    let mut out = String::with_capacity(body.len());
3192    let mut rest = body;
3193    loop {
3194        let open = ["<!--", "<details>"]
3195            .iter()
3196            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3197            .min_by_key(|(i, _)| *i);
3198        let Some((at, tag)) = open else {
3199            out.push_str(rest);
3200            return out;
3201        };
3202        out.push_str(&rest[..at]);
3203        let after = &rest[at + tag.len()..];
3204        let close = if tag == "<!--" { "-->" } else { "</details>" };
3205        match after.find(close) {
3206            Some(end) => rest = &after[end + close.len()..],
3207            // Unterminated: the rest of the body is inside the block.
3208            None => return out,
3209        }
3210    }
3211}
3212
3213/// Strip blockquote markers, which both bots wrap their callouts in.
3214fn unquote(line: &str) -> &str {
3215    let mut s = line.trim();
3216    while let Some(rest) = s.strip_prefix('>') {
3217        s = rest.trim_start();
3218    }
3219    s.trim()
3220}
3221
3222/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
3223fn is_checklist(line: &str) -> bool {
3224    let rest = line
3225        .strip_prefix("- ")
3226        .or_else(|| line.strip_prefix("* "))
3227        .unwrap_or("");
3228    let rest = rest.trim_start();
3229    matches!(
3230        rest.get(..3),
3231        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3232    )
3233}
3234
3235/// A heading, a horizontal rule, or a callout tag - shape, never content.
3236fn is_decoration(line: &str) -> bool {
3237    line.starts_with('#')
3238        || line.starts_with("[!")
3239        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3240}
3241
3242/// A line that is nothing but emphasis and links.
3243///
3244/// Both review jobs open with a status banner
3245/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
3246/// to a line-based test and asks for nothing, so it is measured the same way a
3247/// heading is: strip the markup, and if no word survives, it was decoration.
3248fn is_banner(line: &str) -> bool {
3249    let plain = drop_spans(line, "**", "**");
3250    let plain = if plain.contains("](") {
3251        drop_spans(&plain, "[", ")")
3252    } else {
3253        plain
3254    };
3255    !plain.chars().any(char::is_alphanumeric)
3256}
3257
3258/// Remove every `open` .. `close` span, including the delimiters. An
3259/// unterminated span swallows the rest of the input, which is what a reader
3260/// sees too.
3261fn drop_spans(s: &str, open: &str, close: &str) -> String {
3262    let mut out = String::with_capacity(s.len());
3263    let mut rest = s;
3264    while let Some(at) = rest.find(open) {
3265        out.push_str(&rest[..at]);
3266        let after = &rest[at + open.len()..];
3267        match after.find(close) {
3268            Some(end) => rest = &after[end + close.len()..],
3269            None => return out,
3270        }
3271    }
3272    out.push_str(rest);
3273    out
3274}
3275
3276/// The lock that keeps at most one run per repository actually moving the
3277/// base branch at a time: a rebase push, or `gh pr merge`.
3278///
3279/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
3280/// running a fix round in the winner's own worktree, waiting on the owner's
3281/// approval - touches nothing a *different* run in the same repository could
3282/// collide with, and holding a lock across any of that would serialise one
3283/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
3284/// resume, which is precisely the "must not wait on another task" property
3285/// the daemon's slot-freeing exists to give a resume. Only the two moments
3286/// that actually write to the shared base branch need mutual exclusion, and
3287/// both are brief.
3288///
3289/// One entry per repository, each its own `tokio::sync::Mutex`, so two
3290/// different repositories' runs never wait on each other. The outer
3291/// `std::sync::Mutex` guards only the map itself, held long enough to find or
3292/// insert an entry and clone its `Arc`, never across an `.await`.
3293fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3294    static LOCKS: std::sync::LazyLock<
3295        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3296    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3297    LOCKS
3298        .lock()
3299        .unwrap_or_else(std::sync::PoisonError::into_inner)
3300        .entry(repo.to_path_buf())
3301        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3302        .clone()
3303}
3304
3305/// `owner/repo` out of a pull request url, falling back to the checkout's
3306/// directory name when the url is not the usual `host/owner/repo/pull/N`.
3307fn repo_label(repo: &Path, pr_url: &str) -> String {
3308    let parts: Vec<&str> = pr_url.split('/').collect();
3309    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3310        && at >= 2
3311        && !parts[at - 1].is_empty()
3312        && !parts[at - 2].is_empty()
3313    {
3314        return format!("{}/{}", parts[at - 2], parts[at - 1]);
3315    }
3316    repo.file_name()
3317        .map(|n| n.to_string_lossy().into_owned())
3318        .unwrap_or_default()
3319}
3320
3321/// The operator-facing sentence for a merge that went ahead with red checks,
3322/// or `None` when the checks were not red. Judged on `checks`, not on
3323/// `failing`, which can be non-empty on a green observation.
3324fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3325    (pr.checks == Checks::Red).then(|| {
3326        let details = crate::notices::format_list(&pr.failing)
3327            .map(|names| format!(": {names}"))
3328            .unwrap_or_default();
3329        format!(
3330            "Merged {repo_name} PR #{} with red checks{details} ({})",
3331            pr.number, pr.url
3332        )
3333    })
3334}
3335
3336/// After a merge that succeeded: record which checks were red and tell the
3337/// operator. The decision to merge is already made; this only makes it audible.
3338/// Best-effort - a broken notifier never fails the run.
3339async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3340    let repo_name = repo_label(&state.repo, &pr.url);
3341    let Some(summary) = red_merge_summary(&repo_name, pr) else {
3342        return;
3343    };
3344    if let Some(rec) = state.pr.as_mut() {
3345        rec.red_at_merge = pr.failing.clone();
3346    }
3347    state.event("land", summary.clone());
3348    // The notice pages through `[notify]` itself, once, unless a question
3349    // already carries the cause.
3350    crate::notices::raise_with(
3351        crate::notices::merged_red(&state.id, &summary),
3352        &state.config.notify,
3353    );
3354}
3355
3356/// Run the loop against a real pull request until it merges or the budget runs
3357/// out.
3358///
3359/// The caller decides whether landing happens at all: this is only reached when
3360/// `graph.land` is on. Returns the last observation, so the caller can report
3361/// what magi was looking at when it stopped.
3362pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3363    land_with(state, pr_url, &GhForge).await
3364}
3365
3366/// The forge calls whose timing the loop's decisions depend on, behind a seam
3367/// so a test can script what the pull request looks like from one observation
3368/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
3369trait Forge {
3370    async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3371        let (ok, out) = gh(
3372            repo,
3373            &[
3374                "pr".into(),
3375                "close".into(),
3376                pr_url.into(),
3377                "--comment".into(),
3378                comment.into(),
3379            ],
3380        )
3381        .await?;
3382        if !ok {
3383            bail!("PR closure failed: {out}");
3384        }
3385        Ok(())
3386    }
3387    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3388    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3389    async fn poll(&self);
3390    /// Rerun the failed jobs of one workflow run.
3391    async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3392    /// Unix seconds, behind the seam so a test owns the clock.
3393    fn now(&self) -> i64;
3394    /// The check contexts the base branch requires, for a stop reason only.
3395    /// `None` when they could not be read, which is not the same as none.
3396    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3397    #[allow(clippy::too_many_arguments)]
3398    async fn fix(
3399        &self,
3400        state: &mut RunState,
3401        pr: &PrState,
3402        round: usize,
3403        budget: usize,
3404        reason: &str,
3405        logs: &str,
3406    ) -> Result<Fixed>;
3407}
3408
3409struct GhForge;
3410
3411impl Forge for GhForge {
3412    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3413        observe(repo, pr_url).await
3414    }
3415    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3416        gh(repo, argv).await
3417    }
3418    async fn poll(&self) {
3419        tokio::time::sleep(POLL).await;
3420    }
3421    async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3422        let args = [
3423            "run".to_owned(),
3424            "rerun".to_owned(),
3425            run.to_owned(),
3426            "--failed".to_owned(),
3427        ];
3428        let (ok, out) = gh(repo, &args).await?;
3429        if !ok {
3430            bail!("gh run rerun {run}: {out}");
3431        }
3432        Ok(())
3433    }
3434    fn now(&self) -> i64 {
3435        jiff::Timestamp::now().as_second()
3436    }
3437    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3438        required_contexts_of(repo, base).await
3439    }
3440    async fn fix(
3441        &self,
3442        state: &mut RunState,
3443        pr: &PrState,
3444        round: usize,
3445        budget: usize,
3446        reason: &str,
3447        logs: &str,
3448    ) -> Result<Fixed> {
3449        fix_round(state, pr, round, budget, reason, logs).await
3450    }
3451}
3452
3453/// Percent-encode a branch name for a URL path segment (`/` included).
3454fn encode_path_segment(s: &str) -> String {
3455    let mut out = String::new();
3456    for b in s.bytes() {
3457        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3458            out.push(b as char);
3459        } else {
3460            let _ = write!(out, "%{b:02X}");
3461        }
3462    }
3463    out
3464}
3465
3466/// Read the required contexts of `base` from classic branch protection and
3467/// from rulesets, once, for a stop reason. Organisation-level rulesets that
3468/// these two endpoints do not list are missed. Any unreadable source makes the
3469/// whole answer `None`: a partial set would read as a complete one.
3470async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3471    let enc = encode_path_segment(base);
3472    let mut all = BTreeSet::new();
3473    // Classic protection answers 404 for an unprotected branch, which is
3474    // "nothing required here", not a failure to read.
3475    let classic = gh(
3476        repo,
3477        &[
3478            "api".to_owned(),
3479            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3480        ],
3481    )
3482    .await
3483    .ok()?;
3484    if classic.0 {
3485        all.extend(parse_classic_required(&classic.1)?);
3486    } else if !classic.1.contains("404") {
3487        return None;
3488    }
3489    let rules = gh(
3490        repo,
3491        &[
3492            "api".to_owned(),
3493            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3494        ],
3495    )
3496    .await
3497    .ok()?;
3498    if !rules.0 {
3499        return None;
3500    }
3501    all.extend(parse_ruleset_required(&rules.1)?);
3502    Some(all)
3503}
3504
3505/// `required_status_checks` of classic protection: `contexts` plus the
3506/// `checks[].context` form.
3507fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3508    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3509    let mut out = BTreeSet::new();
3510    for c in v.get("contexts")?.as_array()? {
3511        out.insert(c.as_str()?.to_owned());
3512    }
3513    for c in v
3514        .get("checks")
3515        .and_then(|c| c.as_array())
3516        .into_iter()
3517        .flatten()
3518    {
3519        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3520            out.insert(name.to_owned());
3521        }
3522    }
3523    Some(out)
3524}
3525
3526/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
3527fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3528    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3529    let mut out = BTreeSet::new();
3530    for rule in v.as_array()? {
3531        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3532            continue;
3533        }
3534        let checks = rule
3535            .pointer("/parameters/required_status_checks")?
3536            .as_array()?;
3537        for c in checks {
3538            out.insert(c.get("context")?.as_str()?.to_owned());
3539        }
3540    }
3541    Some(out)
3542}
3543
3544/// Is the observation older than the commit a fix round pushed?
3545///
3546/// The forge takes a few seconds to move the pull request to a new head and
3547/// attach that head's check runs, and until it has, the rollup is the previous
3548/// head's - all green, which is exactly what a merge decision must not read.
3549/// An absent or different head counts as not yet: guessing "close enough"
3550/// would reopen the hole.
3551fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3552    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3553}
3554
3555/// The commit an observation may be decided on, or `None` while it cannot be
3556/// trusted to describe one.
3557///
3558/// `None` when a pushed commit is awaited and the pull request is not on it,
3559/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3560/// last commit's) is not the head's: that is the previous commit's checks. The
3561/// caller re-polls on `None`. A rollup that cannot be read (including one
3562/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3563/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3564fn bound_head<'a>(
3565    seen_head: &'a str,
3566    rollup_head: &str,
3567    awaiting: Option<&str>,
3568) -> Option<&'a str> {
3569    if seen_head.is_empty()
3570        || awaiting_new_head(awaiting, seen_head)
3571        || !rollup_head.eq_ignore_ascii_case(seen_head)
3572    {
3573        return None;
3574    }
3575    Some(seen_head)
3576}
3577
3578/// What a refused `gh pr merge` means.
3579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3580enum Refused {
3581    /// The branch policy is not satisfied *yet*: go back to waiting.
3582    Pending,
3583    /// Checks have settled and the merge state still says no, seen for the
3584    /// first time. The forge updates `mergeStateStatus` a moment after the last
3585    /// check finishes, so one more look is allowed before believing it.
3586    Recheck,
3587    /// Nothing is in flight and the policy still refuses: a person has to
3588    /// supply what it asks for (a review, say).
3589    Final,
3590}
3591
3592/// Judged from the pull request's state after the refusal, never from the
3593/// refusal's wording, which belongs to the forge and changes.
3594fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3595    let Some(after) = after else {
3596        // Unreadable is not evidence of anything; the next loop reads again.
3597        return Refused::Pending;
3598    };
3599    if after.pr.state != PrLifecycle::Open {
3600        return Refused::Final;
3601    }
3602    // The branch moved after it was observed (the forge refuses a merge pinned
3603    // to the old commit): not a verdict on anything, look again.
3604    if !after.head.eq_ignore_ascii_case(observed_head) {
3605        return Refused::Pending;
3606    }
3607    // The same binding the loop applies: checks of another commit say nothing.
3608    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3609        return Refused::Pending;
3610    }
3611    let state = after.merge_state.to_ascii_uppercase();
3612    if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3613        || state.is_empty()
3614        || state == "UNKNOWN"
3615    {
3616        return Refused::Pending;
3617    }
3618    if rechecked {
3619        Refused::Final
3620    } else {
3621        Refused::Recheck
3622    }
3623}
3624
3625/// Take auto-merge back from the forge and forget that it was armed.
3626///
3627/// `Err` carries the forge's message: the caller must not push or move on, as
3628/// an armed merge left behind could still fire for a commit nobody approved.
3629async fn disarm<F: Forge>(
3630    forge: &F,
3631    state: &mut RunState,
3632    repo: &Path,
3633    number: u64,
3634) -> std::result::Result<(), String> {
3635    let argv = disable_automerge_argv(number);
3636    let out = {
3637        let merge_lock = repo_merge_lock(repo);
3638        let _merge_slot = merge_lock.lock().await;
3639        forge.merge(repo, &argv).await
3640    };
3641    match out {
3642        Ok((true, _)) => {
3643            state.land_armed_head = None;
3644            state.event("land", "auto-merge disabled");
3645            state.save().map_err(|e| format!("{e:#}"))?;
3646            Ok(())
3647        }
3648        Ok((false, msg)) => Err(msg),
3649        Err(e) => Err(format!("{e:#}")),
3650    }
3651}
3652
3653/// [`stop`], first taking back an armed auto-merge so a pull request magi
3654/// gave up on does not merge on its own later. A failure to disarm is added
3655/// to the reason rather than hiding it.
3656async fn stop_disarmed<F: Forge>(
3657    forge: &F,
3658    state: &mut RunState,
3659    repo: &Path,
3660    pr: &PrState,
3661    why: &str,
3662) -> Result<()> {
3663    if state.land_armed_head.is_none() {
3664        return stop(state, repo, pr, why).await;
3665    }
3666    match disarm(forge, state, repo, pr.number).await {
3667        Ok(()) => stop(state, repo, pr, why).await,
3668        Err(e) => {
3669            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3670            stop(state, repo, pr, &why).await
3671        }
3672    }
3673}
3674
3675/// Check the persisted card again around awaits that can outlive an owner tap.
3676fn owner_requested_stop(state: &RunState) -> Result<bool> {
3677    let Some(binding) = &state.land_approval else {
3678        return Ok(false);
3679    };
3680    let q = ask::Questions::open().get(&binding.question)?;
3681    Ok(q.run == state.id
3682        && q.node == APPROVAL_NODE
3683        && q.resolution()
3684            .as_deref()
3685            .is_some_and(crate::merge_decision::stops))
3686}
3687
3688/// Read stop choices before checks, repair or rebase. These are about stopping
3689/// ownership, so a changed head never cancels them as it cancels merge consent.
3690async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3691    use crate::merge_decision as md;
3692    let Some(binding) = &state.land_approval else {
3693        return Ok(false);
3694    };
3695    let q = ask::Questions::open().get(&binding.question)?;
3696    let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3697        return Ok(false);
3698    };
3699    if q.run != state.id || q.node != APPROVAL_NODE {
3700        bail!("stop decision names another run");
3701    }
3702    let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3703        pr: pr.url.clone(),
3704        head: binding.head.clone(),
3705        language: state.config.graph.language.clone(),
3706    });
3707    if target.pr != pr.url {
3708        bail!("stop decision names another PR");
3709    }
3710    if state.merge_decision.is_none() {
3711        state.merge_decision = Some(md::Decision {
3712            question: q.id.clone(),
3713            target,
3714            choice: choice.clone(),
3715            closed: false,
3716            replacement_run: None,
3717            replacement_pr: None,
3718        });
3719        state.save()?;
3720    }
3721    let repo = state.repo.clone();
3722    // Always disable, including arms left by an older driver.
3723    if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3724        stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3725        return Ok(true);
3726    }
3727    if choice.eq_ignore_ascii_case(HOLD) {
3728        stop(
3729            state,
3730            &repo,
3731            pr,
3732            "owner held the decision: PR left open; no further repair or merge work",
3733        )
3734        .await?;
3735        return Ok(true);
3736    }
3737    if md::selected_agent(&choice).is_some()
3738        || state
3739            .origin
3740            .as_ref()
3741            .and_then(|o| o.task.as_ref())
3742            .is_some()
3743    {
3744        let owners: Vec<_> = crate::queue::Queue::open()
3745            .list()
3746            .into_iter()
3747            .filter(|task| task.runs.last() == Some(&state.id))
3748            .collect();
3749        let owner = (owners.len() == 1).then(|| &owners[0]);
3750        if !owner.is_some_and(|task| {
3751            task.runs.last() == Some(&state.id)
3752                && (!task
3753                    .operator_decided_at
3754                    .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3755                    || task.resumption.as_ref().is_some_and(|release| {
3756                        release.question_id.as_deref() == Some(q.id.as_str())
3757                            && task.operator_decided_at == Some(release.at)
3758                    }))
3759        }) {
3760            stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3761            return Ok(true);
3762        }
3763    }
3764    if pr.state == PrLifecycle::Merged {
3765        stop(
3766            state,
3767            &repo,
3768            pr,
3769            "PR already merged; withdrawal/replacement cannot be applied",
3770        )
3771        .await?;
3772        return Ok(true);
3773    }
3774    let comment = format!(
3775        "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3776        q.id, choice, state.id
3777    );
3778    if pr.state == PrLifecycle::Open {
3779        if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3780            stop(
3781                state,
3782                &repo,
3783                pr,
3784                &format!(
3785                    "owner requested closure but it failed: {e}; task held, no replacement started"
3786                ),
3787            )
3788            .await?;
3789            return Ok(true);
3790        }
3791    }
3792    let confirmed = forge.view(&repo, &pr.url).await?;
3793    if confirmed.pr.state != PrLifecycle::Closed {
3794        stop(
3795            state,
3796            &repo,
3797            pr,
3798            "PR closure was not confirmed; task held, no replacement started",
3799        )
3800        .await?;
3801        return Ok(true);
3802    }
3803    *pr = confirmed.pr;
3804    state.merge_decision.as_mut().unwrap().closed = true;
3805    if let Some(record) = &mut state.pr {
3806        record.state = "closed".into();
3807    }
3808    state.status = RunStatus::Superseded;
3809    state.merge = Some(MergeOutcome {
3810        mode: MergeMode::Pr,
3811        ok: false,
3812        detail: format!("owner decision: {choice}; PR closure confirmed"),
3813        empty: false,
3814    });
3815    state.event(
3816        "land",
3817        format!(
3818            "{} applied: PR closure confirmed; old execution retired",
3819            choice
3820        ),
3821    );
3822    state.save()?;
3823    Ok(true)
3824}
3825
3826/// Keep an unstarted verification resumable without charging a task attempt.
3827fn check_landing_resources(
3828    state: &mut RunState,
3829    node: &str,
3830    outcomes: &[crate::run::CommandOutcome],
3831) -> Result<()> {
3832    if let Some(blocked) = outcomes.iter().find(|outcome| outcome.resource_blocked) {
3833        let detail = format!("{}: {}", blocked.command, blocked.output_tail);
3834        state.resource_block = Some(crate::run::ResourceBlock {
3835            node: node.into(),
3836            kind: "shared build cache".into(),
3837            detail: detail.clone(),
3838            at: jiff::Timestamp::now(),
3839        });
3840        state.event("resource", &detail);
3841        state.save()?;
3842        bail!("landing verification could not start: {detail}");
3843    }
3844    Ok(())
3845}
3846
3847/// Gate the local winner and publish that exact SHA, retaining the saved lease.
3848/// A push completed before a crash is recoverable only when its SHA was gated.
3849async fn publish_landing_head<F: Forge>(
3850    state: &mut RunState,
3851    observed: &str,
3852    pr_url: &str,
3853    forge: &F,
3854) -> Result<String> {
3855    crate::pr_head::ensure_owner(state)?;
3856    let winner = state.winner().cloned().context("landing has no winner")?;
3857    let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3858    let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3859    anyhow::ensure!(
3860        observed == expected
3861            || (observed == head
3862                && state.gate_head.as_deref() == Some(&head)
3863                && state.gate_allows_progress()),
3864        "public head moved outside this run; refusing to overwrite it"
3865    );
3866    anyhow::ensure!(
3867        git::is_clean(&winner.worktree).await?,
3868        "landing worktree is dirty"
3869    );
3870    if state.gate_head.as_deref() != Some(&head) || !state.gate_ran || !state.gate_allows_progress()
3871    {
3872        state.gate.clear();
3873        state.gate_head = None;
3874        state.gate_ran = false;
3875        state.pre_gate.clear();
3876        state.pre_gate_commit = None;
3877        state.save()?;
3878        let inherited = crate::repo_lock::WaitPolicy::current();
3879        let wait = crate::repo_lock::WaitPolicy::new(
3880            Duration::from_secs(state.config.graph.timeout_resource_wait),
3881            move || inherited.cancelled(),
3882        );
3883        let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3884        loop {
3885            if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3886                state.gate = outcomes.clone();
3887                check_landing_resources(state, "gate", &outcomes)?;
3888            }
3889            if outcomes.iter().all(|o| o.ok()) {
3890                break;
3891            }
3892            match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3893                crate::graph::GateFix::Retry => {
3894                    outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3895                }
3896                crate::graph::GateFix::Stop => break,
3897                crate::graph::GateFix::Defer => {
3898                    // The repair checks e2e immediately, before the next gate.
3899                    // Preserve a cache wait here as well as below; otherwise
3900                    // the daemon charges an attempt for unstarted verification.
3901                    if let Some(e2e) = &state.gate_repair_e2e {
3902                        let outcomes = e2e.outcomes.clone();
3903                        check_landing_resources(state, "e2e", &outcomes)?;
3904                    }
3905                    bail!("landing gate repair deferred");
3906                }
3907            }
3908        }
3909        state.gate = outcomes;
3910        state.gate_ran = true;
3911        state.gate_head = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3912        state.save()?;
3913    }
3914    anyhow::ensure!(
3915        state.gate_allows_progress(),
3916        "landing gate failed; nothing published"
3917    );
3918    let checked = state
3919        .gate_head
3920        .clone()
3921        .context("landing gate has no checked SHA")?;
3922    anyhow::ensure!(
3923        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3924            && git::is_clean(&winner.worktree).await?,
3925        "landing tree changed after gate"
3926    );
3927    let inherited = crate::repo_lock::WaitPolicy::current();
3928    let wait = crate::repo_lock::WaitPolicy::new(
3929        Duration::from_secs(state.config.graph.timeout_resource_wait),
3930        move || inherited.cancelled(),
3931    );
3932    let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3933    if matches!(e2e, crate::graph::GateFix::Defer) {
3934        let outcomes = state
3935            .gate_repair_e2e
3936            .as_ref()
3937            .context("deferred landing e2e has no outcomes")?
3938            .outcomes
3939            .clone();
3940        check_landing_resources(state, "e2e", &outcomes)?;
3941    }
3942    anyhow::ensure!(
3943        matches!(e2e, crate::graph::GateFix::Retry),
3944        "post-repair e2e has not passed on the landing head; nothing published"
3945    );
3946    anyhow::ensure!(
3947        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3948            && git::is_clean(&winner.worktree).await?,
3949        "landing tree changed during post-repair e2e"
3950    );
3951    crate::pr_head::ensure_owner(state)?;
3952    if owner_requested_stop(state)? {
3953        bail!("owner requested stop before publication");
3954    }
3955    let current = forge.view(&state.repo, pr_url).await?;
3956    anyhow::ensure!(
3957        current.pr.state == PrLifecycle::Open,
3958        "PR closed or merged while gating; nothing published"
3959    );
3960    anyhow::ensure!(
3961        current.head == observed || current.head == checked,
3962        "PR head changed while gating; nothing published"
3963    );
3964    let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3965    let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3966    let refspec = format!("{checked}:refs/heads/{public}");
3967    let lock = repo_merge_lock(&state.repo);
3968    let _slot = lock.lock().await;
3969    let pushed = git::git_raw(
3970        &winner.worktree,
3971        &["push", &lease, &state.config.merge.remote, &refspec],
3972    )
3973    .await?;
3974    anyhow::ensure!(
3975        pushed.ok(),
3976        "publishing gated landing head failed: {}",
3977        pushed.stderr
3978    );
3979    state.pr_pushed_head = Some(checked.clone());
3980    state.land_sync_pending = false;
3981    state.save()?;
3982    Ok(checked)
3983}
3984
3985/// A scratch rebase moves the winner ref before its index and files. Recover
3986/// only a tree proven unchanged since the saved start; never discard edits.
3987async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
3988    let winner = state.winner().cloned().context("landing has no winner")?;
3989    let inherited = crate::repo_lock::WaitPolicy::current();
3990    let wait = crate::repo_lock::WaitPolicy::new(
3991        Duration::from_secs(state.config.graph.timeout_resource_wait),
3992        move || inherited.cancelled(),
3993    );
3994    let _coordination = crate::repo_lock::SeatLease::operation_with(
3995        &winner.worktree,
3996        state.config.coordination.as_ref(),
3997        &wait,
3998    )
3999    .await?;
4000    if !git::is_clean(&winner.worktree).await? {
4001        let from = state
4002            .land_rebase_from
4003            .as_deref()
4004            .context("landing worktree is dirty")?;
4005        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4006        anyhow::ensure!(head != from, "landing worktree is dirty");
4007        for args in [
4008            vec!["diff", "--cached", "--quiet", from],
4009            vec!["diff", "--quiet"],
4010        ] {
4011            anyhow::ensure!(
4012                git::git_raw(&winner.worktree, &args).await?.ok(),
4013                "landing worktree has edits beyond the interrupted rebase"
4014            );
4015        }
4016        // Preserve build artifacts and local files, including ignored ones.
4017        // read-tree can overwrite ignored paths, so reject collisions with
4018        // the new tree (including file/directory conflicts) before updating.
4019        let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
4020        let tracked = git::git(
4021            &winner.worktree,
4022            &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
4023        )
4024        .await?;
4025        for local in untracked.split('\0').filter(|p| !p.is_empty()) {
4026            anyhow::ensure!(
4027                !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
4028                    path == local
4029                        || path
4030                            .strip_prefix(local)
4031                            .is_some_and(|rest| rest.starts_with('/'))
4032                        || local
4033                            .strip_prefix(path)
4034                            .is_some_and(|rest| rest.starts_with('/'))
4035                }),
4036                "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
4037            );
4038        }
4039        git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
4040    }
4041    crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
4042    state.land_rebase_from = None;
4043    state.save()?;
4044    Ok(())
4045}
4046
4047async fn rebase_landing_worktree(
4048    state: &mut RunState,
4049    onto: &str,
4050) -> Result<crate::rebase::Rebased> {
4051    restore_landing_worktree(state).await?;
4052    let winner = state.winner().cloned().context("landing has no winner")?;
4053    state.land_sync_pending = true;
4054    state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
4055    state.save()?;
4056    let scratch = state.dir().join("rebase");
4057    let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
4058    if result == crate::rebase::Rebased::Applied {
4059        restore_landing_worktree(state).await?;
4060    }
4061    Ok(result)
4062}
4063
4064/// Reconcile a stopped landing before changing its branch. This also handles
4065/// old records whose graph status or merge detail no longer identifies land.
4066async fn resume_landing_head<F: Forge>(
4067    state: &mut RunState,
4068    observed: &str,
4069    pr_url: &str,
4070    forge: &F,
4071) -> Result<String> {
4072    crate::pr_head::ensure_owner(state)?;
4073    let winner = state.winner().cloned().context("landing has no winner")?;
4074    let local = git::rev_parse(&winner.worktree, "HEAD").await?;
4075    let expected = state
4076        .pr_pushed_head
4077        .as_deref()
4078        .or(state.gate_head.as_deref())
4079        .context("missing public head lease")?;
4080    anyhow::ensure!(
4081        observed == expected
4082            || (observed == local
4083                && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
4084                    || git::is_ancestor(&winner.worktree, expected, &local).await)),
4085        "public head moved outside this run; refusing to sync"
4086    );
4087    // An operator may have extended and published the winner while approval
4088    // was pending. Matching local/public history can be adopted as the lease,
4089    // but publish_landing_head must gate its new SHA before proceeding. A
4090    // remote-only or divergent update still cannot be overwritten.
4091    // Save reconciliation before rebasing again; a prior push may have reached
4092    // the remote immediately before the previous driver's last save.
4093    state.pr_pushed_head = Some(observed.to_owned());
4094    state.save()?;
4095    restore_landing_worktree(state).await?;
4096    let remote = state.config.merge.remote.clone();
4097    let base = state.base_branch.clone();
4098    let fetched = git::fetch(&state.repo, &remote, &base).await?;
4099    anyhow::ensure!(
4100        fetched.ok(),
4101        "cannot fetch landing base: {}",
4102        fetched.stderr
4103    );
4104    let onto = format!("{remote}/{base}");
4105    let tip = git::rev_parse(&state.repo, &onto).await?;
4106    if !git::is_ancestor(&state.repo, &tip, &local).await {
4107        match rebase_landing_worktree(state, &onto).await? {
4108            crate::rebase::Rebased::Applied => {}
4109            crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4110        }
4111    }
4112    publish_landing_head(state, observed, pr_url, forge).await
4113}
4114
4115async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4116    crate::pr_head::ensure_owner(state)?;
4117    let reopening_hold = crate::merge_decision::resumable_hold(state);
4118    let repo = state.repo.clone();
4119    let budget = state.config.graph.land_rounds;
4120    let mut round = 0usize;
4121    // Counted apart from `round`: a rebase is not a fix, and a base that
4122    // moved is not the change's fault.
4123    let mut rebases = 0usize;
4124    let mut waited = Duration::ZERO;
4125    // Comment bodies the fixer has already been shown. A comment is
4126    // outstanding until it has been handed over once; after that it is a
4127    // recorded decision, not an open question, and re-feeding it would loop the
4128    // budget away on a comment the fixer already declined with an argument.
4129    let mut shown: BTreeSet<String> = BTreeSet::new();
4130    // Await the last successfully published head, including across resumes.
4131    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4132    // Whether a settled-checks refusal has already been given its one re-look.
4133    let mut rechecked = false;
4134
4135    // Marks the run resumable through exactly this function, not through a
4136    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
4137    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
4138    // status specifically to know a resumed run belongs back in `land`
4139    // rather than at a second `gh pr create`. Set on every entry - fresh or
4140    // resumed - because a resume that parked here again must keep reading
4141    // `Landing`, not whatever a first pass through `merge` left behind.
4142    state.status = RunStatus::Landing;
4143    state.event("land", format!("watching {pr_url}"));
4144    state.save()?;
4145
4146    // An armed merge recorded by an earlier pass may or may not have reached
4147    // the forge (the record is written before the call). It is taken back on
4148    // the first observation, where a pull request is known to stop with.
4149    let mut resumed_armed = state.land_armed_head.is_some();
4150
4151    loop {
4152        let seen = forge.view(&repo, pr_url).await?;
4153        let mut pr = seen.pr.clone();
4154        pr.review_comments.retain(|c| !shown.contains(&c.body));
4155        state.pr = Some(crate::run::PrRecord {
4156            url: pr.url.clone(),
4157            number: pr.number,
4158            state: pr.state.as_str().to_owned(),
4159            checks: pr.checks.as_str().to_owned(),
4160            round,
4161            rounds: budget,
4162            red_at_merge: Vec::new(),
4163        });
4164        state.save()?;
4165
4166        if pr.state == PrLifecycle::Open {
4167            state.observe_branch_owner_holds(&seen.head).await;
4168            state.save()?;
4169        }
4170        crate::pr_head::ensure_owner(state)?;
4171        if reopening_hold {
4172            anyhow::ensure!(
4173                pr.state == PrLifecycle::Open,
4174                "held PR is no longer open; cannot request a fresh decision"
4175            );
4176            // An explicit resume authorizes a fresh question only. Preserve the
4177            // answered card, and never enter repair/rebase/merge on this pass.
4178            disarm(forge, state, &repo, pr.number)
4179                .await
4180                .map_err(anyhow::Error::msg)?;
4181            let previous = state.merge_decision.take();
4182            let binding = state.land_approval.take();
4183            state.config.graph.land_approval = true;
4184            let subject = merge_subject(
4185                crate::graph::landing_title(state, &seen.title),
4186                &crate::graph::landing_subject_source(state),
4187            );
4188            let contested = contested_to_ask(state);
4189            if let Err(error) =
4190                approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4191            {
4192                state.merge_decision = previous;
4193                state.land_approval = binding;
4194                return Err(error);
4195            }
4196            state.end_pass_parked(crate::run::ParkCause::Question);
4197            state.parked = true;
4198            state.event("land", format!("explicit resume reopened held decision {}; parked awaiting a fresh owner answer", previous.as_ref().map(|d| d.question.as_str()).unwrap_or_default()));
4199            state.save()?;
4200            return Ok(pr);
4201        }
4202        if owner_stop(state, &mut pr, forge).await? {
4203            return Ok(pr);
4204        }
4205
4206        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4207            // An approval already given for the same head is reused, so
4208            // nothing is asked twice. A failed disable
4209            // stops the run with the record kept: pushing on could change the
4210            // head under an arm that is still live.
4211            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4212                let why = format!(
4213                    "a previous pass may have armed auto-merge and it could not be disabled \
4214                     on resume: {e}"
4215                );
4216                stop(state, &repo, &pr, &why).await?;
4217                return Ok(pr);
4218            }
4219        }
4220
4221        // The head moved away from the one auto-merge was armed on, by
4222        // someone other than this loop. The arm is pinned and would refuse to
4223        // merge the new commit, but the approval was for the old one: take it
4224        // back and go through the normal path again.
4225        if pr.state == PrLifecycle::Open
4226            && !seen.head.is_empty()
4227            && state
4228                .land_armed_head
4229                .as_deref()
4230                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4231        {
4232            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4233                let why = format!(
4234                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
4235                );
4236                stop(state, &repo, &pr, &why).await?;
4237                return Ok(pr);
4238            }
4239        }
4240
4241        if state.land_sync_pending && pr.state == PrLifecycle::Open {
4242            // Disarming can outlive an owner answer. Consume it before our
4243            // own rebase invalidates the head shown on the decision card.
4244            if owner_stop(state, &mut pr, forge).await? {
4245                return Ok(pr);
4246            }
4247            let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4248                binding.head.eq_ignore_ascii_case(&seen.head)
4249                    && ask::Questions::open()
4250                        .get(&binding.question)
4251                        .is_ok_and(|q| {
4252                            q.run == state.id
4253                                && q.node == APPROVAL_NODE
4254                                && q.status == ask::QuestionStatus::Answered
4255                                && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4256                        })
4257            });
4258            if repair_answer {
4259                let subject = merge_subject(
4260                    crate::graph::landing_title(state, &seen.title),
4261                    &crate::graph::landing_subject_source(state),
4262                );
4263                let contested = contested_to_ask(state);
4264                if matches!(
4265                    approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4266                    ApprovalGate::Repair
4267                ) {
4268                    if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4269                        state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4270                        state.save()?;
4271                    }
4272                    return Ok(pr);
4273                }
4274            }
4275            let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4276            // A stop detected before publication must apply its durable
4277            // decision, even when verification returned an error meanwhile.
4278            if owner_stop(state, &mut pr, forge).await? {
4279                return Ok(pr);
4280            }
4281            let head = synced?;
4282            awaiting_head = Some(head);
4283            waited = Duration::ZERO;
4284            rechecked = false;
4285            // Read CI and approvals afresh, never from the pre-push observation.
4286            continue;
4287        }
4288
4289        if pr.state == PrLifecycle::Open {
4290            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4291                if waited >= WAIT_CEILING {
4292                    let want = awaiting_head.as_deref().unwrap_or_default();
4293                    let why = format!(
4294                        "the pull request's checks were still not about one readable head after \
4295                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
4296                         someone may have pushed over it",
4297                        WAIT_CEILING.as_secs() / 60,
4298                        if want.is_empty() { "any" } else { want },
4299                        if seen.head.is_empty() {
4300                            "nothing readable"
4301                        } else {
4302                            &seen.head
4303                        },
4304                        if seen.rollup_head.is_empty() {
4305                            "nothing readable"
4306                        } else {
4307                            &seen.rollup_head
4308                        },
4309                    );
4310                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
4311                    return Ok(pr);
4312                }
4313                waited += POLL;
4314                forge.poll().await;
4315                continue;
4316            }
4317            // Reset once, when the awaited head first shows up; resetting on
4318            // every re-observation would let a standing refusal wait forever.
4319            if awaiting_head.take().is_some() {
4320                // The checks now being read belong to the new head; give them
4321                // the same grace a fresh pull request gets.
4322                waited = Duration::ZERO;
4323            }
4324        }
4325
4326        // A failed workflow run is rerun once, judged by its own run rather
4327        // than by the rollup's aggregate: another run still pending, or a fix
4328        // budget already spent, must not keep a flaky failure from its one
4329        // rerun. It spends no round and no attempt.
4330        if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
4331            let failing = rerunnable(&seen.contexts);
4332            let now = forge.now();
4333            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4334                RerunStep::Rerun(runs) => {
4335                    let rec = state.land_reruns.get_or_insert_with(Default::default);
4336                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
4337                        *rec = LandReruns {
4338                            head: seen.head.clone(),
4339                            runs: BTreeMap::new(),
4340                        };
4341                    }
4342                    for run in &runs {
4343                        rec.runs.insert(run.clone(), now);
4344                    }
4345                    // Durable before the call: a restart must not
4346                    // rerun the same run twice. A save that fails
4347                    // means no rerun.
4348                    state.save()?;
4349                    let mut any = false;
4350                    for run in &runs {
4351                        let names = failing
4352                            .iter()
4353                            .filter(|(_, r)| r.as_deref() == Some(run))
4354                            .map(|(l, _)| l.as_str())
4355                            .collect::<Vec<_>>()
4356                            .join(", ");
4357                        match forge.rerun(&repo, run).await {
4358                            Ok(()) => {
4359                                any = true;
4360                                state.event(
4361                                    "land",
4362                                    format!("reran failed check(s) {names} (run {run})"),
4363                                );
4364                            }
4365                            // Stays recorded: never a retry loop.
4366                            Err(e) => state.event(
4367                                "land",
4368                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4369                            ),
4370                        }
4371                    }
4372                    state.save()?;
4373                    if any {
4374                        waited = Duration::ZERO;
4375                        forge.poll().await;
4376                        continue;
4377                    }
4378                }
4379                RerunStep::Grace => {
4380                    if waited >= WAIT_CEILING {
4381                        let why = format!(
4382                            "checks were still failing after a rerun and {} minutes",
4383                            WAIT_CEILING.as_secs() / 60
4384                        );
4385                        stop(state, &repo, &pr, &why).await?;
4386                        return Ok(pr);
4387                    }
4388                    waited += POLL;
4389                    forge.poll().await;
4390                    continue;
4391                }
4392                RerunStep::Fix => {}
4393            }
4394        }
4395
4396        let step = decide(&pr, round, budget, waited);
4397        // Armed on exactly this head: the forge is doing the waiting. A
4398        // decision to merge (or keep waiting) is only watched, never re-armed
4399        // and never re-approved; anything else - a red check, a comment, a
4400        // conflict - falls through to its own arm, which disarms first.
4401        let armed_here = state
4402            .land_armed_head
4403            .as_deref()
4404            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4405        if armed_here && matches!(step, Step::Merge | Step::Wait) {
4406            if waited >= WAIT_CEILING {
4407                let required = if seen.base.is_empty() {
4408                    None
4409                } else {
4410                    forge.required_contexts(&repo, &seen.base).await
4411                };
4412                let why = format!(
4413                    "auto-merge was armed on {} but the pull request did not merge within {} \
4414                     minutes ({})",
4415                    seen.head,
4416                    WAIT_CEILING.as_secs() / 60,
4417                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4418                );
4419                stop_disarmed(forge, state, &repo, &pr, &why).await?;
4420                return Ok(pr);
4421            }
4422            waited += POLL;
4423            forge.poll().await;
4424            continue;
4425        }
4426        if armed_here && !matches!(step, Step::Done { .. }) {
4427            // Not merging or waiting any more: whatever is next may change the
4428            // head or give up, and neither may leave the arm standing.
4429            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4430                let why = format!("auto-merge could not be disabled: {e}");
4431                stop(state, &repo, &pr, &why).await?;
4432                return Ok(pr);
4433            }
4434        }
4435        match step {
4436            Step::Wait => {
4437                if waited >= WAIT_CEILING {
4438                    let why = format!(
4439                        "checks were still running after {} minutes",
4440                        WAIT_CEILING.as_secs() / 60
4441                    );
4442                    stop(state, &repo, &pr, &why).await?;
4443                    return Ok(pr);
4444                }
4445                waited += POLL;
4446                forge.poll().await;
4447            }
4448            Step::Done { merged } => {
4449                if merged {
4450                    state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4451                }
4452                // Auto-merge is pinned to the approved head, but the forge's
4453                // own handling of a later push is not something magi can
4454                // see: if the pull request merged on another commit, say so
4455                // loudly rather than record a clean landing.
4456                if let Some(armed) = state.land_armed_head.take() {
4457                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4458                        let msg = format!(
4459                            "{} merged on {} but the owner approved {armed}; review what landed",
4460                            pr.url, seen.head
4461                        );
4462                        tracing::warn!("{msg}");
4463                        state.event("land", msg);
4464                        // Only an arm left by an older build can get here.
4465                        // The wording is fixed so a repeat does not relight
4466                        // the notice.
4467                        crate::notices::raise_with(
4468                            crate::notices::Notice::warn(
4469                                &format!("merged-unapproved-head:{}", state.id),
4470                                "A pull request merged on a commit the owner did not approve; \
4471                                 review what landed",
4472                            )
4473                            .link(crate::notices::Link::Run {
4474                                id: state.id.clone(),
4475                            }),
4476                            &state.config.notify,
4477                        );
4478                    }
4479                }
4480                state.status = if merged {
4481                    RunStatus::Merged
4482                } else {
4483                    RunStatus::Ready
4484                };
4485                let detail = if merged {
4486                    format!("{} was merged", pr.url)
4487                } else {
4488                    format!("{} was closed without merging", pr.url)
4489                };
4490                state.merge = Some(MergeOutcome {
4491                    mode: MergeMode::Pr,
4492                    ok: merged,
4493                    detail: detail.clone(),
4494                    empty: false,
4495                });
4496                state.event("land", detail);
4497                state.save()?;
4498                write_pr_state_through(state, pr.state);
4499                return Ok(pr);
4500            }
4501            Step::Merge => {
4502                state.check_delivery_evidence(&seen.head)?;
4503                let subject = merge_subject(
4504                    crate::graph::landing_title(state, &seen.title),
4505                    &crate::graph::landing_subject_source(state),
4506                );
4507                // The owner sees the panel before the one irreversible step,
4508                // and an unanswered question is a hold: silence never merges.
4509                //
4510                // The dedicated policy includes existing consent and post-repair
4511                // decisions; repair choice availability never grants merge authority.
4512                let contested = contested_to_ask(state);
4513                if !approval_reasons(state).is_empty() {
4514                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4515                        .await?
4516                    {
4517                        ApprovalGate::Approved => {}
4518                        ApprovalGate::Repair => {
4519                            // The driver consumes the saved request under the existing
4520                            // run/task claim. No merge, no second run or task.
4521                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4522                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4523                                state.save()?;
4524                            }
4525                            return Ok(pr);
4526                        }
4527                        ApprovalGate::Held => {
4528                            // Another entry point may have saved a stop after
4529                            // the first read above. Apply its durable action.
4530                            if owner_stop(state, &mut pr, forge).await? {
4531                                return Ok(pr);
4532                            }
4533                            stop(
4534                                state,
4535                                &repo,
4536                                &pr,
4537                                "the owner did not approve the merge (held or unanswered)",
4538                            )
4539                            .await?;
4540                            return Ok(pr);
4541                        }
4542                        // Filed (or still standing from an earlier visit) and
4543                        // not yet answered. Park here rather than wait: the
4544                        // question survives on disk, the daemon hands this
4545                        // run's slot to something else, and a later resume
4546                        // re-enters `land`, finds the same question, and
4547                        // either merges or stops depending on what it says
4548                        // by then.
4549                        ApprovalGate::Pending => {
4550                            state.end_pass_parked(crate::run::ParkCause::Question);
4551                            state.parked = true;
4552                            state.event(
4553                                "land",
4554                                "parked awaiting merge approval - resumes once answered",
4555                            );
4556                            state.save()?;
4557                            return Ok(pr);
4558                        }
4559                    }
4560                }
4561                // Open and past the gate above, so `seen.head` is the commit
4562                // the checks were bound to and the owner approved.
4563                //
4564                // Merge directly, bound to that commit. Auto-merge is not
4565                // armed any more: the forge checks `--match-head-commit` only
4566                // when the request is made, so an arm outlives the head it
4567                // was made for, and a push of another commit whose
4568                // requirements are met first would merge without approval.
4569                // A direct merge is checked by the forge at the moment it
4570                // happens, so only the approved head can land.
4571                let observed_head = seen.head.clone();
4572                // Read the pull request again just before: the state seen
4573                // above can be a moment old.
4574                {
4575                    let fresh = forge.view(&repo, pr_url).await.ok();
4576                    if !direct_merge_is_safe(
4577                        fresh.as_ref(),
4578                        &observed_head,
4579                        &shown,
4580                        round,
4581                        budget,
4582                        waited,
4583                    ) {
4584                        if waited >= WAIT_CEILING {
4585                            let why = "the pull request did not settle on the approved head \
4586                                       before it could be merged";
4587                            stop(state, &repo, &pr, why).await?;
4588                            return Ok(pr);
4589                        }
4590                        state.event(
4591                            "land",
4592                            "the pull request changed before merging; looking again",
4593                        );
4594                        waited += POLL;
4595                        forge.poll().await;
4596                        continue;
4597                    }
4598                }
4599                let argv = merge_argv_at(pr.number, &subject, &observed_head);
4600                let out = {
4601                    let merge_lock = repo_merge_lock(&repo);
4602                    let _merge_slot = merge_lock.lock().await;
4603                    crate::pr_head::ensure_owner(state)?;
4604                    if let Some(task_id) = state
4605                        .origin
4606                        .as_ref()
4607                        .and_then(|origin| origin.task.as_ref())
4608                    {
4609                        let task = crate::queue::Queue::open()
4610                            .get(task_id)
4611                            .context("cannot verify the task owner immediately before merge")?;
4612                        if task.runs.last() != Some(&state.id)
4613                            || task.operator_held()
4614                            || task.status == crate::queue::TaskStatus::Done
4615                        {
4616                            bail!(
4617                                "task ownership or an operator hold superseded this merge approval"
4618                            );
4619                        }
4620                    }
4621                    if let Some(binding) = &state.land_approval {
4622                        let current = ask::Questions::open().get(&binding.question)?;
4623                        if merge_consent_superseded(state, &current)?
4624                            || current.run != state.id
4625                            || !binding.head.eq_ignore_ascii_case(&observed_head)
4626                            || approval(current.resolution().as_deref()) != Approval::Merge
4627                            || current.merge_target.as_ref().is_some_and(|t| {
4628                                t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4629                            })
4630                        {
4631                            bail!(
4632                                "merge decision is stale or no longer authorizes the reviewed head"
4633                            );
4634                        }
4635                    }
4636                    forge.merge(&repo, &argv).await?
4637                };
4638                if out.0 {
4639                    // Exit 0 is not proof of a merge: with a merge queue `gh`
4640                    // enqueues (or reports the pull request already queued)
4641                    // and succeeds while it is still open. Ask the forge; an
4642                    // unreadable answer is not a confirmation either, so it
4643                    // is looked at again rather than recorded as merged.
4644                    let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4645                        if c.pr.state == PrLifecycle::Merged {
4646                            state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4647                            true
4648                        } else {
4649                            false
4650                        }
4651                    });
4652                    if !confirmed {
4653                        if waited >= WAIT_CEILING {
4654                            let why = "the merge request succeeded but the pull request \
4655                                       could not be confirmed merged after waiting";
4656                            stop(state, &repo, &pr, why).await?;
4657                            return Ok(pr);
4658                        }
4659                        state.event(
4660                            "land",
4661                            "merge accepted but the pull request is not confirmed merged yet; waiting",
4662                        );
4663                        state.save()?;
4664                        waited += POLL;
4665                        forge.poll().await;
4666                        continue;
4667                    }
4668                    pr.state = PrLifecycle::Merged;
4669                    state.status = RunStatus::Merged;
4670                    state.merge = Some(MergeOutcome {
4671                        mode: MergeMode::Pr,
4672                        ok: true,
4673                        detail: format!("gh {}", argv.join(" ")),
4674                        empty: false,
4675                    });
4676                    // The last `state.pr` snapshot is whatever the poll before
4677                    // this merge observed - still `open` - and nothing below
4678                    // refreshes it from GitHub again, so the UI's round rail
4679                    // would otherwise keep animating a merged run forever.
4680                    if let Some(pr_record) = state.pr.as_mut() {
4681                        pr_record.state = pr.state.as_str().to_owned();
4682                    }
4683                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4684                    announce_red_merge(state, &pr).await;
4685                    state.save()?;
4686                    write_pr_state_through(state, pr.state);
4687                    return Ok(pr);
4688                }
4689                let after_seen = forge.view(&repo, pr_url).await.ok();
4690                let after = after_seen.as_ref().map(|s| s.pr.state);
4691                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4692                    state.pr_merged_head = after_seen
4693                        .as_ref()
4694                        .map(|s| s.head.clone())
4695                        .filter(|h| !h.is_empty());
4696                    pr.state = PrLifecycle::Merged;
4697                    state.status = RunStatus::Merged;
4698                    state.merge = Some(outcome);
4699                    if let Some(pr_record) = state.pr.as_mut() {
4700                        pr_record.state = pr.state.as_str().to_owned();
4701                    }
4702                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4703                    announce_red_merge(state, &pr).await;
4704                    state.save()?;
4705                    write_pr_state_through(state, pr.state);
4706                    return Ok(pr);
4707                }
4708                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4709                match verdict {
4710                    Refused::Final => {
4711                        let merge_state = after_seen
4712                            .as_ref()
4713                            .map(|s| s.merge_state.as_str())
4714                            .filter(|m| !m.is_empty())
4715                            .unwrap_or("unknown");
4716                        // Which refusal this was decides what a person has to
4717                        // do about it, so the two are worded apart; both carry
4718                        // the forge's own message.
4719                        let why = format!(
4720                            "the merge was refused: {} (merge state: {merge_state})",
4721                            out.1
4722                        );
4723                        stop(state, &repo, &pr, &why).await?;
4724                        return Ok(pr);
4725                    }
4726                    verdict => {
4727                        // Back to the top, which re-decides and passes the
4728                        // approval gate again, a poll later. `waited` is not
4729                        // reset here: only a pushed fix restarts it, or a
4730                        // standing refusal would wait forever.
4731                        if waited >= WAIT_CEILING {
4732                            let why = format!(
4733                                "the merge was still refused after {} minutes: {}",
4734                                WAIT_CEILING.as_secs() / 60,
4735                                out.1
4736                            );
4737                            stop(state, &repo, &pr, &why).await?;
4738                            return Ok(pr);
4739                        }
4740                        if verdict == Refused::Recheck {
4741                            rechecked = true;
4742                        }
4743                        state.event(
4744                            "land",
4745                            "merge refused while the branch policy is not satisfied yet; waiting",
4746                        );
4747                        state.save()?;
4748                        waited += POLL;
4749                        forge.poll().await;
4750                    }
4751                }
4752            }
4753            Step::Rebase => {
4754                // Bounded by the same budget as a fix, because a rebase that
4755                // keeps being needed means the base moves faster than this
4756                // run can land and a person should decide what to do. It
4757                // spends none of that budget: the change is not what is
4758                // wrong.
4759                if rebases >= budget {
4760                    let why = format!(
4761                        "the base moved under this branch {budget} time(s) and it still does \
4762                         not merge; rebasing again would only race it"
4763                    );
4764                    stop(state, &repo, &pr, &why).await?;
4765                    return Ok(pr);
4766                }
4767                rebases += 1;
4768                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4769                    stop(
4770                        state,
4771                        &repo,
4772                        &pr,
4773                        "the pull request conflicts and this run has no winning branch to rebase",
4774                    )
4775                    .await?;
4776                    return Ok(pr);
4777                };
4778                let base = state.base_branch.clone();
4779                state.event(
4780                    "land",
4781                    format!("{} no longer merges; rebasing onto {base}", pr.url),
4782                );
4783                state.save()?;
4784
4785                // Onto the base as the *remote* has it: the local ref may be
4786                // behind, and rebasing onto a stale base produces a branch
4787                // that conflicts all over again.
4788                let remote = state.config.merge.remote.clone();
4789                git::fetch(&repo, &remote, &base).await.ok();
4790                if owner_stop(state, &mut pr, forge).await? {
4791                    return Ok(pr);
4792                }
4793                let onto = format!("{remote}/{base}");
4794                let rebased = match rebase_landing_worktree(state, &onto).await {
4795                    Ok(crate::rebase::Rebased::Applied) => Ok(None),
4796                    Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4797                    Err(e) => Err(e),
4798                };
4799                if owner_stop(state, &mut pr, forge).await? {
4800                    return Ok(pr);
4801                }
4802                match rebased {
4803                    Ok(None) => {
4804                        let published =
4805                            publish_landing_head(state, &seen.head, pr_url, forge).await;
4806                        if owner_stop(state, &mut pr, forge).await? {
4807                            return Ok(pr);
4808                        }
4809                        let head = published?;
4810                        crate::graph::refresh_reviewed_commits(state, &branch).await;
4811                        state.pr_pushed_head = Some(head.clone());
4812                        awaiting_head = Some(head);
4813                        rechecked = false;
4814                        state.event("land", format!("rebased {branch} onto {base}"));
4815                        state.save()?;
4816                        // The forge has to re-run its checks against the
4817                        // rebased head before anything else can be decided.
4818                        waited = Duration::ZERO;
4819                        tokio::time::sleep(POLL).await;
4820                    }
4821                    // The fixer's rounds are spent (or it could not finish):
4822                    // that is a decision for a person.
4823                    Ok(Some(conflict)) => {
4824                        let why = format!(
4825                            "{} conflicts with {base} and the rebase did not apply: {}",
4826                            pr.url,
4827                            conflict.chars().take(600).collect::<String>()
4828                        );
4829                        stop(state, &repo, &pr, &why).await?;
4830                        return Ok(pr);
4831                    }
4832                    Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4833                    Err(e) => {
4834                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
4835                        stop(state, &repo, &pr, &why).await?;
4836                        return Ok(pr);
4837                    }
4838                }
4839            }
4840            Step::GiveUp { reason } => {
4841                stop(state, &repo, &pr, &reason).await?;
4842                return Ok(pr);
4843            }
4844            Step::Fix { reason } => {
4845                round += 1;
4846                waited = Duration::ZERO;
4847                for c in &pr.review_comments {
4848                    shown.insert(c.body.clone());
4849                }
4850                state.event("land", format!("round {round}: {reason}"));
4851                state.save()?;
4852
4853                let logs = failing_logs(&repo, &seen.failing_urls).await;
4854                let was_red = pr.checks == Checks::Red;
4855                if owner_stop(state, &mut pr, forge).await? {
4856                    return Ok(pr);
4857                }
4858                let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4859                if owner_stop(state, &mut pr, forge).await? {
4860                    return Ok(pr);
4861                }
4862                match fixed {
4863                    Fixed::Committed { head } => {
4864                        // Whatever the next look shows may still be the
4865                        // previous head; see `awaiting_new_head`.
4866                        state.pr_pushed_head = Some(head.clone());
4867                        awaiting_head = Some(head);
4868                        rechecked = false;
4869                        waited = Duration::ZERO;
4870                        forge.poll().await;
4871                    }
4872                    Fixed::Declined if was_red => {
4873                        let why = format!(
4874                            "the fixer produced no commit while {} check(s) were failing \
4875                             ({}); stopping instead of looping on an unchanged tree",
4876                            pr.failing.len(),
4877                            pr.failing.join(", ")
4878                        );
4879                        stop(state, &repo, &pr, &why).await?;
4880                        return Ok(pr);
4881                    }
4882                    // Comment-driven round with no commit: the fixer read the
4883                    // comments and changed nothing, which is a decision it is
4884                    // allowed to make. The comments are recorded as shown, so
4885                    // the next observation sees a clean pull request.
4886                    Fixed::Declined => state.event(
4887                        "land",
4888                        format!("round {round}: fixer declined the comments, nothing committed"),
4889                    ),
4890                    Fixed::Failed(why) => {
4891                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4892                        return Ok(pr);
4893                    }
4894                }
4895                state.save()?;
4896            }
4897        }
4898    }
4899}
4900
4901/// One observation, plus the two things [`PrState`] deliberately does not carry:
4902/// the title (needed for the squash subject) and where the failing checks'
4903/// logs live.
4904#[derive(Clone)]
4905struct Seen {
4906    pr: PrState,
4907    title: String,
4908    failing_urls: Vec<(String, String)>,
4909    /// `headRefOid`: the commit this observation, checks included, is about.
4910    head: String,
4911    /// The commit the checks belong to, read from the same GraphQL node as
4912    /// the checks themselves. Empty when unreadable.
4913    rollup_head: String,
4914    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
4915    /// BEHIND and DRAFT together, and a stop reason has to say which.
4916    merge_state: String,
4917    /// Every check of the rollup, for naming what an armed merge waits on.
4918    contexts: Vec<CheckInfo>,
4919    /// `baseRefName`, to look up which contexts the base requires.
4920    base: String,
4921}
4922
4923/// One check of the rollup as far as a stop reason needs it.
4924#[derive(Clone)]
4925struct CheckInfo {
4926    label: String,
4927    verdict: Verdict,
4928    required: Option<bool>,
4929    /// The workflow run the check belongs to; `None` for an external status.
4930    run: Option<String>,
4931}
4932
4933/// What to do with a red head before a fix round is spent on it.
4934#[derive(Debug, Clone, PartialEq, Eq)]
4935enum RerunStep {
4936    /// Rerun these workflow runs (each not yet rerun for this head).
4937    Rerun(Vec<String>),
4938    /// Reran already and the forge may still report the old failure: wait.
4939    Grace,
4940    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
4941    Fix,
4942}
4943
4944/// Failed checks a rerun may help: a failure of a check that blocks (`required`
4945/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
4946/// pending checks in another run do not delay this run's rerun.
4947fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4948    contexts
4949        .iter()
4950        .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4951        // `gh run rerun --failed` refuses a run with a job still going.
4952        .filter(|c| {
4953            c.run.as_ref().is_none_or(|r| {
4954                !contexts.iter().any(|o| {
4955                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4956                        && o.run.as_ref() == Some(r)
4957                })
4958            })
4959        })
4960        .map(|c| (c.label.clone(), c.run.clone()))
4961        .collect()
4962}
4963
4964/// Pure: rerun once per run id, wait out the grace, then hand over.
4965fn rerun_step(
4966    failing: &[(String, Option<String>)],
4967    record: Option<&LandReruns>,
4968    head: &str,
4969    now: i64,
4970) -> RerunStep {
4971    // A failure with no workflow run (an external status) cannot be rerun, and
4972    // the fixer's push re-runs everything anyway.
4973    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4974        return RerunStep::Fix;
4975    }
4976    // Reruns belong to the head they were made on.
4977    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4978    let mut fresh: Vec<String> = Vec::new();
4979    let mut recent = false;
4980    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4981        match record.and_then(|r| r.runs.get(run)) {
4982            None => {
4983                if !fresh.iter().any(|f| f == run) {
4984                    fresh.push(run.to_owned());
4985                }
4986            }
4987            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4988            Some(_) => {}
4989        }
4990    }
4991    if !fresh.is_empty() {
4992        RerunStep::Rerun(fresh)
4993    } else if recent {
4994        RerunStep::Grace
4995    } else {
4996        RerunStep::Fix
4997    }
4998}
4999
5000/// Read the pull request: `gh pr view` for the top-level thread and merge
5001/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
5002/// inline review comments `gh pr view` does not report.
5003async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
5004    let view = gh(
5005        repo,
5006        &[
5007            "pr".to_owned(),
5008            "view".to_owned(),
5009            pr_url.to_owned(),
5010            "--json".to_owned(),
5011            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
5012                .to_owned(),
5013        ],
5014    )
5015    .await?;
5016    if !view.0 {
5017        bail!("gh pr view {pr_url}: {}", view.1);
5018    }
5019    let number = parse_pr(&view.1)?.number;
5020    let node = last_commit_node(repo, number).await;
5021    let mut seen = seen_from(&view.1, node.as_deref())?;
5022
5023    let inline = gh(
5024        repo,
5025        &[
5026            "api".to_owned(),
5027            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
5028        ],
5029    )
5030    .await?;
5031    if inline.0 {
5032        match parse_inline_comments(&inline.1) {
5033            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5034            // An unreadable inline thread must not end a landing: the rollup
5035            // and the top-level thread are still real signal.
5036            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5037        }
5038    } else {
5039        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5040    }
5041    Ok(seen)
5042}
5043
5044/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
5045/// response. No I/O.
5046///
5047/// The commit oid and the checks are read from the *same* node, so the rollup
5048/// is bound to the commit it belongs to by construction; two reads that agree
5049/// before and after another response prove nothing about what that response
5050/// held. The view's own rollup is never used. A node that is missing,
5051/// unreadable, carries GraphQL errors, or whose checks run past the page
5052/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
5053/// as "look again" and never as a reason to merge.
5054fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5055    let mut pr = parse_pr(view_json)?;
5056    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5057
5058    let mut rollup_head = String::new();
5059    let mut failing_urls = Vec::new();
5060    let mut contexts = Vec::new();
5061    let mut checks = Checks::Unknown;
5062    let mut failing = Vec::new();
5063    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5064        (checks, failing) = rollup_verdict(&rollup);
5065        failing_urls = rollup
5066            .iter()
5067            .filter(|c| c.verdict() == Verdict::Fail)
5068            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5069            .collect();
5070        contexts = rollup
5071            .iter()
5072            .map(|c| CheckInfo {
5073                label: c.label(),
5074                verdict: c.verdict(),
5075                required: c.is_required,
5076                run: c.url().and_then(run_of),
5077            })
5078            .collect();
5079        rollup_head = oid;
5080    }
5081    pr.checks = checks;
5082    pr.failing = failing;
5083
5084    Ok(Seen {
5085        pr,
5086        title: raw.title,
5087        failing_urls,
5088        head: raw.head_ref_oid,
5089        rollup_head,
5090        merge_state: raw.merge_state_status,
5091        contexts,
5092        base: raw.base_ref_name,
5093    })
5094}
5095
5096/// The last commit's oid and its checks from one GraphQL response, `None`
5097/// when anything about it cannot be trusted.
5098fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5099    let v: serde_json::Value = serde_json::from_str(json).ok()?;
5100    if v.get("errors").is_some_and(|e| !e.is_null()) {
5101        return None;
5102    }
5103    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5104    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5105    let contexts = commit.pointer("/statusCheckRollup/contexts");
5106    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5107        // No rollup at all: the commit has no checks.
5108        return Some((oid.to_owned(), Vec::new()));
5109    };
5110    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5111        return None;
5112    }
5113    let nodes = contexts.get("nodes")?.as_array()?;
5114    let rollup = nodes
5115        .iter()
5116        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5117        .collect::<Result<Vec<_>, _>>()
5118        .ok()?;
5119    Some((oid.to_owned(), rollup))
5120}
5121
5122/// The pull request's last commit and its checks, in one response. `None`
5123/// when the forge could not be asked.
5124async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5125    let out = gh(
5126        repo,
5127        &[
5128            "api".to_owned(),
5129            "graphql".to_owned(),
5130            "-F".to_owned(),
5131            "owner={owner}".to_owned(),
5132            "-F".to_owned(),
5133            "repo={repo}".to_owned(),
5134            "-F".to_owned(),
5135            format!("number={number}"),
5136            "-f".to_owned(),
5137            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5138             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5139             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5140             ... on CheckRun{name status conclusion detailsUrl \
5141             isRequired(pullRequestNumber:$number)} \
5142             ... on StatusContext{context state targetUrl \
5143             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5144                .to_owned(),
5145        ],
5146    )
5147    .await
5148    .ok()?;
5149    out.0.then_some(out.1)
5150}
5151
5152/// What a fix round did.
5153#[doc(hidden)]
5154#[derive(Debug, PartialEq)]
5155pub enum Fixed {
5156    /// The fixer committed something, and this is the head that was pushed.
5157    Committed {
5158        /// The commit now at the tip of the pushed branch.
5159        head: String,
5160    },
5161    /// The fixer ran and chose to change nothing.
5162    Declined,
5163    /// The fixer could not run, or said nothing usable.
5164    Failed(String),
5165}
5166
5167/// Hand the failures and the comments to the fixer, then commit and push.
5168///
5169/// The fixer works in the winner's own worktree so its commits land on the
5170/// branch the pull request is built from, and it runs with `allow_write` for
5171/// the same reason.
5172#[doc(hidden)]
5173pub async fn fix_round(
5174    state: &mut RunState,
5175    pr: &PrState,
5176    round: usize,
5177    budget: usize,
5178    reason: &str,
5179    logs: &str,
5180) -> Result<Fixed> {
5181    let winner = state
5182        .winner()
5183        .cloned()
5184        .context("landing needs a winning candidate; none is recorded on this run")?;
5185    let roles = state
5186        .config
5187        .resolve_roles()
5188        .context("resolve the roster for the fix round")?;
5189    // Same chain as the review loop (see `crate::fixer`): the configured
5190    // fixers in order, otherwise the winner's own author continuing its own
5191    // conversation - the competition is over, so its context is pure benefit.
5192    let attempts = crate::fixer::attempts(state, &roles, &winner);
5193    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5194
5195    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5196    let mut artifacts = agent::artifacts_dir(&state.dir());
5197    if let Some(request) = state
5198        .merge_repairs
5199        .last()
5200        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5201    {
5202        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5203    }
5204    let prompt = if state.config.cache_dir().is_some() {
5205        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5206    } else {
5207        prompt
5208    };
5209    // Read before the fixer runs: it normally commits for itself, so HEAD has
5210    // already moved by the time it returns and a later read would see no
5211    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
5212    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5213    // Each id at most once, forward only: the next one is asked only when the
5214    // call advances, and only the last attempt's result is judged below, so an
5215    // exhausted chain ends as a single failed fixer does.
5216    let mut last = None;
5217    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5218        if owner_requested_stop(state)? {
5219            return Ok(Fixed::Declined);
5220        }
5221        let mut seat = seat_of(state, &seat_key, &spec.id);
5222        let stem = if i == 0 {
5223            format!("land-{round}")
5224        } else {
5225            format!("land-{round}-{}", spec.id)
5226        };
5227        let out = agent::invoke(
5228            &spec,
5229            &mut seat,
5230            &Invocation {
5231                scope: crate::agent::InvocationScope::Repository,
5232                coordination: state.config.coordination.as_ref(),
5233                fetched_base: state.fetched_base.as_ref(),
5234                cwd: &winner.worktree,
5235                prompt: &prompt,
5236                timeout: Duration::from_secs(state.config.graph.timeout_fix),
5237                allow_write: true,
5238                unsandboxed: false,
5239                sessions: state.config.graph.sessions,
5240                artifacts: &artifacts,
5241                stem: &stem,
5242                run: &state.id,
5243                node: "land",
5244                cache_dir: state.config.cache_dir().as_deref(),
5245                attachments: &[],
5246                writable: &[],
5247            },
5248        )
5249        .await;
5250        state.seats.insert(seat.key.clone(), seat);
5251        if let Some(next) = ids.get(i + 1)
5252            && agent::chain_advances(&out)
5253        {
5254            let (class, why) = crate::fixer::failure_of(&out);
5255            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5256            continue;
5257        }
5258        last = Some(out);
5259        break;
5260    }
5261    let out = last.expect("the fixer chain always has an entry");
5262
5263    match out {
5264        Ok(o) if o.quota_exhausted() => {
5265            return Ok(Fixed::Failed(
5266                "rate limited (quota); the fixer could not run".to_owned(),
5267            ));
5268        }
5269        Ok(o) if !o.usable() => {
5270            return Ok(Fixed::Failed(format!(
5271                "the fixer produced nothing usable ({})",
5272                crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5273            )));
5274        }
5275        Ok(_) => {}
5276        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5277        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5278    }
5279
5280    if owner_requested_stop(state)? {
5281        return Ok(Fixed::Declined);
5282    }
5283
5284    // An agent that edited files but never committed would otherwise push
5285    // nothing and look like a refusal.
5286    let wait = crate::repo_lock::WaitPolicy::new(
5287        Duration::from_secs(state.config.graph.timeout_resource_wait),
5288        || false,
5289    );
5290    let coordination = crate::repo_lock::SeatLease::operation_with(
5291        &winner.worktree,
5292        state.config.coordination.as_ref(),
5293        &wait,
5294    )
5295    .await?;
5296    match git::rescue_commit(
5297        &winner.worktree,
5298        &format!("magi: land round {round} fixes (uncommitted work)"),
5299    )
5300    .await
5301    {
5302        Ok(r) => state.note_withheld("land", &r.withheld),
5303        Err(e)
5304            if state
5305                .merge_repairs
5306                .last()
5307                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5308        {
5309            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5310        }
5311        Err(_) => {}
5312    }
5313    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5314    drop(coordination);
5315    if after == before {
5316        return Ok(Fixed::Declined);
5317    }
5318
5319    if owner_requested_stop(state)? {
5320        return Ok(Fixed::Declined);
5321    }
5322    crate::pr_head::ensure_owner(state)?;
5323    let remote = state.config.merge.remote.clone();
5324    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5325    let push = if state.pr_head.is_some() {
5326        let expected = state
5327            .pr_pushed_head
5328            .as_deref()
5329            .context("missing public head lease")?;
5330        git::push_as(
5331            &winner.worktree,
5332            &remote,
5333            &winner.branch,
5334            &public_head,
5335            expected,
5336        )
5337        .await?
5338    } else {
5339        git::push(&winner.worktree, &remote, &winner.branch).await?
5340    };
5341    if !push.ok() {
5342        return Ok(Fixed::Failed(format!(
5343            "pushing {} to {remote} failed: {}",
5344            public_head, push.stderr
5345        )));
5346    }
5347    state.event(
5348        "land",
5349        format!("round {round}: pushed a fix to {public_head}"),
5350    );
5351    state.pr_pushed_head = Some(after.clone());
5352    state.save()?;
5353    Ok(Fixed::Committed { head: after })
5354}
5355
5356/// Fetch or create a seat, keeping its conversation across nodes.
5357pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5358    crate::fixer::seat_for(state, key, agent)
5359}
5360
5361/// Recheck the live PR and local worktree before spending the requested fix.
5362/// A moved/closed PR is held, never repaired using obsolete evidence.
5363pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5364    let seen = observe(&state.repo, url).await?;
5365    let winner = state.winner().context("no winning branch to repair")?;
5366    if seen.pr.state != PrLifecycle::Open || seen.head != head {
5367        anyhow::bail!(
5368            "PR is closed or its head changed; review the current head before requesting repair again"
5369        );
5370    }
5371    if git::rev_parse(&winner.worktree, "HEAD").await? != head
5372        || !git::is_clean(&winner.worktree).await?
5373    {
5374        anyhow::bail!(
5375            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5376        );
5377    }
5378    // Always disable any forge-side reservation, including one not recorded
5379    // by this run. A repair request is never merge authorization.
5380    let view = gh(
5381        &state.repo,
5382        &[
5383            "pr".into(),
5384            "view".into(),
5385            url.into(),
5386            "--json".into(),
5387            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5388        ],
5389    )
5390    .await?;
5391    if !view.0 {
5392        bail!("cannot check auto-merge: {}", view.1);
5393    }
5394    let live: serde_json::Value = serde_json::from_str(&view.1)?;
5395    let public = state.pr_branch().unwrap_or(&winner.branch);
5396    if live["state"] != "OPEN"
5397        || live["headRefOid"] != head
5398        || live["headRefName"] != public
5399        || live["baseRefName"] != state.base_branch
5400        || live["isCrossRepository"] != false
5401    {
5402        bail!("PR changed during repair preflight; review its current head explicitly");
5403    }
5404    let auto_merge = live
5405        .get("autoMergeRequest")
5406        .context("cannot read the PR auto-merge reservation; repair held")?;
5407    if !auto_merge.is_null() {
5408        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5409        if !out.0 {
5410            bail!("cannot disable auto-merge; repair held: {}", out.1);
5411        }
5412    }
5413    Ok(seen.pr)
5414}
5415
5416/// What the fixer is told.
5417fn fix_prompt(
5418    state: &RunState,
5419    pr: &PrState,
5420    round: usize,
5421    budget: usize,
5422    reason: &str,
5423    logs: &str,
5424) -> String {
5425    let mut s = format!(
5426        "Your patch is open as a pull request and it is not landing. Land round \
5427         {round} of {budget}.\n\n\
5428         Pull request: {}\n\n\
5429         What is holding it: {reason}\n\n\
5430         # The task\n\n{}\n",
5431        pr.url, state.instruction
5432    );
5433
5434    if pr.failing.is_empty() {
5435        s.push_str("\n# Failing checks\n\n(none)\n");
5436    } else {
5437        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5438        if logs.trim().is_empty() {
5439            s.push_str(
5440                "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5441            );
5442        } else {
5443            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5444        }
5445    }
5446
5447    if pr.review_comments.is_empty() {
5448        s.push_str("\n# Review comments\n\n(none)\n");
5449    } else {
5450        s.push_str("\n# Review comments\n");
5451        for c in &pr.review_comments {
5452            let where_ = match (&c.path, c.line) {
5453                (Some(p), Some(l)) => format!(" ({p}:{l})"),
5454                (Some(p), None) => format!(" ({p})"),
5455                _ => String::new(),
5456            };
5457            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5458        }
5459    }
5460
5461    s.push_str(
5462        "\n# Rules\n\n\
5463         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5464            failing test; do not silence a lint with an allow attribute; do not \
5465            stretch a timeout to hide a race. If the check is right, the code is \
5466            wrong.\n\
5467         2. Change nothing the checks and the comments did not raise. A \
5468            drive-by refactor turns a one-line fix into a pull request that \
5469            needs reviewing again.\n\
5470         3. If a comment is wrong, say so with a checkable argument and change \
5471            nothing for it. A declined comment with a reason is a correct \
5472            outcome; a change made to appease a reviewer is not.\n\
5473         4. Commit in this worktree. magi pushes to the pull request's branch \
5474            for you; do not push, merge, or close anything yourself.\n\
5475         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5476         # Output\n\n\
5477         Say what you changed and why, and what you declined and why.",
5478    );
5479
5480    let language = &state.config.graph.language;
5481    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5482        let _ = write!(s, "\n\nWrite all prose in {language}.");
5483    }
5484    // After the language line, so the exception is the last word on it.
5485    s.push_str(&crate::prompt::github_rule(
5486        language,
5487        &state.config.graph.github_language,
5488    ));
5489    if let Some(overlay) = state.config.prompts.overlay("fix") {
5490        let _ = write!(s, "\n\n{overlay}");
5491    }
5492    s.push('\n');
5493    s.push_str(crate::prompt::child_run_ownership());
5494    s
5495}
5496
5497/// Failing log tails, the way the operator collects them by hand:
5498/// `gh run view --log-failed`.
5499async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5500    let mut out = String::new();
5501    for (name, url) in failing.iter().take(MAX_LOGS) {
5502        let args = match (job_of(url), run_of(url)) {
5503            (Some(job), _) => vec![
5504                "run".to_owned(),
5505                "view".to_owned(),
5506                "--log-failed".to_owned(),
5507                "--job".to_owned(),
5508                job,
5509            ],
5510            (None, Some(run)) => vec![
5511                "run".to_owned(),
5512                "view".to_owned(),
5513                run,
5514                "--log-failed".to_owned(),
5515            ],
5516            // Not a GitHub Actions check - an external status has no log here.
5517            (None, None) => continue,
5518        };
5519        let (ok, body) = match gh(repo, &args).await {
5520            Ok(v) => v,
5521            Err(e) => (false, format!("{e:#}")),
5522        };
5523        if !ok && body.trim().is_empty() {
5524            continue;
5525        }
5526        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5527    }
5528    out
5529}
5530
5531/// Job id out of a check's `detailsUrl`
5532/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
5533fn job_of(details_url: &str) -> Option<String> {
5534    let after = details_url.split("/job/").nth(1)?;
5535    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5536    (!id.is_empty()).then_some(id)
5537}
5538
5539/// Workflow run id out of a check's `detailsUrl`.
5540pub(crate) fn run_of(details_url: &str) -> Option<String> {
5541    let after = details_url.split("/actions/runs/").nth(1)?;
5542    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5543    (!id.is_empty()).then_some(id)
5544}
5545
5546/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
5547/// it lands on GitHub, not in front of the operator. Pure so a test can hold
5548/// it to that.
5549fn stop_comment(run_id: &str, why: &str) -> String {
5550    format!(
5551        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5552         The branch is untouched and the run is `{run_id}`. Nothing was merged."
5553    )
5554}
5555
5556/// Leave the pull request open, say why on it, and mark the run blocked.
5557///
5558/// The comment is what makes an unattended stop actionable: the operator wakes
5559/// up to a pull request that explains itself rather than to a silent queue.
5560async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5561    let body = stop_comment(&state.id, why);
5562    let (_, mut body) = crate::github_text::prepare(state, "", &body);
5563    if !body.contains(MARKER) {
5564        body = format!("{MARKER}\n{body}");
5565    }
5566    let posted = gh(
5567        repo,
5568        &[
5569            "pr".to_owned(),
5570            "comment".to_owned(),
5571            pr.number.to_string(),
5572            "--body".to_owned(),
5573            body,
5574        ],
5575    )
5576    .await;
5577    match posted {
5578        Ok((true, _)) => {}
5579        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5580        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5581    }
5582    state.status = RunStatus::Blocked;
5583    state.merge = Some(MergeOutcome {
5584        mode: MergeMode::Pr,
5585        ok: false,
5586        detail: why.to_owned(),
5587        empty: false,
5588    });
5589    state.event("land", format!("stopped: {why}"));
5590    state.save()?;
5591    Ok(())
5592}
5593
5594/// Run `gh` in `repo`, returning success and the combined output.
5595///
5596/// Combined because `gh` reports a refused merge on stderr and the pull request
5597/// json on stdout, and both are evidence.
5598///
5599/// `GH_REPO` is stripped from the child's environment: every call site here
5600/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
5601/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
5602/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
5603/// `GH_REPO` the operator happens to have exported for an unrelated script
5604/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
5605/// below) to a different repository than the one actually on disk - which
5606/// for the same-repo guard in [`correct_manual_merge`] would mean the check
5607/// could be made to agree with whatever repository a forged `--merged` URL
5608/// claims, defeating it entirely.
5609pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5610    let out = tokio::process::Command::new("gh")
5611        .args(args)
5612        .current_dir(cwd)
5613        .env_remove("GH_REPO")
5614        .quiet()
5615        .stdin(std::process::Stdio::null())
5616        .output()
5617        .await
5618        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5619    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5620    let err = String::from_utf8_lossy(&out.stderr);
5621    if body.trim().is_empty() {
5622        body = err.into_owned();
5623    } else if !err.trim().is_empty() {
5624        body.push_str(&err);
5625    }
5626    Ok((out.status.success(), body.trim().to_owned()))
5627}
5628
5629/// Verdict of one entry in the status rollup.
5630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5631pub(crate) enum Verdict {
5632    Pass,
5633    Fail,
5634    Pending,
5635    Unknown,
5636}
5637
5638impl Verdict {
5639    /// Short operator-facing check result.
5640    pub(crate) fn label(self) -> &'static str {
5641        match self {
5642            Self::Pass => "passed",
5643            Self::Fail => "failed",
5644            Self::Pending => "pending",
5645            Self::Unknown => "unknown",
5646        }
5647    }
5648}
5649
5650#[derive(Debug, Deserialize)]
5651#[serde(rename_all = "camelCase")]
5652struct GhPr {
5653    #[serde(default)]
5654    url: String,
5655    #[serde(default)]
5656    number: u64,
5657    #[serde(default)]
5658    state: String,
5659    #[serde(default)]
5660    title: String,
5661    #[serde(default)]
5662    status_check_rollup: Vec<GhCheck>,
5663    /// GitHub's own verdict on whether the pull request can be merged.
5664    ///
5665    /// Worth asking for because it is the only place the *required* check set
5666    /// is applied: the rollup lists every check equally, so a repository that
5667    /// deliberately does not require `coverage` still looks red here. See
5668    /// [`Blocking`].
5669    #[serde(default)]
5670    merge_state_status: String,
5671    /// The commit the pull request currently points at. Compared with the
5672    /// commit a fix round pushed, it is how the loop knows the forge has moved
5673    /// on and the rollup belongs to the new head.
5674    #[serde(default)]
5675    head_ref_oid: String,
5676    #[serde(default)]
5677    base_ref_name: String,
5678    #[serde(default)]
5679    reviews: Vec<GhReview>,
5680    #[serde(default)]
5681    comments: Vec<GhComment>,
5682}
5683
5684/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
5685/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
5686/// status API, which is how CodeRabbit reports - has `context`/`state` and no
5687/// conclusion at all.
5688#[derive(Debug, Deserialize)]
5689#[serde(rename_all = "camelCase")]
5690struct GhCheck {
5691    #[serde(default)]
5692    name: Option<String>,
5693    #[serde(default)]
5694    context: Option<String>,
5695    #[serde(default)]
5696    status: Option<String>,
5697    #[serde(default)]
5698    conclusion: Option<String>,
5699    #[serde(default)]
5700    state: Option<String>,
5701    #[serde(default)]
5702    details_url: Option<String>,
5703    #[serde(default)]
5704    target_url: Option<String>,
5705    /// Whether the base branch requires this check. Only the GraphQL node
5706    /// carries it; `None` is "not read", never "not required".
5707    #[serde(default)]
5708    is_required: Option<bool>,
5709}
5710
5711impl GhCheck {
5712    /// Name to show a human and hand to the fixer.
5713    fn label(&self) -> String {
5714        self.name
5715            .clone()
5716            .or_else(|| self.context.clone())
5717            .unwrap_or_else(|| "(unnamed check)".to_owned())
5718    }
5719
5720    /// Where this check's logs live, when it has any.
5721    fn url(&self) -> Option<&str> {
5722        self.details_url
5723            .as_deref()
5724            .or(self.target_url.as_deref())
5725            .filter(|u| !u.is_empty())
5726    }
5727
5728    /// Did it pass?
5729    ///
5730    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
5731    /// skips release and bot pull requests by design, and a skip that blocked
5732    /// landing would block exactly the pull requests that need no review.
5733    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
5734    /// merging over one is merging over a check that never ran.
5735    fn verdict(&self) -> Verdict {
5736        if let Some(status) = self.status.as_deref() {
5737            if !status.eq_ignore_ascii_case("COMPLETED") {
5738                return Verdict::Pending;
5739            }
5740        }
5741        let outcome = self
5742            .conclusion
5743            .as_deref()
5744            .or(self.state.as_deref())
5745            .unwrap_or("");
5746        match outcome.to_ascii_uppercase().as_str() {
5747            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5748            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5749            | "ACTION_REQUIRED" => Verdict::Fail,
5750            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5751                Verdict::Pending
5752            }
5753            _ => Verdict::Unknown,
5754        }
5755    }
5756}
5757
5758#[derive(Debug, Deserialize)]
5759struct GhAuthor {
5760    #[serde(default)]
5761    login: String,
5762}
5763
5764#[derive(Debug, Deserialize)]
5765struct GhReview {
5766    #[serde(default)]
5767    author: GhAuthor,
5768    #[serde(default)]
5769    body: String,
5770}
5771
5772#[derive(Debug, Deserialize)]
5773struct GhComment {
5774    #[serde(default)]
5775    author: GhAuthor,
5776    #[serde(default)]
5777    body: String,
5778}
5779
5780#[derive(Debug, Deserialize)]
5781struct GhUser {
5782    #[serde(default)]
5783    login: String,
5784}
5785
5786#[derive(Debug, Deserialize)]
5787struct GhInline {
5788    #[serde(default)]
5789    user: GhUser,
5790    #[serde(default)]
5791    path: Option<String>,
5792    #[serde(default)]
5793    line: Option<u64>,
5794    #[serde(default)]
5795    body: String,
5796}
5797
5798impl Default for GhAuthor {
5799    fn default() -> Self {
5800        Self {
5801            login: "(unknown)".to_owned(),
5802        }
5803    }
5804}
5805
5806impl Default for GhUser {
5807    fn default() -> Self {
5808        Self {
5809            login: "(unknown)".to_owned(),
5810        }
5811    }
5812}
5813
5814#[cfg(test)]
5815pub(crate) mod tests {
5816    use super::*;
5817    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5818
5819    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5820        format!(
5821            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5822        )
5823    }
5824
5825    #[test]
5826    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5827        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5828        assert_eq!(
5829            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5830            Ok(())
5831        );
5832        for (json, why) in [
5833            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5834            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5835            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5836            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5837            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5838            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5839        ] {
5840            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5841                .unwrap_err()
5842                .why;
5843            assert!(err.contains(why), "{json}: {err}");
5844        }
5845        // A head that moved on past what was verified is left alone.
5846        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5847        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5848        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5849        assert!(
5850            !closable(
5851                &head_json("x", "main", "OPEN", false),
5852                "magi/27b2/A",
5853                "main",
5854                &[]
5855            )
5856            .unwrap_err()
5857            .retry
5858        );
5859        assert!(is_forge_url("https://github.com/o/r.git"));
5860        assert!(is_forge_url("git@github.com:o/r.git"));
5861        assert!(!is_forge_url("/tmp/origin.git"));
5862        assert!(!is_forge_url("C:\\work\\origin.git"));
5863        assert!(!is_forge_url("file:///tmp/origin.git"));
5864        assert!(forge_unavailable(
5865            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5866        ));
5867        assert!(!forge_unavailable(
5868            "gh pr list failed: error connecting to api.github.com"
5869        ));
5870        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5871        // A record that does not say whether it is a fork is not trusted.
5872        assert!(
5873            closable(
5874                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5875                "b",
5876                "main",
5877                &["aaa".to_owned()]
5878            )
5879            .is_err()
5880        );
5881    }
5882
5883    #[test]
5884    fn the_close_comment_names_the_commit_on_the_base() {
5885        let e = crate::already::Evidence {
5886            proof: crate::already::Proof::PatchId,
5887            tip: "1234567890".to_owned(),
5888            commits: vec!["0e368de0000".to_owned()],
5889        };
5890        let c = superseded_comment("main", &e);
5891        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5892    }
5893
5894    /// Real `gh pr view` output for the open pull request #10 (Renovate's apm bump), trimmed to four checks and its one comment. Every check passed or was skipped by the review workflow, and the only comment is CodeRabbit's trigger notice.
5895    const GREEN_OPEN: &str = r####"{
5896  "url": "https://github.com/yukimemi/magi/pull/10",
5897  "number": 10,
5898  "state": "OPEN",
5899  "mergeStateStatus": "CLEAN",
5900  "statusCheckRollup": [
5901    {
5902      "__typename": "CheckRun",
5903      "conclusion": "SKIPPED",
5904      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5905      "name": "review",
5906      "status": "COMPLETED",
5907      "workflowName": "claude-review"
5908    },
5909    {
5910      "__typename": "CheckRun",
5911      "conclusion": "SUCCESS",
5912      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5913      "name": "check (ubuntu-latest)",
5914      "status": "COMPLETED",
5915      "workflowName": "CI"
5916    },
5917    {
5918      "__typename": "CheckRun",
5919      "conclusion": "SUCCESS",
5920      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5921      "name": "rustfmt",
5922      "status": "COMPLETED",
5923      "workflowName": "CI"
5924    },
5925    {
5926      "__typename": "StatusContext",
5927      "context": "CodeRabbit",
5928      "state": "SUCCESS",
5929      "targetUrl": ""
5930    }
5931  ],
5932  "reviews": [],
5933  "comments": [
5934    {
5935      "author": {
5936        "login": "coderabbitai"
5937      },
5938      "authorAssociation": "NONE",
5939      "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Pro Plus\n> \n> **Run ID**: `78e70bf3-c5a0-4269-a96c-2afb2dba7eff`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=10)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summary>❤️ Share</summary>\n\n- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%2"
5940    }
5941  ]
5942}"####;
5943
5944    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
5945    const RED_OPEN: &str = r####"{
5946  "url": "https://github.com/yukimemi/magi/pull/9",
5947  "number": 9,
5948  "state": "OPEN",
5949  "mergeStateStatus": "UNSTABLE",
5950  "statusCheckRollup": [
5951    {
5952      "__typename": "CheckRun",
5953      "conclusion": "SUCCESS",
5954      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5955      "name": "check (ubuntu-latest)",
5956      "status": "COMPLETED",
5957      "workflowName": "CI"
5958    },
5959    {
5960      "__typename": "CheckRun",
5961      "conclusion": "SUCCESS",
5962      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5963      "name": "rustfmt",
5964      "status": "COMPLETED",
5965      "workflowName": "CI"
5966    },
5967    {
5968      "__typename": "CheckRun",
5969      "conclusion": "FAILURE",
5970      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5971      "name": "editorconfig",
5972      "status": "COMPLETED",
5973      "workflowName": "CI"
5974    },
5975    {
5976      "__typename": "StatusContext",
5977      "context": "CodeRabbit",
5978      "state": "SUCCESS",
5979      "targetUrl": ""
5980    }
5981  ],
5982  "reviews": [],
5983  "comments": [
5984    {
5985      "author": {
5986        "login": "coderabbitai"
5987      },
5988      "authorAssociation": "NONE",
5989      "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Team\n> \n> **Run ID**: `91e0dc24-6040-4c3d-92c6-f7d2b542523d`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderab"
5990    }
5991  ]
5992}"####;
5993
5994    /// Pull request #9's real payload with its `editorconfig` check rewound to the `IN_PROGRESS` / `conclusion: null` pair `gh` reports while a job is still in flight.
5995    const PENDING_OPEN: &str = r####"{
5996  "url": "https://github.com/yukimemi/magi/pull/9",
5997  "number": 9,
5998  "state": "OPEN",
5999  "statusCheckRollup": [
6000    {
6001      "__typename": "CheckRun",
6002      "conclusion": "SUCCESS",
6003      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6004      "name": "check (ubuntu-latest)",
6005      "status": "COMPLETED",
6006      "workflowName": "CI"
6007    },
6008    {
6009      "__typename": "CheckRun",
6010      "conclusion": "SUCCESS",
6011      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6012      "name": "rustfmt",
6013      "status": "COMPLETED",
6014      "workflowName": "CI"
6015    },
6016    {
6017      "__typename": "CheckRun",
6018      "conclusion": null,
6019      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6020      "name": "editorconfig",
6021      "status": "IN_PROGRESS",
6022      "workflowName": "CI"
6023    },
6024    {
6025      "__typename": "StatusContext",
6026      "context": "CodeRabbit",
6027      "state": "SUCCESS",
6028      "targetUrl": ""
6029    }
6030  ],
6031  "reviews": [],
6032  "comments": []
6033}"####;
6034
6035    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
6036    const MERGED: &str = r####"{
6037  "url": "https://github.com/yukimemi/magi/pull/16",
6038  "number": 16,
6039  "state": "MERGED",
6040  "statusCheckRollup": [
6041    {
6042      "__typename": "CheckRun",
6043      "conclusion": "SUCCESS",
6044      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6045      "name": "check (ubuntu-latest)",
6046      "status": "COMPLETED",
6047      "workflowName": "CI"
6048    },
6049    {
6050      "__typename": "CheckRun",
6051      "conclusion": "SUCCESS",
6052      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6053      "name": "review",
6054      "status": "COMPLETED",
6055      "workflowName": "claude-review"
6056    }
6057  ],
6058  "reviews": [],
6059  "comments": []
6060}"####;
6061
6062    /// Pull request #12's real payload - a green pull request carrying CodeRabbit's walkthrough and a Claude review that found a real bug - rewound to the `OPEN` state it was in when that review was posted.
6063    const REVIEWED_OPEN: &str = r####"{
6064  "url": "https://github.com/yukimemi/magi/pull/12",
6065  "number": 12,
6066  "state": "OPEN",
6067  "statusCheckRollup": [
6068    {
6069      "__typename": "CheckRun",
6070      "conclusion": "SUCCESS",
6071      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6072      "name": "check (ubuntu-latest)",
6073      "status": "COMPLETED",
6074      "workflowName": "CI"
6075    },
6076    {
6077      "__typename": "CheckRun",
6078      "conclusion": "SUCCESS",
6079      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6080      "name": "review",
6081      "status": "COMPLETED",
6082      "workflowName": "claude-review"
6083    }
6084  ],
6085  "reviews": [
6086    {
6087      "author": {
6088        "login": "claude"
6089      },
6090      "state": "COMMENTED",
6091      "body": ""
6092    }
6093  ],
6094  "comments": [
6095    {
6096      "author": {
6097        "login": "coderabbitai"
6098      },
6099      "authorAssociation": "NONE",
6100      "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Team\n> \n> **Run ID**: `72058bf3-b7df-41d9-8e4d-a06a31be4a26`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=12)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summa"
6101    },
6102    {
6103      "author": {
6104        "login": "claude"
6105      },
6106      "authorAssociation": "NONE",
6107      "body": "**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)\n\n---\n### Review: `magi review <branch>` — cheap-half-only graph\n\nRead through `src/graph.rs`, `src/main.rs`, `src/prompt.rs`, and the new/edited tests, and traced the claimed degeneration (`prep` short-circuits on a non-empty candidate list, `implement` skips because `commits != 0`, `judge`/`vote` skip on `viable().len() == 1`, `tally` skips because it's pre-set, `fold_losers` has no losers) against the actual code — it holds up. CI (`cargo make check`) is green on this PR.\n\n**Correctness**\n\n- One real bug, flagged inline on `src/graph.rs:1255`: the fixer-agent fallback (`self.roles.implementers[winner.index].clone()`) is unreachable in the normal graph (a real candidate's `winner.agent` always resolves via `config.agent(...)`), but a review-only run's `winner.agent` is always the `\"(existing branch)\"` sentinel, so this fallback now runs on *every* review-only fix that has no dedicated `[roles] fixer`. `graph.candidates` has no lower-bound validation, so a `magi.toml` tuned for review-only use (`candidates = 0`, plausible given this PR's own cost rationale) would panic with an out-of-bounds index the first time a"
6108    }
6109  ]
6110}"####;
6111
6112    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
6113    const INLINE: &str = r####"[
6114  {
6115    "user": {
6116      "login": "claude[bot]"
6117    },
6118    "path": "src/graph.rs",
6119    "line": 231,
6120    "body": "Minor edge case: unlike `implement()` (which sets `c.empty = commits == 0 || patch.trim().is_empty()`, `src/graph.rs:472`), the seeded review-only candidate always sets `empty: false` once `commits > 0` is confirmed, without checking whether the diff itself is actually empty (e.g. a commit immediately followed by a revert nets zero file changes). Such a branch would pass `Runner::review`'s validation and proceed into a review round with an empty patch, where `implement()`'s equivalent path would"
6121  }
6122]"####;
6123
6124    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
6125    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6126<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6127
6128> [!IMPORTANT]
6129> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6130> 
6131> This repository does not receive automatic reviews because it has fewer than 10 stars.
6132> 
6133> <details>
6134> <summary>⚙️ Run configuration</summary>
6135> 
6136> **Configuration used**: defaults
6137> 
6138> **Review profile**: CHILL
6139> 
6140> **Plan**: Team
6141> 
6142> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6143> 
6144> </details>
6145
6146<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6147
6148<!-- tips_start -->
6149
6150---
6151
6152Thanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=16)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
6153
6154<details>
6155<summary>❤️ Share</summary>
6156
6157- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20off"####;
6158
6159    /// The Claude review job's real comment while it is still working: a heading and a task list, and nothing that asks for a change.
6160    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6161
6162---
6163### Reviewing PR #16
6164
6165- [x] Read AGENTS.md conventions
6166- [x] Review `src/daemon.rs` changes
6167- [x] Review `src/main.rs` changes (new `doctor` reporting)
6168- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6169- [x] Check test coverage for new behavior
6170- [x] Run verification commands (blocked — see note)
6171- [x] Post findings"####;
6172
6173    /// The same job's real comment on pull request #12 once it had something to say.
6174    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6175
6176---
6177### Review: `magi review <branch>` — cheap-half-only graph
6178
6179Read through `src/graph.rs`, `src/main.rs`, `src/prompt.rs`, and the new/edited tests, and traced the claimed degeneration (`prep` short-circuits on a non-empty candidate list, `implement` skips because `commits != 0`, `judge`/`vote` skip on `viable().len() == 1`, `tally` skips because it's pre-set, `fold_losers` has no losers) against the actual code — it holds up. CI (`cargo make check`) is green on this PR.
6180
6181**Correctness**
6182
6183- One real bug, flagged inline on `src/graph.rs:1255`: the fixer-agent fallback (`self.roles.implementers[winner.index].clone()`) is unreachable in the normal graph (a real candidate's `winner.agent` always resolves via `config.agent(...)`), but a review-only run's `winner.agent` is always the `"(existing branch)"` sentinel, so this fallback now runs on *every* review-only fix that has no dedicated `[roles] fixer`. `graph.candidates` has no lower-bound validation, so a `magi.toml` tuned for review-only use (`candidates = 0`, plausible given this PR's own cost rationale) would panic with an out-of-bounds index the first time a"####;
6184
6185    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6186        PrState {
6187            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6188            number: 16,
6189            state: PrLifecycle::Open,
6190            checks,
6191            // These tests are about red-means-fix, so a red here is one the
6192            // forge gates on. Without saying so they would assert the new
6193            // "merge past a check nobody requires" path by accident.
6194            blocking: if matches!(checks, Checks::Red) {
6195                Blocking::Yes
6196            } else {
6197                Blocking::No
6198            },
6199            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6200            review_comments: (0..comments)
6201                .map(|i| ReviewComment {
6202                    author: "coderabbitai".to_owned(),
6203                    path: Some("src/graph.rs".to_owned()),
6204                    line: Some(231),
6205                    body: format!("finding {i}"),
6206                })
6207                .collect(),
6208        }
6209    }
6210
6211    #[test]
6212    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6213        use CiExpectation::{Absent, Expected};
6214        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6215            let p = pr(checks, &["x"], 0);
6216            for waited in [Duration::ZERO, CHECKS_GRACE] {
6217                assert_eq!(
6218                    decide_with(&p, 0, 4, waited, Expected),
6219                    decide(&p, 0, 4, waited)
6220                );
6221            }
6222        }
6223        // Nothing will ever report: no wait, no give-up, no fix round.
6224        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6225            let p = pr(checks, &["x"], 0);
6226            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6227            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6228        }
6229        // A conflict is not a check: it still wants the rebase.
6230        let mut p = pr(Checks::Unknown, &[], 0);
6231        p.blocking = Blocking::Conflict;
6232        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6233        // And a pull request that left our hands is still done.
6234        p.state = PrLifecycle::Merged;
6235        assert_eq!(
6236            decide_with(&p, 0, 4, Duration::ZERO, Absent),
6237            Step::Done { merged: true }
6238        );
6239    }
6240
6241    #[test]
6242    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6243        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6244        assert_eq!(state.number, 10);
6245        assert_eq!(state.state, PrLifecycle::Open);
6246        assert_eq!(state.checks, Checks::Green);
6247        assert!(state.failing.is_empty());
6248        assert!(
6249            state.review_comments.is_empty(),
6250            "the only comment is CodeRabbit's trigger notice: {:?}",
6251            state.review_comments
6252        );
6253        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6254    }
6255
6256    #[test]
6257    fn a_failing_check_parses_as_red_and_is_named() {
6258        let state = parse_pr(RED_OPEN).expect("red fixture parses");
6259        assert_eq!(state.checks, Checks::Red);
6260        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6261        // The captured payload says `UNSTABLE` - mergeable, with a check
6262        // nobody requires red - which is exactly the shape that had to be
6263        // merged by hand. Asserted separately, in
6264        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
6265        // test is about is that a red check is *named*, so the reason a fixer
6266        // is handed says which one; so it asks the blocking question here.
6267        let mut blocking = state.clone();
6268        blocking.blocking = Blocking::Yes;
6269        match decide(&blocking, 0, 4, Duration::ZERO) {
6270            Step::Fix { reason } => {
6271                assert!(reason.contains("editorconfig"), "reason: {reason}");
6272                assert!(reason.contains("failing"), "reason: {reason}");
6273            }
6274            other => panic!("expected a fix round, got {other:?}"),
6275        }
6276    }
6277
6278    #[test]
6279    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6280        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6281        assert_eq!(state.checks, Checks::Pending);
6282        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6283    }
6284
6285    #[test]
6286    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6287        let state = parse_pr(MERGED).expect("merged fixture parses");
6288        assert_eq!(state.state, PrLifecycle::Merged);
6289        assert_eq!(
6290            decide(&state, 0, 4, Duration::ZERO),
6291            Step::Done { merged: true }
6292        );
6293    }
6294
6295    #[test]
6296    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6297        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6298        assert_eq!(state.checks, Checks::Green);
6299        let authors: Vec<&str> = state
6300            .review_comments
6301            .iter()
6302            .map(|c| c.author.as_str())
6303            .collect();
6304        assert_eq!(
6305            authors,
6306            vec!["claude"],
6307            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6308        );
6309        match decide(&state, 0, 4, Duration::ZERO) {
6310            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6311            other => panic!("expected a fix round, got {other:?}"),
6312        }
6313    }
6314
6315    #[test]
6316    fn inline_review_comments_keep_their_file_and_line() {
6317        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6318        assert_eq!(comments.len(), 1);
6319        assert_eq!(comments[0].author, "claude[bot]");
6320        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6321        assert_eq!(comments[0].line, Some(231));
6322        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6323    }
6324
6325    #[test]
6326    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6327        assert!(
6328            is_noise(CODERABBIT_TRIGGER),
6329            "CodeRabbit's trigger notice declares itself not a review"
6330        );
6331        assert!(
6332            is_noise(CLAUDE_CHECKLIST),
6333            "a progress checklist asks for nothing"
6334        );
6335        assert!(
6336            !is_noise(CLAUDE_FINDING),
6337            "a review that names a bug is input, not noise"
6338        );
6339
6340        let mut clean = pr(Checks::Green, &[], 0);
6341        clean.review_comments.push(ReviewComment {
6342            author: "coderabbitai".to_owned(),
6343            path: None,
6344            line: None,
6345            body: CODERABBIT_TRIGGER.to_owned(),
6346        });
6347        clean.review_comments.retain(|c| !is_noise(&c.body));
6348        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6349
6350        let mut found = pr(Checks::Green, &[], 0);
6351        found.review_comments.push(ReviewComment {
6352            author: "claude".to_owned(),
6353            path: None,
6354            line: None,
6355            body: CLAUDE_FINDING.to_owned(),
6356        });
6357        found.review_comments.retain(|c| !is_noise(&c.body));
6358        assert!(matches!(
6359            decide(&found, 0, 4, Duration::ZERO),
6360            Step::Fix { .. }
6361        ));
6362    }
6363
6364    #[test]
6365    fn the_policy_table_holds_for_every_combination_that_matters() {
6366        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6367            (
6368                "pending checks are waited for, even on the last round",
6369                pr(Checks::Pending, &[], 0),
6370                4,
6371                4,
6372                Duration::ZERO,
6373                Step::Wait,
6374            ),
6375            (
6376                "red checks are fixed",
6377                pr(Checks::Red, &["editorconfig"], 0),
6378                0,
6379                4,
6380                Duration::ZERO,
6381                Step::Fix {
6382                    reason: "1 check(s) failing: editorconfig".to_owned(),
6383                },
6384            ),
6385            (
6386                "green with comments is fixed, not merged",
6387                pr(Checks::Green, &[], 2),
6388                1,
6389                4,
6390                Duration::ZERO,
6391                Step::Fix {
6392                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6393                        .to_owned(),
6394                },
6395            ),
6396            (
6397                "green and clean merges",
6398                pr(Checks::Green, &[], 0),
6399                3,
6400                4,
6401                Duration::ZERO,
6402                Step::Merge,
6403            ),
6404            (
6405                "an unreadable rollup is waited on while the grace lasts",
6406                pr(Checks::Unknown, &[], 0),
6407                0,
6408                4,
6409                Duration::ZERO,
6410                Step::Wait,
6411            ),
6412            (
6413                "an unreadable rollup is never merged once the grace is spent",
6414                pr(Checks::Unknown, &[], 0),
6415                0,
6416                4,
6417                CHECKS_GRACE,
6418                Step::GiveUp {
6419                    reason: "no check status is readable on the pull request after 3 minute(s); \
6420                             refusing to merge on a guess"
6421                        .to_owned(),
6422                },
6423            ),
6424        ];
6425        for (what, state, round, budget, waited, want) in cases {
6426            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6427        }
6428    }
6429
6430    #[test]
6431    fn the_forge_verdict_survives_the_round_trip_from_gh() {
6432        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
6433        // requested but never parsed is the kind of thing that looks wired up
6434        // and answers `Unsaid` forever.
6435        let green = parse_pr(GREEN_OPEN).expect("parse");
6436        assert_eq!(green.blocking, Blocking::No);
6437        let red = parse_pr(RED_OPEN).expect("parse");
6438        assert_eq!(
6439            red.blocking,
6440            Blocking::No,
6441            "`UNSTABLE` is mergeable: the red check is one nobody requires"
6442        );
6443        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6444        // A payload from an older `gh` has no such field at all.
6445        let quiet =
6446            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6447        assert_eq!(quiet.blocking, Blocking::Unsaid);
6448    }
6449
6450    #[test]
6451    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6452        // Pull request 37's only red check was `editorconfig`, failing
6453        // because the action could not fetch its own binary after
6454        // editorconfig-checker v4 renamed its release assets. The repository
6455        // does not require it. magi answered by asking a fixer to repair a
6456        // change that was fine, and the pull request had to be merged by hand.
6457        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6458        nonblocking.blocking = Blocking::No;
6459        assert_eq!(
6460            decide(&nonblocking, 0, 4, Duration::ZERO),
6461            Step::Merge,
6462            "the forge says nothing is in the way, so nothing is"
6463        );
6464
6465        // The same red, gated on: that is a fix round, as before.
6466        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6467        blocking.blocking = Blocking::Yes;
6468        assert!(matches!(
6469            decide(&blocking, 0, 4, Duration::ZERO),
6470            Step::Fix { .. }
6471        ));
6472
6473        // A review comment still outranks green-enough: a non-required red
6474        // must not become a way to merge past an unanswered reviewer.
6475        let mut commented = pr(Checks::Red, &["coverage"], 1);
6476        commented.blocking = Blocking::No;
6477        assert!(matches!(
6478            decide(&commented, 0, 4, Duration::ZERO),
6479            Step::Fix { .. }
6480        ));
6481
6482        // And silence from the forge is not consent.
6483        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6484        unsaid.blocking = Blocking::Unsaid;
6485        assert!(matches!(
6486            decide(&unsaid, 0, 4, Duration::ZERO),
6487            Step::Fix { .. }
6488        ));
6489    }
6490
6491    #[test]
6492    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6493        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6494        red.blocking = Blocking::No;
6495        assert_eq!(
6496            decide(&red, 0, 4, Duration::ZERO),
6497            Step::Merge,
6498            "announcing must not change the decision"
6499        );
6500        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6501        assert!(said.contains("yukimemi/magi"), "{said}");
6502        assert!(said.contains("#16"), "{said}");
6503        assert!(
6504            said.contains("https://github.com/yukimemi/magi/pull/16"),
6505            "{said}"
6506        );
6507        assert!(
6508            said.contains("test (windows-latest)") && said.contains("coverage"),
6509            "{said}"
6510        );
6511
6512        // `failing` can be left over on a green observation; only `checks` counts.
6513        let green = pr(Checks::Green, &["stale"], 0);
6514        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6515    }
6516
6517    #[test]
6518    fn red_merge_notices_omit_empty_check_details() {
6519        for checks in [vec![], vec!["", "  "]] {
6520            let red = pr(Checks::Red, &checks, 0);
6521            let summary = red_merge_summary("owner/repo", &red).unwrap();
6522            let notice = crate::notices::merged_red("run-1", &summary);
6523            assert_eq!(
6524                notice.message,
6525                format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6526            );
6527            assert_eq!(notice.severity, crate::notices::Severity::Warn);
6528        }
6529        let red = pr(Checks::Red, &["", " test ", " "], 0);
6530        let summary = red_merge_summary("owner/repo", &red).unwrap();
6531        assert!(summary.contains("with red checks: test ("), "{summary}");
6532    }
6533
6534    #[test]
6535    fn the_repo_label_comes_from_the_pull_request_url() {
6536        let p = Path::new("/tmp/checkout");
6537        assert_eq!(
6538            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6539            "yukimemi/magi"
6540        );
6541        assert_eq!(repo_label(p, "not a url"), "checkout");
6542    }
6543
6544    #[test]
6545    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6546        // Pull requests 35 and 37 were both rebased by hand: a competition
6547        // that runs for two hours against a repository merging pull requests
6548        // all day conflicts on the way in, and that is arithmetic rather
6549        // than a defect in the change.
6550        let mut conflicted = pr(Checks::Green, &[], 0);
6551        conflicted.blocking = Blocking::Conflict;
6552        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6553
6554        // Decided before the checks, and even with the rounds spent: every
6555        // check on a branch that cannot land is an answer about a state that
6556        // cannot land, and a conflict is not the change's fault.
6557        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6558        red.blocking = Blocking::Conflict;
6559        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6560
6561        // The lifecycle still wins over everything, conflict included.
6562        let mut merged = pr(Checks::Red, &[], 0);
6563        merged.blocking = Blocking::Conflict;
6564        merged.state = PrLifecycle::Merged;
6565        assert_eq!(
6566            decide(&merged, 0, 4, Duration::ZERO),
6567            Step::Done { merged: true }
6568        );
6569    }
6570
6571    #[test]
6572    fn the_forge_verdict_is_read_off_merge_state_status() {
6573        // The spellings that mean "mergeable". `UNSTABLE` is the one that
6574        // matters: mergeable, with a non-required check red or still running.
6575        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6576            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6577            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6578        }
6579        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6580        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6581        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6582        // An older `gh`, or a token without the scope, says nothing - and
6583        // refusing to guess is the rule everywhere else in this module.
6584        for quiet in ["", "UNKNOWN"] {
6585            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6586            assert!(Blocking::of(quiet).stops_a_merge());
6587        }
6588    }
6589
6590    #[test]
6591    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6592        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6593        // The exact stderr from run ec12, in a jj-colocated repository.
6594        let jj = "could not determine current branch: failed to run git: not on any branch";
6595
6596        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6597            .expect("the forge says merged, so it merged");
6598        assert!(landed.ok);
6599        assert!(
6600            landed.detail.contains("but the pull request is merged"),
6601            "the record must not read as a clean success: {}",
6602            landed.detail
6603        );
6604        assert!(
6605            landed.detail.contains("not on any branch"),
6606            "and it must keep what the command actually said: {}",
6607            landed.detail
6608        );
6609
6610        // A pull request still open means the merge really failed.
6611        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6612        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6613        // And an unreadable answer is not evidence of success.
6614        assert!(merged_after_all(&argv, jj, None).is_none());
6615    }
6616
6617    #[test]
6618    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6619        let mut state = pr(Checks::Red, &["editorconfig"], 3);
6620        state.state = PrLifecycle::Closed;
6621        assert_eq!(
6622            decide(&state, 0, 4, Duration::ZERO),
6623            Step::Done { merged: false },
6624            "a human closing the pull request ends the loop, whatever CI says"
6625        );
6626    }
6627
6628    #[test]
6629    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6630        let red = decide(
6631            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6632            4,
6633            4,
6634            Duration::ZERO,
6635        );
6636        match red {
6637            Step::GiveUp { reason } => {
6638                assert!(reason.contains("editorconfig"), "reason: {reason}");
6639                assert!(reason.contains("test (macos)"), "reason: {reason}");
6640                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6641            }
6642            other => panic!("expected a give-up, got {other:?}"),
6643        }
6644
6645        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6646        match commented {
6647            Step::GiveUp { reason } => {
6648                assert!(reason.contains("unresolved"), "reason: {reason}");
6649                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6650            }
6651            other => panic!("expected a give-up, got {other:?}"),
6652        }
6653    }
6654
6655    #[test]
6656    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6657        let candidate_commit = "magi: candidate A (uncommitted work)";
6658        let subject = merge_subject(candidate_commit, "add retries to the uploader");
6659        let argv = merge_argv(16, &subject);
6660
6661        assert!(argv.contains(&"--squash".to_owned()));
6662        assert!(argv.contains(&"--delete-branch".to_owned()));
6663        assert!(argv.contains(&"--subject".to_owned()));
6664        assert_eq!(
6665            argv.last().map(String::as_str),
6666            Some("add retries to the uploader"),
6667            "the subject must not be the candidate commit message"
6668        );
6669        assert_ne!(subject, candidate_commit);
6670    }
6671
6672    #[test]
6673    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6674        assert_eq!(
6675            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6676            "feat: a queue, an unattended loop, and a phone UI"
6677        );
6678        assert_eq!(
6679            merge_subject("", "# port the retry logic\n\ndetails"),
6680            "port the retry logic",
6681            "an empty title falls back to the task's first line, heading marks stripped"
6682        );
6683    }
6684
6685    #[test]
6686    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6687        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6688        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6689        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6690        assert_eq!(job_of("https://coderabbit.ai/status"), None);
6691        assert_eq!(run_of(""), None);
6692    }
6693
6694    #[test]
6695    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6696        let mut out = Vec::new();
6697        push_if_outstanding(
6698            &mut out,
6699            ReviewComment {
6700                author: "yukimemi".to_owned(),
6701                path: None,
6702                line: None,
6703                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6704            },
6705        );
6706        assert!(out.is_empty());
6707    }
6708
6709    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
6710    /// falls back to the repository - which keeps these tests free of a
6711    /// worktree, a `git` invocation and a network.
6712    fn run_state() -> RunState {
6713        let mut state = RunState::new(
6714            std::path::PathBuf::from("/repo/magi"),
6715            "main".to_owned(),
6716            "abcdef1234".to_owned(),
6717            "add retries to the uploader".to_owned(),
6718            crate::config::Config::default(),
6719        );
6720        // Tests run in parallel against one persistent home and the ids
6721        // `RunState::new` draws from the clock can repeat, so two tests would
6722        // share a run's questions. The home also outlives the process, so the
6723        // counter alone would reuse an earlier run's ids.
6724        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6725        let nanos = std::time::SystemTime::now()
6726            .duration_since(std::time::UNIX_EPOCH)
6727            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6728        state.id = format!(
6729            "20261004-{nanos:06}-{:04x}",
6730            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6731        );
6732        state
6733    }
6734
6735    fn green_pr() -> PrState {
6736        PrState {
6737            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6738            number: 42,
6739            state: PrLifecycle::Open,
6740            checks: Checks::Green,
6741            // The forge sees nothing in the way unless a test says otherwise.
6742            blocking: Blocking::No,
6743            failing: Vec::new(),
6744            review_comments: vec![ReviewComment {
6745                author: "coderabbitai".to_owned(),
6746                path: Some("src/land.rs".to_owned()),
6747                line: Some(212),
6748                body: "this branch never checks the exit code".to_owned(),
6749            }],
6750        }
6751    }
6752
6753    #[test]
6754    fn land_fixer_defers_review_and_verification_without_a_cache() {
6755        let state = run_state();
6756        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6757        assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
6758        assert!(p.contains("Commit in this worktree"), "{p}");
6759    }
6760
6761    #[test]
6762    fn github_facing_land_text_is_english_whatever_the_language() {
6763        let mut state = run_state();
6764        state.config.graph.language = "ja".to_owned();
6765        let comment = stop_comment(&state.id, "checks are still red");
6766        assert!(crate::github_text::check("", &comment).is_empty());
6767        assert!(comment.is_ascii(), "{comment}");
6768        assert!(comment.starts_with(MARKER));
6769
6770        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6771        let ja_at = p.find("Write all prose in ja").unwrap();
6772        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6773        assert!(ja_at < rule_at, "{p}");
6774        assert!(p.contains("stays in Japanese"), "{p}");
6775
6776        state.config.graph.language = "en".to_owned();
6777        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6778        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6779        assert!(!p.contains("does not apply"), "{p}");
6780    }
6781
6782    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6783
6784    fn panel() -> String {
6785        approval_panel(
6786            &run_state(),
6787            &green_pr(),
6788            NUMSTAT,
6789            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6790            &[
6791                "land: ask before merging".to_owned(),
6792                "land: colour the diff".to_owned(),
6793            ],
6794            "feat: merge approval from the phone",
6795        )
6796    }
6797
6798    #[test]
6799    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6800        let html = panel();
6801        for needle in [
6802            "42",
6803            "main",
6804            "src/land.rs",
6805            "src/web.rs",
6806            "assets/logo.png",
6807            "feat: merge approval from the phone",
6808            "land: ask before merging",
6809            "land: colour the diff",
6810            "coderabbitai",
6811            "this branch never checks the exit code",
6812            "green",
6813        ] {
6814            assert!(html.contains(needle), "the panel must state `{needle}`");
6815        }
6816    }
6817
6818    async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
6819        crate::run::pin_test_home();
6820        let tmp = tempfile::tempdir().unwrap();
6821        let repo = tmp.path().join("repo");
6822        let remote = tmp.path().join("remote.git");
6823        std::fs::create_dir(&repo).unwrap();
6824        git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
6825            .await
6826            .unwrap();
6827        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
6828        git::git(&repo, &["config", "user.name", "Test"])
6829            .await
6830            .unwrap();
6831        git::git(&repo, &["config", "user.email", "test@example.test"])
6832            .await
6833            .unwrap();
6834        std::fs::write(repo.join("base"), "base").unwrap();
6835        git::git(&repo, &["add", "."]).await.unwrap();
6836        git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
6837        git::git(
6838            &repo,
6839            &["remote", "add", "origin", remote.to_str().unwrap()],
6840        )
6841        .await
6842        .unwrap();
6843        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
6844        git::git(&repo, &["checkout", "-b", "winner"])
6845            .await
6846            .unwrap();
6847        std::fs::write(repo.join("change"), "change").unwrap();
6848        git::git(&repo, &["add", "."]).await.unwrap();
6849        git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
6850        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
6851        git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
6852            .await
6853            .unwrap();
6854        let mut state = run_state();
6855        state.repo = repo.clone();
6856        state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
6857        state.config.graph.land = true;
6858        state.config.graph.land_approval = true;
6859        state.pr_head = Some("task-head".into());
6860        state.pr_pushed_head = Some(head.clone());
6861        state.candidates = vec![winning_candidate("change")];
6862        state.candidates[0].worktree = repo;
6863        state.candidates[0].branch = "winner".into();
6864        state.tally = Some(uncontested_tally());
6865        state.gate_ran = true;
6866        state.gate_head = Some(head.clone());
6867        state.land_sync_pending = true;
6868        state.save().unwrap();
6869        (tmp, state, head)
6870    }
6871
6872    async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
6873        let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
6874        resume_landing_head(state, head, &green_pr().url, &forge).await
6875    }
6876
6877    async fn advance_landing_base(state: &RunState) {
6878        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
6879        std::fs::write(state.repo.join("advance"), "advance").unwrap();
6880        git::git(&state.repo, &["add", "."]).await.unwrap();
6881        git::git(&state.repo, &["commit", "-m", "advance base"])
6882            .await
6883            .unwrap();
6884        git::git(&state.repo, &["push", "origin", "main"])
6885            .await
6886            .unwrap();
6887        git::git(&state.repo, &["checkout", "winner"])
6888            .await
6889            .unwrap();
6890    }
6891
6892    #[tokio::test]
6893    async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
6894        let (_tmp, mut state, old) = landing_git_fixture().await;
6895        advance_landing_base(&state).await;
6896        state.config.verify.gate = vec!["test -f advance".into()];
6897        let head = resume_observed_head(&mut state, &old).await.unwrap();
6898        assert_ne!(head, old);
6899        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
6900        assert!(state.gate_allows_progress());
6901        assert_eq!(
6902            git::remote_head(&state.repo, "origin", "task-head")
6903                .await
6904                .unwrap(),
6905            Some(head.clone())
6906        );
6907        assert_eq!(state.pr_pushed_head, Some(head));
6908        assert!(!state.land_sync_pending);
6909        assert!(state.config.graph.land_approval);
6910    }
6911
6912    fn landing_gate_fixer(state: &mut RunState, script: &str) {
6913        state.config.disk.min_free_bytes = 0;
6914        state.config.graph.gate_fix_rounds = 1;
6915        state.config.agents = vec![crate::config::AgentSpec {
6916            id: "mock".into(),
6917            kind: crate::config::AgentKind::Command,
6918            model: None,
6919            command: vec!["sh".into(), "-c".into(), script.into()],
6920            extra_args: Vec::new(),
6921            env: Default::default(),
6922            prompt_delivery: None,
6923        }];
6924        state.candidates[0].agent = "mock".into();
6925    }
6926
6927    #[tokio::test]
6928    async fn landing_resume_revalidates_an_operator_published_winner_extension() {
6929        let (_tmp, mut state, old) = landing_git_fixture().await;
6930        std::fs::write(state.repo.join("change"), "operator update").unwrap();
6931        git::commit_all(&state.repo, "extend winner").await.unwrap();
6932        let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
6933        git::git(
6934            &state.repo,
6935            &["push", "origin", "HEAD:refs/heads/task-head"],
6936        )
6937        .await
6938        .unwrap();
6939        state.config.verify.gate = vec!["exit 1".into()];
6940        state.config.graph.gate_fix_rounds = 0;
6941        assert!(resume_observed_head(&mut state, &updated).await.is_err());
6942        assert_ne!(old, updated);
6943        assert_eq!(state.gate_head.as_ref(), Some(&updated));
6944        assert!(!state.gate_allows_progress());
6945        assert!(state.land_sync_pending);
6946        state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
6947        let head = resume_observed_head(&mut state, &updated).await.unwrap();
6948        assert_eq!(head, updated);
6949        assert!(state.gate_allows_progress());
6950        assert!(!state.land_sync_pending);
6951        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
6952        land_with(&mut state, &green_pr().url, &forge)
6953            .await
6954            .unwrap();
6955        assert!(state.parked);
6956        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
6957        assert!(forge.argvs().is_empty());
6958    }
6959
6960    #[tokio::test]
6961    async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
6962        for divergent in [false, true] {
6963            let (_tmp, mut state, old) = landing_git_fixture().await;
6964            if divergent {
6965                git::git(&state.repo, &["reset", "--hard", "origin/main"])
6966                    .await
6967                    .unwrap();
6968            }
6969            std::fs::write(state.repo.join("external"), "external").unwrap();
6970            git::commit_all(&state.repo, "external update")
6971                .await
6972                .unwrap();
6973            let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
6974            git::git(
6975                &state.repo,
6976                &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
6977            )
6978            .await
6979            .unwrap();
6980            if !divergent {
6981                git::git(&state.repo, &["reset", "--hard", &old])
6982                    .await
6983                    .unwrap();
6984            }
6985            let error = resume_observed_head(&mut state, &external)
6986                .await
6987                .unwrap_err();
6988            assert!(
6989                error
6990                    .to_string()
6991                    .contains("public head moved outside this run")
6992            );
6993            assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
6994            assert_eq!(
6995                git::remote_head(&state.repo, "origin", "task-head")
6996                    .await
6997                    .unwrap(),
6998                Some(external)
6999            );
7000        }
7001    }
7002
7003    #[tokio::test]
7004    async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
7005        let (_tmp, mut state, old) = landing_git_fixture().await;
7006        advance_landing_base(&state).await;
7007        landing_gate_fixer(
7008            &mut state,
7009            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7010        );
7011        state.config.verify.gate =
7012            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7013        state.config.verify.e2e = vec!["test -f advance".into()];
7014        // First save a red gate without spending a fixer round. Resume must
7015        // still offer the remaining budget for this exact same failed head.
7016        state.config.graph.gate_fix_rounds = 0;
7017        assert!(resume_observed_head(&mut state, &old).await.is_err());
7018        assert_eq!(
7019            git::remote_head(&state.repo, "origin", "task-head")
7020                .await
7021                .unwrap(),
7022            Some(old.clone())
7023        );
7024        state.config.graph.gate_fix_rounds = 1;
7025        let head = resume_observed_head(&mut state, &old).await.unwrap();
7026        assert_eq!(state.gate_fixes.len(), 1);
7027        assert!(state.gate_fixes[0].committed);
7028        assert!(state.gate_allows_progress());
7029        assert_eq!(state.gate_head.as_ref(), Some(&head));
7030        assert_eq!(
7031            git::remote_head(&state.repo, "origin", "task-head")
7032                .await
7033                .unwrap(),
7034            Some(head.clone())
7035        );
7036        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7037        land_with(&mut state, &green_pr().url, &forge)
7038            .await
7039            .unwrap();
7040        assert!(state.parked);
7041        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7042        assert!(forge.argvs().is_empty());
7043    }
7044
7045    #[tokio::test]
7046    async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7047        let (_tmp, mut state, old) = landing_git_fixture().await;
7048        advance_landing_base(&state).await;
7049        landing_gate_fixer(
7050            &mut state,
7051            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7052        );
7053        state.config.verify.gate = vec!["echo still red; exit 1".into()];
7054        for _ in 0..2 {
7055            assert!(resume_observed_head(&mut state, &old).await.is_err());
7056            assert_eq!(state.gate_fixes.len(), 1);
7057            assert_eq!(
7058                git::remote_head(&state.repo, "origin", "task-head")
7059                    .await
7060                    .unwrap(),
7061                Some(old.clone())
7062            );
7063        }
7064    }
7065
7066    #[tokio::test]
7067    async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7068        let (_tmp, mut state, old) = landing_git_fixture().await;
7069        advance_landing_base(&state).await;
7070        landing_gate_fixer(
7071            &mut state,
7072            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7073        );
7074        state.config.verify.gate =
7075            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7076        // The repair makes the gate green while breaking an e2e invariant.
7077        state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7078        for resume in 0..3 {
7079            assert!(resume_observed_head(&mut state, &old).await.is_err());
7080            state = RunState::load(&state.id).unwrap();
7081            let evidence = state.gate_repair_e2e.as_ref().unwrap();
7082            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7083            assert_eq!(evidence.head, head);
7084            assert!(!evidence.passed);
7085            assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7086            assert_eq!(state.gate_fixes.len(), 1);
7087            assert_eq!(
7088                git::remote_head(&state.repo, "origin", "task-head")
7089                    .await
7090                    .unwrap(),
7091                Some(old.clone())
7092            );
7093            if resume > 0 {
7094                assert!(
7095                    state.gate_allows_progress(),
7096                    "a green gate must not bypass red e2e"
7097                );
7098            }
7099        }
7100        // Once e2e genuinely succeeds on the same SHA, the saved requirement
7101        // can be satisfied without another fixer or approval bypass.
7102        state.config.verify.e2e = vec!["grep -q fixed change".into()];
7103        let head = resume_observed_head(&mut state, &old).await.unwrap();
7104        assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7105        assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7106        assert_eq!(state.gate_fixes.len(), 1);
7107        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7108        land_with(&mut state, &green_pr().url, &forge)
7109            .await
7110            .unwrap();
7111        assert!(state.parked);
7112        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7113        assert!(forge.argvs().is_empty());
7114    }
7115
7116    #[tokio::test]
7117    async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7118        for phase in 0..3 {
7119            let (_tmp, mut state, old) = landing_git_fixture().await;
7120            advance_landing_base(&state).await;
7121            let head = resume_observed_head(&mut state, &old).await.unwrap();
7122            // Persist the shapes left by interruption, resource deferral,
7123            // and a successful check for a different (older) SHA.
7124            state.land_sync_pending = true;
7125            state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7126            state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7127                head: if phase == 2 {
7128                    old.clone()
7129                } else {
7130                    head.clone()
7131                },
7132                passed: phase == 2,
7133                outcomes: if phase == 1 {
7134                    vec![crate::run::CommandOutcome {
7135                        command: state.config.verify.e2e[0].clone(),
7136                        code: None,
7137                        output_tail: "verification resource unavailable".into(),
7138                        duration_ms: 0,
7139                        resource_blocked: true,
7140                    }]
7141                } else {
7142                    Vec::new()
7143                },
7144            });
7145            state.save().unwrap();
7146            for _ in 0..2 {
7147                state = RunState::load(&state.id).unwrap();
7148                assert!(resume_observed_head(&mut state, &head).await.is_err());
7149                let evidence = state.gate_repair_e2e.as_ref().unwrap();
7150                assert_eq!(evidence.head, head);
7151                assert!(!evidence.passed);
7152                assert!(!evidence.outcomes.is_empty());
7153                assert_eq!(
7154                    git::remote_head(&state.repo, "origin", "task-head")
7155                        .await
7156                        .unwrap(),
7157                    Some(head.clone())
7158                );
7159                assert!(state.land_sync_pending);
7160            }
7161        }
7162    }
7163
7164    async fn interrupt_landing_rebase(state: &mut RunState) {
7165        state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7166        state.save().unwrap();
7167        let scratch = state.dir().join("rebase");
7168        assert_eq!(
7169            crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7170                .await
7171                .unwrap(),
7172            crate::rebase::Rebased::Applied
7173        );
7174        assert!(!git::is_clean(&state.repo).await.unwrap());
7175    }
7176
7177    async fn landing_setup(state: &mut RunState) {
7178        git::git(
7179            &state.repo,
7180            &["config", "extensions.worktreeConfig", "true"],
7181        )
7182        .await
7183        .unwrap();
7184        state.config.worktree.setup =
7185            vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7186        crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7187            .await
7188            .unwrap();
7189        state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7190    }
7191
7192    #[tokio::test]
7193    async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7194        for phase in 0..3 {
7195            let (_tmp, mut state, old) = landing_git_fixture().await;
7196            advance_landing_base(&state).await;
7197            landing_setup(&mut state).await;
7198            if phase > 0 {
7199                interrupt_landing_rebase(&mut state).await;
7200                if phase == 2 {
7201                    // Crash after clean removed setup products, before setup.
7202                    git::sync_to_head(&state.repo).await.unwrap();
7203                    assert!(!state.repo.join(".env").exists());
7204                }
7205                state = RunState::load(&state.id).unwrap();
7206            }
7207            let head = resume_observed_head(&mut state, &old).await.unwrap();
7208            assert_ne!(head, old);
7209            assert!(state.gate_allows_progress());
7210            assert!(state.land_rebase_from.is_none());
7211            assert_eq!(
7212                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7213                "secret"
7214            );
7215            assert!(git::is_clean(&state.repo).await.unwrap());
7216            assert!(
7217                !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7218                    .await
7219                    .unwrap()
7220                    .contains(".env")
7221            );
7222        }
7223    }
7224
7225    #[tokio::test]
7226    async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7227        let (_tmp, mut state, _) = landing_git_fixture().await;
7228        advance_landing_base(&state).await;
7229        landing_setup(&mut state).await;
7230        interrupt_landing_rebase(&mut state).await;
7231        // Exercise unusual names without asking Windows to create an invalid
7232        // control-character filename. Unix also covers embedded newlines.
7233        let locals = [
7234            "local notes",
7235            #[cfg(unix)]
7236            "local\nnotes",
7237        ];
7238        let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7239            .await
7240            .unwrap();
7241        std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7242        for local in locals {
7243            std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7244            assert!(
7245                git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7246                    .await
7247                    .unwrap()
7248                    .ok()
7249            );
7250        }
7251        restore_landing_worktree(&mut state).await.unwrap();
7252        for local in locals {
7253            assert_eq!(
7254                std::fs::read_to_string(state.repo.join(local)).unwrap(),
7255                "keep these notes"
7256            );
7257        }
7258        assert_eq!(
7259            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7260            "secret"
7261        );
7262        assert!(git::is_clean(&state.repo).await.unwrap());
7263        assert!(state.land_rebase_from.is_none());
7264    }
7265
7266    #[tokio::test]
7267    async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7268        for local in ["advance", "advance/notes", "new-directory"] {
7269            let (_tmp, mut state, _) = landing_git_fixture().await;
7270            advance_landing_base(&state).await;
7271            // Also cover a local file blocking a new tracked directory.
7272            git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7273            std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7274            std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7275            git::git(&state.repo, &["add", "."]).await.unwrap();
7276            git::git(&state.repo, &["commit", "-m", "add directory"])
7277                .await
7278                .unwrap();
7279            git::git(&state.repo, &["push", "origin", "main"])
7280                .await
7281                .unwrap();
7282            git::git(&state.repo, &["checkout", "winner"])
7283                .await
7284                .unwrap();
7285            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7286                .await
7287                .unwrap();
7288            std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7289            let path = state.repo.join(local);
7290            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7291            std::fs::write(&path, "local notes").unwrap();
7292            interrupt_landing_rebase(&mut state).await;
7293            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7294            let checkpoint = state.land_rebase_from.clone();
7295            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7296            assert!(
7297                error.to_string().contains("conflicting with HEAD"),
7298                "{error:#}"
7299            );
7300            assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7301            assert_eq!(
7302                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7303                before
7304            );
7305            assert_eq!(state.land_rebase_from, checkpoint);
7306        }
7307    }
7308
7309    #[tokio::test]
7310    async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7311        for interrupted in [false, true] {
7312            let (_tmp, mut state, old) = landing_git_fixture().await;
7313            assert!(state.config.worktree.setup.is_empty());
7314            // A gate product exists before the base moves, without setup metadata.
7315            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7316                .await
7317                .unwrap();
7318            std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7319            state.config.verify.gate =
7320                vec!["mkdir -p target && printf artifact > target/probe".into()];
7321            state.gate_ran = false;
7322            resume_observed_head(&mut state, &old).await.unwrap();
7323            assert_eq!(
7324                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7325                "artifact"
7326            );
7327            advance_landing_base(&state).await;
7328            if interrupted {
7329                interrupt_landing_rebase(&mut state).await;
7330                state = RunState::load(&state.id).unwrap();
7331            }
7332            state.config.verify.gate =
7333                vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7334            let head = resume_observed_head(&mut state, &old).await.unwrap();
7335            assert_ne!(head, old);
7336            assert!(state.gate_allows_progress());
7337            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7338            assert_eq!(
7339                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7340                "artifact"
7341            );
7342            assert!(state.land_rebase_from.is_none());
7343            assert_eq!(
7344                git::remote_head(&state.repo, "origin", "task-head")
7345                    .await
7346                    .unwrap(),
7347                Some(head.clone())
7348            );
7349            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7350            land_with(&mut state, &green_pr().url, &forge)
7351                .await
7352                .unwrap();
7353            assert!(state.parked);
7354            assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7355            assert!(forge.argvs().is_empty());
7356        }
7357    }
7358
7359    #[tokio::test]
7360    #[cfg(unix)]
7361    async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7362        use std::os::fd::AsRawFd as _;
7363
7364        for interrupted in [false, true] {
7365            let (_tmp, mut state, _) = landing_git_fixture().await;
7366            advance_landing_base(&state).await;
7367            landing_setup(&mut state).await;
7368            if interrupted {
7369                interrupt_landing_rebase(&mut state).await;
7370            }
7371            state.config.worktree.setup =
7372                vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7373            let common = crate::repo_lock::common_dir(&state.repo)
7374                .await
7375                .unwrap()
7376                .unwrap();
7377            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7378            state.config.coordination = crate::config::test_coordination();
7379            state.config.graph.timeout_resource_wait = 0;
7380            let external = std::fs::OpenOptions::new()
7381                .read(true)
7382                .write(true)
7383                .create(true)
7384                .truncate(false)
7385                .open(common.join("updater.lock"))
7386                .unwrap();
7387            // SAFETY: the descriptor belongs to `external` and remains open.
7388            let locked =
7389                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7390            assert_eq!(locked, 0);
7391            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7392            let checkpoint = state.land_rebase_from.clone();
7393            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7394            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7395            assert_eq!(
7396                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7397                before
7398            );
7399            assert_eq!(state.land_rebase_from, checkpoint);
7400            assert_eq!(
7401                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7402                "secret"
7403            );
7404
7405            drop(external);
7406            state.config.graph.timeout_resource_wait = 2;
7407            restore_landing_worktree(&mut state).await.unwrap();
7408            assert!(git::is_clean(&state.repo).await.unwrap());
7409            assert!(state.land_rebase_from.is_none());
7410            assert_eq!(
7411                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7412                "restored"
7413            );
7414        }
7415    }
7416
7417    #[tokio::test]
7418    #[cfg(unix)]
7419    async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7420        use std::sync::atomic::{AtomicBool, Ordering};
7421
7422        let (_tmp, mut state, _) = landing_git_fixture().await;
7423        advance_landing_base(&state).await;
7424        landing_setup(&mut state).await;
7425        interrupt_landing_rebase(&mut state).await;
7426        let common = crate::repo_lock::common_dir(&state.repo)
7427            .await
7428            .unwrap()
7429            .unwrap();
7430        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7431        state.config.coordination = crate::config::test_coordination();
7432        let holder = crate::repo_lock::SeatLease::for_checkout(
7433            &state.repo,
7434            true,
7435            state.config.coordination.as_ref(),
7436        )
7437        .await
7438        .unwrap();
7439        assert!(holder.is_some());
7440        let cancelled = Arc::new(AtomicBool::new(false));
7441        let signal = cancelled.clone();
7442        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7443            signal.load(Ordering::SeqCst)
7444        });
7445        let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7446        let checkpoint = state.land_rebase_from.clone();
7447        let (result, ()) = tokio::join!(
7448            tokio::time::timeout(
7449                Duration::from_secs(2),
7450                wait.scope(restore_landing_worktree(&mut state)),
7451            ),
7452            async {
7453                tokio::time::sleep(Duration::from_millis(100)).await;
7454                cancelled.store(true, Ordering::SeqCst);
7455            }
7456        );
7457        let error = result
7458            .expect("cancellation must release the waiting driver")
7459            .unwrap_err();
7460        assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7461        assert_eq!(
7462            git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7463            before
7464        );
7465        assert_eq!(state.land_rebase_from, checkpoint);
7466        assert_eq!(
7467            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7468            "secret"
7469        );
7470        drop(holder);
7471        restore_landing_worktree(&mut state).await.unwrap();
7472        assert!(state.land_rebase_from.is_none());
7473        assert!(git::is_clean(&state.repo).await.unwrap());
7474    }
7475
7476    #[tokio::test]
7477    #[cfg(unix)]
7478    async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
7479        use std::sync::atomic::{AtomicBool, Ordering};
7480
7481        for e2e in [false, true] {
7482            let (_tmp, mut state, head) = landing_git_fixture().await;
7483            let common = crate::repo_lock::common_dir(&state.repo)
7484                .await
7485                .unwrap()
7486                .unwrap();
7487            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7488            state.config.coordination = crate::config::test_coordination();
7489            if e2e {
7490                state.config.verify.e2e = vec!["true".into()];
7491                state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7492                    head: head.clone(),
7493                    passed: false,
7494                    outcomes: Vec::new(),
7495                });
7496            } else {
7497                state.config.verify.gate = vec!["true".into()];
7498                state.gate_ran = false;
7499            }
7500            let holder = crate::repo_lock::SeatLease::for_checkout(
7501                &state.repo,
7502                true,
7503                state.config.coordination.as_ref(),
7504            )
7505            .await
7506            .unwrap();
7507            assert!(holder.is_some());
7508            let cancelled = Arc::new(AtomicBool::new(false));
7509            let signal = cancelled.clone();
7510            let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7511                signal.load(Ordering::SeqCst)
7512            });
7513            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7514            let url = green_pr().url;
7515            let (result, ()) = tokio::join!(
7516                tokio::time::timeout(
7517                    Duration::from_secs(2),
7518                    wait.scope(publish_landing_head(&mut state, &head, &url, &forge)),
7519                ),
7520                async {
7521                    tokio::time::sleep(Duration::from_millis(100)).await;
7522                    cancelled.store(true, Ordering::SeqCst);
7523                }
7524            );
7525            let error = result
7526                .expect("cancellation must release the verification waiter")
7527                .unwrap_err();
7528            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7529            assert!(forge.argvs().is_empty());
7530            assert_eq!(
7531                git::remote_head(&state.repo, "origin", "task-head")
7532                    .await
7533                    .unwrap(),
7534                Some(head.clone())
7535            );
7536            drop(holder);
7537            // Cancellation must release its reservation so another attempt can verify.
7538            assert_eq!(
7539                publish_landing_head(&mut state, &head, &url, &forge)
7540                    .await
7541                    .unwrap(),
7542                head
7543            );
7544        }
7545    }
7546
7547    #[tokio::test]
7548    async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
7549        for staged in [false, true] {
7550            let (_tmp, mut state, old) = landing_git_fixture().await;
7551            advance_landing_base(&state).await;
7552            interrupt_landing_rebase(&mut state).await;
7553            std::fs::write(state.repo.join("change"), "user edit").unwrap();
7554            if staged {
7555                git::git(&state.repo, &["add", "change"]).await.unwrap();
7556            }
7557            assert!(resume_observed_head(&mut state, &old).await.is_err());
7558            assert_eq!(
7559                std::fs::read_to_string(state.repo.join("change")).unwrap(),
7560                "user edit"
7561            );
7562            assert_eq!(
7563                git::remote_head(&state.repo, "origin", "task-head")
7564                    .await
7565                    .unwrap(),
7566                Some(old)
7567            );
7568        }
7569    }
7570
7571    #[tokio::test]
7572    async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
7573        let (_tmp, mut state, head) = landing_git_fixture().await;
7574        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7575        let id = state.id.clone();
7576        let pr = land_with(&mut state, &green_pr().url, &forge)
7577            .await
7578            .unwrap();
7579        assert_eq!(pr.state, PrLifecycle::Open);
7580        assert_eq!(state.id, id);
7581        assert!(state.parked);
7582        assert_eq!(state.status, RunStatus::Landing);
7583        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7584        assert!(forge.argvs().is_empty());
7585
7586        state.land_sync_pending = true;
7587        std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
7588        let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
7589        closed.pr.state = PrLifecycle::Closed;
7590        let forge = Scripted::new(vec![closed], vec![]);
7591        assert_eq!(
7592            land_with(&mut state, &green_pr().url, &forge)
7593                .await
7594                .unwrap()
7595                .state,
7596            PrLifecycle::Closed
7597        );
7598        assert_eq!(
7599            std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
7600            "do not touch"
7601        );
7602        assert!(forge.argvs().is_empty());
7603    }
7604
7605    #[tokio::test]
7606    async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
7607        let (_tmp, mut state, old) = landing_git_fixture().await;
7608        advance_landing_base(&state).await;
7609        state.config.verify.gate = vec!["exit 1".into()];
7610        assert!(resume_observed_head(&mut state, &old).await.is_err());
7611        assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
7612        assert!(!state.gate_allows_progress());
7613        assert_eq!(
7614            git::remote_head(&state.repo, "origin", "task-head")
7615                .await
7616                .unwrap(),
7617            Some(old)
7618        );
7619        assert!(state.land_sync_pending);
7620    }
7621
7622    #[tokio::test]
7623    async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
7624        let (_tmp, mut state, old) = landing_git_fixture().await;
7625        advance_landing_base(&state).await;
7626        let pushed = resume_observed_head(&mut state, &old).await.unwrap();
7627        // Model a crash after the push but before saving its published SHA.
7628        state.pr_pushed_head = Some(old.clone());
7629        state.land_sync_pending = true;
7630        assert_eq!(
7631            resume_observed_head(&mut state, &pushed).await.unwrap(),
7632            pushed
7633        );
7634        // A writer races the observed PR snapshot: the explicit remote lease
7635        // must reject the push even though preflight saw our expected head.
7636        git::git(
7637            &state.repo,
7638            &["push", "--force", "origin", "main:refs/heads/task-head"],
7639        )
7640        .await
7641        .unwrap();
7642        assert!(
7643            publish_landing_head(
7644                &mut state,
7645                &pushed,
7646                &green_pr().url,
7647                &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
7648            )
7649            .await
7650            .unwrap_err()
7651            .to_string()
7652            .contains("publishing gated")
7653        );
7654        assert!(
7655            resume_observed_head(&mut state, "foreign-head")
7656                .await
7657                .is_err()
7658        );
7659    }
7660    #[tokio::test]
7661    async fn landing_publication_refuses_a_pr_closed_during_gate() {
7662        let (_tmp, mut state, head) = landing_git_fixture().await;
7663        let mut closed = seen(&head, Checks::Green, "CLEAN", false);
7664        closed.pr.state = PrLifecycle::Closed;
7665        let forge = Scripted::new(vec![closed], vec![]);
7666        assert!(
7667            publish_landing_head(&mut state, &head, &green_pr().url, &forge)
7668                .await
7669                .unwrap_err()
7670                .to_string()
7671                .contains("PR closed or merged")
7672        );
7673    }
7674
7675    /// A candidate selected by the uncontested tally.
7676    fn winning_candidate(summary: &str) -> Candidate {
7677        Candidate {
7678            index: 0,
7679            label: 'A',
7680            agent: "opus".to_owned(),
7681            branch: "magi/x/A".to_owned(),
7682            worktree: PathBuf::from("/wt/A"),
7683            summary: summary.to_owned(),
7684            stat: String::new(),
7685            files: 1,
7686            commits: 1,
7687            empty: false,
7688            failed: None,
7689            verified_noop: None,
7690            duration_ms: 0,
7691            folded: false,
7692        }
7693    }
7694
7695    fn uncontested_tally() -> Tally {
7696        Tally {
7697            first_choice: BTreeMap::from([('A', 1)]),
7698            borda: BTreeMap::new(),
7699            winner: 'A',
7700            rankings: 1,
7701            unanimous_initial: true,
7702            deliberated: false,
7703            changed_votes: 0,
7704            unanimous_final: true,
7705            tie_break: None,
7706            judges: 1,
7707            present: 1,
7708            quorum: 1,
7709            met_quorum: true,
7710            uncontested: None,
7711        }
7712    }
7713
7714    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
7715        ReviewRecord {
7716            attempts: 0,
7717            reviewer,
7718            agent: agent.to_owned(),
7719            summary: summary.to_owned(),
7720            findings: Vec::new(),
7721            vote: None,
7722            failed: None,
7723            duration_ms: 0,
7724        }
7725    }
7726
7727    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
7728        let answered = reviews.len();
7729        ReviewRound {
7730            round,
7731            head: "abc1234".to_owned(),
7732            verified_head: None,
7733            verified_at: None,
7734            reviews,
7735            e2e: Vec::new(),
7736            verify_retried: false,
7737            e2e_deferred: false,
7738            e2e_defer_reason: None,
7739            fix: None,
7740            blocking: 0,
7741            answered,
7742            expected: answered,
7743            clean: true,
7744            progressed: false,
7745            vote_split: false,
7746            reconsideration: Vec::new(),
7747            verdict: None,
7748        }
7749    }
7750
7751    #[test]
7752    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
7753        let en = panel();
7754        assert!(en.contains("Task"), "{en}");
7755        assert!(en.contains("add retries to the uploader"), "{en}");
7756
7757        let mut state = run_state();
7758        state.config.graph.language = "ja".to_owned();
7759        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7760        assert!(ja.contains("タスク"), "{ja}");
7761        assert!(
7762            ja.contains("add retries to the uploader"),
7763            "the task itself is not translated: {ja}"
7764        );
7765    }
7766
7767    #[test]
7768    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
7769        // `run_state()` has no candidates, no tally and no reviews - exactly
7770        // the shape a run has before anything has judged or reviewed it, and
7771        // the panel must not print an empty box for either.
7772        let html = panel();
7773        assert!(!html.contains("What changed"), "{html}");
7774        assert!(!html.contains("Review verdict"), "{html}");
7775    }
7776
7777    #[test]
7778    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
7779        let mut state = run_state();
7780        state.candidates = vec![winning_candidate("")];
7781        state.tally = Some(uncontested_tally());
7782        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7783        assert!(
7784            !html.contains("What changed"),
7785            "an empty summary must not render an empty box: {html}"
7786        );
7787    }
7788
7789    #[test]
7790    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
7791        let mut state = run_state();
7792        state.candidates = vec![winning_candidate(
7793            "Added a retry loop around the uploader PUT call.",
7794        )];
7795        state.tally = Some(uncontested_tally());
7796        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7797        assert!(en.contains("What changed"), "{en}");
7798        assert!(
7799            en.contains("Added a retry loop around the uploader PUT call."),
7800            "{en}"
7801        );
7802
7803        state.config.graph.language = "ja".to_owned();
7804        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7805        assert!(ja.contains("変更内容"), "{ja}");
7806        assert!(
7807            ja.contains("Added a retry loop around the uploader PUT call."),
7808            "{ja}"
7809        );
7810    }
7811
7812    #[test]
7813    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
7814        let mut state = run_state();
7815        state.reviews = vec![
7816            review_round(
7817                1,
7818                vec![review_record(1, "alpha", "found a race, sent back")],
7819            ),
7820            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
7821        ];
7822        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7823        assert!(en.contains("Review verdict"), "{en}");
7824        assert!(en.contains("race is fixed, clean"), "{en}");
7825        assert!(
7826            !en.contains("found a race, sent back"),
7827            "only the round that actually cleared the merge should show: {en}"
7828        );
7829
7830        state.config.graph.language = "ja".to_owned();
7831        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7832        assert!(ja.contains("レビューの結論"), "{ja}");
7833        assert!(ja.contains("レビュアー"), "{ja}");
7834        assert!(ja.contains("race is fixed, clean"), "{ja}");
7835    }
7836
7837    /// The `incomplete_review = "warn"` policy (see
7838    /// `graph::Runner::review_loop`) can push a `clean` round to
7839    /// `state.reviews` while one seat's own record still has `failed: Some`
7840    /// and an empty `summary` - a seat that never answered, not one that
7841    /// answered with nothing to say.
7842    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
7843        ReviewRecord {
7844            attempts: 0,
7845            reviewer,
7846            agent: agent.to_owned(),
7847            summary: String::new(),
7848            findings: Vec::new(),
7849            vote: None,
7850            failed: Some(reason.to_owned()),
7851            duration_ms: 0,
7852        }
7853    }
7854
7855    #[test]
7856    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
7857        let mut state = run_state();
7858        state.reviews = vec![review_round(
7859            1,
7860            vec![
7861                review_record(1, "alpha", "clean, nothing to add"),
7862                unanswered_review_record(2, "beta", "timed out"),
7863            ],
7864        )];
7865        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7866        assert!(en.contains("clean, nothing to add"), "{en}");
7867        assert!(
7868            en.contains("produced no answer: timed out"),
7869            "a seat that never answered must say so, not render a blank box: {en}"
7870        );
7871        assert!(
7872            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
7873            "no reviewer box may be left empty: {en}"
7874        );
7875
7876        state.config.graph.language = "ja".to_owned();
7877        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7878        assert!(ja.contains("回答なし: timed out"), "{ja}");
7879    }
7880
7881    #[test]
7882    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
7883        let html = panel();
7884        assert!(!html.contains("<script"), "no script survives the csp");
7885        assert!(!html.contains("<form"), "form-action is 'none'");
7886        let pr = green_pr();
7887        assert_eq!(
7888            html.matches("http").count(),
7889            html.matches(pr.url.as_str()).count(),
7890            "the only http url in the panel is the pull request's own link"
7891        );
7892        assert!(
7893            html.contains("target=\"_blank\""),
7894            "the PR link must open in a new tab so it escapes the iframe"
7895        );
7896        assert!(
7897            html.contains("rel=\"noopener noreferrer\""),
7898            "external links opening in new tabs must carry noopener noreferrer"
7899        );
7900    }
7901
7902    #[test]
7903    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
7904        let html = panel();
7905        assert!(
7906            html.contains(">+</span>"),
7907            "an added line carries a `+` in the gutter, not only a background"
7908        );
7909        assert!(
7910            html.contains(">-</span>"),
7911            "a removed line carries a `-` in the gutter, not only a background"
7912        );
7913        assert!(
7914            html.contains(">new line</span>"),
7915            "the marker is moved to the gutter, so the body is printed once without it"
7916        );
7917    }
7918
7919    #[test]
7920    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
7921        let total = DIFF_MAX_LINES + 100;
7922        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
7923        let html = approval_panel(
7924            &run_state(),
7925            &green_pr(),
7926            NUMSTAT,
7927            &diff,
7928            &[],
7929            "feat: something long",
7930        );
7931        assert!(
7932            html.contains(&format!("100 of {total} diff lines omitted")),
7933            "the note must say exactly how much was cut"
7934        );
7935        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
7936        assert!(
7937            !html.contains(&format!("line {DIFF_MAX_LINES}")),
7938            "nothing past the threshold is rendered"
7939        );
7940        assert!(
7941            html.contains("/repo/magi"),
7942            "the note says where the rest is"
7943        );
7944    }
7945
7946    #[test]
7947    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
7948        let html = approval_panel(
7949            &run_state(),
7950            &green_pr(),
7951            "1\t2\tsrc/<b>&\"x\"'.rs",
7952            "",
7953            &[],
7954            "subject",
7955        );
7956        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
7957        assert!(
7958            !html.contains("<b>"),
7959            "an agent-influenced path must never become markup"
7960        );
7961    }
7962
7963    #[tokio::test]
7964    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
7965        let a = std::path::PathBuf::from("/repo/a");
7966        let b = std::path::PathBuf::from("/repo/b");
7967
7968        let held = repo_merge_lock(&a).lock_owned().await;
7969
7970        // A second, concurrent land run against the *same* repository must
7971        // wait - `try_lock` fails while `held` is alive.
7972        assert!(
7973            repo_merge_lock(&a).try_lock().is_err(),
7974            "a second merge into the same repository must not proceed concurrently"
7975        );
7976
7977        // A run against a *different* repository must not be blocked by it -
7978        // this is what keeps a slow rebase or `gh pr merge` in one
7979        // repository from also stalling a land-approval resume in another.
7980        assert!(
7981            repo_merge_lock(&b).try_lock().is_ok(),
7982            "a different repository's merge lock must be independent"
7983        );
7984
7985        drop(held);
7986        assert!(
7987            repo_merge_lock(&a).try_lock().is_ok(),
7988            "the lock is released once the holder is done"
7989        );
7990    }
7991
7992    pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
7993        let mut state = landing_state();
7994        let mut rec = review_record(1, "alpha", "precise rejection reason");
7995        rec.vote = Some(if reject {
7996            crate::verdict::ReviewVote::Reject
7997        } else {
7998            crate::verdict::ReviewVote::Approve
7999        });
8000        if major {
8001            rec.findings = contested().findings;
8002        }
8003        let mut round = review_round(1, vec![rec]);
8004        round.clean = false;
8005        round.blocking = usize::from(major);
8006        state.reviews.push(round);
8007        state.config.graph.review_rounds = 1;
8008        state
8009    }
8010
8011    #[test]
8012    fn approval_policy_controls_are_independent_and_keep_all_reasons() {
8013        for every in [false, true] {
8014            for contested in [false, true] {
8015                for major in [false, true] {
8016                    for reject in [false, true] {
8017                        let mut state = repair_state(major, reject);
8018                        state.config.graph.land_approval = every;
8019                        state.config.graph.hold_contested_merge = contested;
8020                        let reasons = approval_reasons(&state);
8021                        assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
8022                        assert_eq!(
8023                            reasons.contains(&ApprovalReason::OpenBlockingFindings),
8024                            contested && major
8025                        );
8026                        assert_eq!(
8027                            reasons.contains(&ApprovalReason::FinalReject),
8028                            contested && reject
8029                        );
8030                        assert_eq!(
8031                            reasons.len(),
8032                            usize::from(every)
8033                                + usize::from(contested && major)
8034                                + usize::from(contested && reject)
8035                        );
8036                    }
8037                }
8038            }
8039        }
8040    }
8041
8042    #[test]
8043    fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8044        use crate::verdict::ReviewVote::{Approve, Reject};
8045        for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8046            let mut state = repair_state(false, initial_reject);
8047            state.reviews[0]
8048                .reconsideration
8049                .push(crate::run::ReviewRevoteRecord {
8050                    reviewer: 1,
8051                    agent: "alpha".into(),
8052                    vote: Some(final_vote),
8053                    reason: "changed after reading the panel <evidence>".into(),
8054                    failed: None,
8055                });
8056            assert_eq!(
8057                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8058                final_vote == Reject
8059            );
8060            for language in ["en", "ja"] {
8061                state.config.graph.language = language.into();
8062                let evidence = approval_evidence(&state);
8063                assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8064                assert!(evidence.contains("changed after reading the panel <evidence>"));
8065                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8066                assert!(html.contains("changed after reading the panel &lt;evidence&gt;"));
8067                assert!(!html.contains("<evidence>"));
8068            }
8069            state.reviews[0].reconsideration[0].vote = None;
8070            state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8071            assert_eq!(
8072                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8073                initial_reject
8074            );
8075            assert!(approval_evidence(&state).contains("timed out"));
8076        }
8077    }
8078
8079    #[tokio::test]
8080    async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8081        for every in [false, true] {
8082            for contested in [false, true] {
8083                for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8084                {
8085                    let mut state = repair_state(major, reject);
8086                    state.config.graph.land_approval = every;
8087                    state.config.graph.hold_contested_merge = contested;
8088                    let forge = Scripted::new(
8089                        vec![seen("abc", Checks::Green, "CLEAN", false)],
8090                        vec![(true, "")],
8091                    );
8092                    land_with(&mut state, URL, &forge).await.unwrap();
8093                    let expected = every || (contested && (major || reject));
8094                    assert_eq!(state.land_approval.is_some(), expected);
8095                    assert_eq!(forge.calls().contains(&"merge"), !expected);
8096                    if expected {
8097                        assert!(state.parked);
8098                    }
8099                }
8100            }
8101        }
8102    }
8103
8104    #[tokio::test]
8105    async fn reviewed_branch_hold_is_read_before_landing_decision() {
8106        for (base, control) in [
8107            (false, "land_approval"),
8108            (true, "land_approval"),
8109            (false, "hold_contested_merge"),
8110        ] {
8111            let (_tmp, mut state, _) = landing_git_fixture().await;
8112            state.config.graph.land_approval = base;
8113            state.config.graph.hold_contested_merge = false;
8114            state.reviewed_commits = Some(Vec::new());
8115            if control == "hold_contested_merge" {
8116                state.reviews = repair_state(false, true).reviews;
8117            }
8118            std::fs::write(
8119                state.repo.join("magi.toml"),
8120                format!("[graph]\n{control} = {}\n", !base),
8121            )
8122            .unwrap();
8123            git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8124            git::git(&state.repo, &["commit", "-m", "approval policy"])
8125                .await
8126                .unwrap();
8127            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8128            state.land_sync_pending = false;
8129            state.pr_pushed_head = Some(head.clone());
8130            state.gate_head = Some(head.clone());
8131            let forge = Scripted::new(
8132                vec![seen(&head, Checks::Green, "CLEAN", false)],
8133                vec![(true, "")],
8134            );
8135            land_with(&mut state, URL, &forge).await.unwrap();
8136            assert!(
8137                state.land_approval.is_some(),
8138                "base={base}, control={control}"
8139            );
8140            assert!(!forge.calls().contains(&"merge"));
8141            if !base {
8142                let question = ask::Questions::open()
8143                    .get(&state.land_approval.as_ref().unwrap().question)
8144                    .unwrap();
8145                assert!(question.detail.contains(&format!(
8146                    "{control}=true from the reviewed branch's magi.toml"
8147                )));
8148            }
8149        }
8150    }
8151
8152    #[tokio::test]
8153    async fn reviewed_branch_hold_asks_and_survives_removal_and_resume() {
8154        let (_tmp, mut state, _) = landing_git_fixture().await;
8155        state.config.graph.land_approval = false;
8156        state.config.graph.hold_contested_merge = false;
8157        state.reviewed_commits = Some(Vec::new());
8158        std::fs::write(
8159            state.repo.join("magi.toml"),
8160            "[graph]\nland_approval = true\n[verify]\ngate = ['untrusted']\n",
8161        )
8162        .unwrap();
8163        git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8164        git::git(&state.repo, &["commit", "-m", "require approval"])
8165            .await
8166            .unwrap();
8167        let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8168        let gate = state.config.verify.gate.clone();
8169        state.observe_branch_owner_holds(&head).await;
8170        assert_eq!(state.config.verify.gate, gate);
8171        assert_eq!(approval_reasons(&state), [ApprovalReason::EveryMerge]);
8172        git::git(&state.repo, &["rm", "magi.toml"]).await.unwrap();
8173        git::git(&state.repo, &["commit", "-m", "remove config"])
8174            .await
8175            .unwrap();
8176        let later = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8177        state = serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
8178        state.observe_branch_owner_holds(&later).await;
8179        assert_eq!(state.branch_owner_holds[0].head, head);
8180        state.land_sync_pending = false;
8181        state.pr_pushed_head = Some(later.clone());
8182        state.gate_head = Some(later.clone());
8183        let forge = Scripted::new(
8184            vec![seen(&later, Checks::Green, "CLEAN", false)],
8185            vec![(true, "")],
8186        );
8187        land_with(&mut state, URL, &forge).await.unwrap();
8188        assert!(!forge.calls().contains(&"merge"));
8189        let question = ask::Questions::open()
8190            .get(&state.land_approval.as_ref().unwrap().question)
8191            .unwrap();
8192        assert!(question.detail.contains("Approval required because:"));
8193        assert!(
8194            question
8195                .detail
8196                .contains("land_approval=true from the reviewed branch's magi.toml")
8197        );
8198    }
8199
8200    #[tokio::test]
8201    async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8202        let mut state = repair_state(false, true);
8203        state.reviews[0].clean = true;
8204        let mut finding = contested().findings.remove(0);
8205        finding.severity = crate::verdict::Severity::Minor;
8206        state.reviews[0].reviews[0].findings.push(finding);
8207        assert!(state.open_findings().is_empty());
8208        let mut pr = green_pr();
8209        pr.review_comments.clear();
8210        for language in ["en", "ja"] {
8211            state.config.graph.language = language.into();
8212            let html = approval_panel(&state, &pr, "", "", &[], "subject");
8213            assert!(html.contains("[R3-1-1] minor"));
8214            assert!(html.contains(if language == "ja" {
8215                "内部指摘の解決を意味するものではありません"
8216            } else {
8217                "This does not mean internal findings are resolved"
8218            }));
8219            assert!(
8220                html.find(if language == "ja" {
8221                    "承認が必要な理由:"
8222                } else {
8223                    "Approval required because:"
8224                })
8225                .unwrap()
8226                    < html.find("[R3-1-1]").unwrap()
8227            );
8228        }
8229        state.config.graph.language = "en".into();
8230        let observed = seen("abc", Checks::Green, "CLEAN", false);
8231        assert_eq!(
8232            approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8233                .await
8234                .unwrap(),
8235            ApprovalGate::Pending
8236        );
8237        let id = state.land_approval.as_ref().unwrap().question.clone();
8238        let q = ask::Questions::open().get(&id).unwrap();
8239        assert!(q.detail.starts_with("Approval required because:"));
8240        assert!(q.detail.contains("final reviewer reject"));
8241        assert!(q.detail.contains("[R3-1-1] minor"));
8242        assert!(
8243            q.detail
8244                .contains("No adoption or resolution evidence recorded")
8245        );
8246        state.config.graph.land_approval = false;
8247        state.config.graph.hold_contested_merge = false;
8248        state.reviews.clear();
8249        assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8250        assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8251        assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8252        let forge = Scripted::new(vec![observed], vec![]);
8253        land_with(&mut state, URL, &forge).await.unwrap();
8254        assert!(!forge.calls().contains(&"merge"));
8255        assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8256        assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8257    }
8258
8259    #[tokio::test]
8260    async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8261        let mut state = repair_state(false, true);
8262        state.config.graph.land_approval = false;
8263        let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8264        let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8265        let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8266        state.reviews[0].reviews[0].summary = long_reason.clone();
8267        for i in 1..=10 {
8268            let mut finding = contested().findings.remove(0);
8269            finding.id = format!("R1-1-{i}");
8270            finding.severity = if i == 9 {
8271                crate::verdict::Severity::Major
8272            } else {
8273                crate::verdict::Severity::Minor
8274            };
8275            finding.title = long_title.clone();
8276            finding.detail = long_detail.clone();
8277            state.reviews[0].reviews[0].findings.push(finding);
8278        }
8279        state.reviews[0].fix = Some(crate::run::FixRecord {
8280            agent: "fixer".into(),
8281            addressed: vec!["R1-1-9".into()],
8282            rejected: vec![crate::verdict::Rejection {
8283                id: "R1-1-10".into(),
8284                why: long_reason.clone(),
8285            }],
8286            notes: long_detail.clone(),
8287            committed: false,
8288            failed: None,
8289            duration_ms: 0,
8290            continuation: None,
8291        });
8292        assert_eq!(
8293            approval_reasons(&state),
8294            [
8295                ApprovalReason::OpenBlockingFindings,
8296                ApprovalReason::FinalReject
8297            ]
8298        );
8299        let excerpt = approval_excerpt(&long_reason, 300);
8300        assert_eq!(excerpt.chars().count(), 300);
8301        assert!(excerpt.ends_with('…'));
8302        let boundary = "拒".repeat(300);
8303        assert_eq!(approval_excerpt(&boundary, 300), boundary);
8304        for language in ["en", "ja"] {
8305            state.config.graph.language = language.into();
8306            for revote in [
8307                None,
8308                Some(crate::verdict::ReviewVote::Reject),
8309                Some(crate::verdict::ReviewVote::Approve),
8310            ] {
8311                state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8312                state.reviews[0].reconsideration.clear();
8313                if let Some(vote) = revote {
8314                    state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8315                    state.reviews[0]
8316                        .reconsideration
8317                        .push(crate::run::ReviewRevoteRecord {
8318                            reviewer: 1,
8319                            agent: "alpha".into(),
8320                            vote: Some(vote),
8321                            reason: long_reason.clone(),
8322                            failed: None,
8323                        });
8324                }
8325                let evidence = approval_evidence(&state);
8326                let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8327                assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8328                assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8329                assert_eq!(
8330                    approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8331                    final_vote == crate::verdict::ReviewVote::Reject
8332                );
8333                assert!(evidence.contains(&excerpt));
8334                assert!(evidence.contains("[R1-1-8] minor"));
8335                assert!(evidence.contains("[R1-1-9] major"));
8336                assert!(evidence.contains("[R1-1-10] minor"));
8337                for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8338                    assert!(!evidence.contains(tail));
8339                }
8340                assert!(evidence.contains(if language == "ja" {
8341                    "採用申告(解決未確認)"
8342                } else {
8343                    "claimed addressed (resolution unconfirmed)"
8344                }));
8345                assert!(evidence.contains(if language == "ja" {
8346                    "不採用理由"
8347                } else {
8348                    "declined"
8349                }));
8350                let major_evidence = evidence
8351                    .split("[R1-1-9] major")
8352                    .nth(1)
8353                    .unwrap()
8354                    .split("[R1-1-10]")
8355                    .next()
8356                    .unwrap();
8357                assert!(major_evidence.contains(if language == "ja" {
8358                    "採用申告(解決未確認)"
8359                } else {
8360                    "claimed addressed (resolution unconfirmed)"
8361                }));
8362                let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8363                assert!(last_evidence.contains(if language == "ja" {
8364                    "不採用理由"
8365                } else {
8366                    "declined"
8367                }));
8368                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8369                assert!(html.contains(&esc(&evidence)));
8370                assert_eq!(
8371                    approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8372                        .await
8373                        .unwrap(),
8374                    ApprovalGate::Pending
8375                );
8376                let id = &state.land_approval.take().unwrap().question;
8377                let q = ask::Questions::open().get(id).unwrap();
8378                assert!(q.detail.starts_with(&evidence));
8379            }
8380            state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8381        }
8382    }
8383
8384    #[test]
8385    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8386        for (major, reject) in [(true, false), (false, true), (true, true)] {
8387            let state = repair_state(major, reject);
8388            assert!(repair_available(&state));
8389            assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8390        }
8391        assert!(!repair_available(&repair_state(false, false)));
8392        let mut clean = repair_state(true, false);
8393        clean.reviews[0].clean = true;
8394        assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8395        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8396    }
8397
8398    #[tokio::test]
8399    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8400        let mut state = repair_state(true, false);
8401        let pr = green_pr();
8402        approval_gate(&mut state, &pr, "feat: x", None, "abc")
8403            .await
8404            .unwrap();
8405        let store = ask::Questions::open();
8406        let id = state.land_approval.as_ref().unwrap().question.clone();
8407        // Simulate an unanswered two-choice question created by an older build.
8408        store
8409            .update(&id, |q| {
8410                q.choices = vec![APPROVE.into(), HOLD.into()];
8411                Ok(())
8412            })
8413            .unwrap();
8414        let upgraded = store.get(&id).unwrap();
8415        assert_eq!(upgraded.choices, approval_choices(&state));
8416        assert!(!upgraded.actions.contains_key(HOLD));
8417        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
8418        assert!(
8419            store
8420                .list()
8421                .iter()
8422                .any(|q| q.id == id && q.choices == upgraded.choices)
8423        );
8424        assert!(
8425            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
8426        );
8427        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
8428        store
8429            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
8430            .unwrap();
8431        // An earlier build may have recorded the repair answer and stopped
8432        // before any driver consumed it. Reconcile that durable decision too.
8433        store
8434            .update(&id, |q| {
8435                q.actions.clear();
8436                Ok(())
8437            })
8438            .unwrap();
8439        assert_eq!(
8440            store.get(&id).unwrap().chosen_action(),
8441            Some(&ask::ChoiceAction::Resume {
8442                run: state.id.clone()
8443            })
8444        );
8445        let binding = state.land_approval.clone();
8446        assert_eq!(
8447            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8448                .await
8449                .unwrap(),
8450            ApprovalGate::Repair
8451        );
8452        assert!(state.land_approval.is_none());
8453        assert_eq!(state.merge_repairs.len(), 1);
8454        let request = &state.merge_repairs[0];
8455        assert_eq!(request.question, id);
8456        assert_eq!(request.head, "abc");
8457        assert_eq!(request.pr, pr.url);
8458        assert_eq!(
8459            request.reviews[0].reviews[0].summary,
8460            "precise rejection reason"
8461        );
8462        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
8463        assert_eq!(
8464            request.reviews[0].reviews[0].findings[0].detail,
8465            state.reviews[0].reviews[0].findings[0].detail
8466        );
8467        // A repeated consumption of the same answer never dispatches another repair.
8468        state.land_approval = binding;
8469        assert_eq!(
8470            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8471                .await
8472                .unwrap(),
8473            ApprovalGate::Repair
8474        );
8475        assert_eq!(state.merge_repairs.len(), 1);
8476        state.land_approval = None;
8477        state.config.graph.land_approval = false;
8478        state.config.graph.hold_contested_merge = false;
8479        state.reviews.clear();
8480        assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
8481        assert_eq!(
8482            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8483                .await
8484                .unwrap(),
8485            ApprovalGate::Pending
8486        );
8487        let fresh_id = &state.land_approval.as_ref().unwrap().question;
8488        assert_ne!(fresh_id, &id);
8489        let fresh = store.get(fresh_id).unwrap();
8490        assert!(
8491            fresh
8492                .detail
8493                .contains("fresh owner approval after merge repair")
8494        );
8495        assert!(fresh.detail.contains("No review recorded"));
8496        assert!(!fresh.detail.contains("precise rejection reason"));
8497    }
8498
8499    #[tokio::test]
8500    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
8501        for choice in [REPAIR, HOLD] {
8502            let mut state = repair_state(false, true);
8503            let observed = seen("abc", Checks::Green, "CLEAN", false);
8504            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
8505                .await
8506                .unwrap();
8507            let id = state.land_approval.as_ref().unwrap().question.clone();
8508            ask::Questions::open()
8509                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
8510                .unwrap();
8511            let forge = Scripted::new(vec![observed], vec![(true, "")]);
8512            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8513                .await
8514                .unwrap();
8515            assert!(
8516                forge
8517                    .argvs()
8518                    .iter()
8519                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
8520            );
8521            assert!(!forge.calls().contains(&"fix"));
8522            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
8523            assert_ne!(state.status, RunStatus::Merged);
8524        }
8525    }
8526
8527    #[test]
8528    fn only_the_merge_choice_merges_and_silence_holds() {
8529        let table = [
8530            (None, Approval::Hold),
8531            (Some("merge"), Approval::Merge),
8532            (Some(" merge\n"), Approval::Merge),
8533            (Some("hold"), Approval::Hold),
8534            (Some(""), Approval::Hold),
8535            (Some("yes"), Approval::Hold),
8536        ];
8537        for (answer, want) in table {
8538            assert_eq!(
8539                approval(answer),
8540                want,
8541                "answer {answer:?} must resolve to {want:?}"
8542            );
8543        }
8544    }
8545
8546    #[tokio::test]
8547    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
8548        let mut state = landing_state();
8549        state.config.graph.land_approval = true;
8550        let pr = green_pr();
8551
8552        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8553            .await
8554            .unwrap();
8555        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
8556        assert!(
8557            !state.parked,
8558            "approval_gate itself never sets `parked`; only its caller does"
8559        );
8560
8561        let store = ask::Questions::open();
8562        let filed: Vec<_> = store
8563            .list()
8564            .into_iter()
8565            .filter(|q| q.run == state.id)
8566            .collect();
8567        assert_eq!(filed.len(), 1, "exactly one question is filed");
8568        assert_eq!(filed[0].node, APPROVAL_NODE);
8569        assert_eq!(filed[0].choices, approval_choices(&state));
8570        assert!(filed[0].status.open());
8571
8572        // A second visit - standing in for a resumed run whose slot the
8573        // daemon handed to something else while nobody had answered - must
8574        // find the same question rather than filing a second one.
8575        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8576            .await
8577            .unwrap();
8578        assert_eq!(again, ApprovalGate::Pending);
8579        let still_one = store
8580            .list()
8581            .into_iter()
8582            .filter(|q| q.run == state.id)
8583            .count();
8584        assert_eq!(
8585            still_one, 1,
8586            "asking twice must not double-file the question"
8587        );
8588    }
8589
8590    #[tokio::test]
8591    async fn approving_the_existing_question_is_read_back_as_approved() {
8592        crate::run::pin_test_home();
8593        let mut state = run_state();
8594        state.config.graph.land_approval = true;
8595        let pr = green_pr();
8596        assert_eq!(
8597            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8598                .await
8599                .unwrap(),
8600            ApprovalGate::Pending
8601        );
8602
8603        let store = ask::Questions::open();
8604        let mut q = store
8605            .list()
8606            .into_iter()
8607            .find(|q| q.run == state.id)
8608            .expect("filed above");
8609        let talks = crate::talk::Talks::open();
8610        let cfg = crate::config::Config {
8611            agents: vec![crate::config::AgentSpec {
8612                id: "mock".into(),
8613                kind: crate::config::AgentKind::Command,
8614                model: None,
8615                command: vec!["true".into()],
8616                extra_args: Vec::new(),
8617                env: Default::default(),
8618                prompt_delivery: None,
8619            }],
8620            ..crate::config::Config::default()
8621        };
8622        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
8623        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
8624        crate::talk::drain(&mut talk, &talks).unwrap();
8625        assert_eq!(
8626            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8627                .await
8628                .unwrap(),
8629            ApprovalGate::Pending,
8630            "consultation cannot approve or hold the merge"
8631        );
8632        q = store.get(&q.id).unwrap();
8633        assert!(q.answer.is_none());
8634        talk.turns.push(crate::talk::Turn {
8635            sender: None,
8636            breaks: Some(Vec::new()),
8637            messages: Vec::new(),
8638            posted_by: None,
8639            who: crate::talk::Who::Operator,
8640            body: "merge abc".into(),
8641            at: Timestamp::now(),
8642            attachments: Vec::new(),
8643            usage: None,
8644        });
8645        talks.put(&mut talk).unwrap();
8646        crate::consult::validate_answer(
8647            &q,
8648            &talks,
8649            &talk.id,
8650            crate::queue::CHAT_NODE,
8651            APPROVE,
8652            Some("merge abc"),
8653        )
8654        .unwrap();
8655        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8656        store.put(&mut q).unwrap();
8657
8658        assert_eq!(
8659            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8660                .await
8661                .unwrap(),
8662            ApprovalGate::Approved
8663        );
8664    }
8665
8666    #[tokio::test]
8667    async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
8668        crate::run::pin_test_home();
8669        let store = ask::Questions::open();
8670        for via_list in [false, true] {
8671            let mut state = repair_state(false, true);
8672            let pr = green_pr();
8673            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8674                .await
8675                .unwrap();
8676            let id = state.land_approval.as_ref().unwrap().question.clone();
8677            store
8678                .update(&id, |q| {
8679                    q.actions.insert(
8680                        HOLD.to_owned(),
8681                        ask::ChoiceAction::Resume {
8682                            run: state.id.clone(),
8683                        },
8684                    );
8685                    Ok(())
8686                })
8687                .unwrap();
8688            let q = if via_list {
8689                store.list().into_iter().find(|q| q.id == id).unwrap()
8690            } else {
8691                store.get(&id).unwrap()
8692            };
8693            assert!(!q.actions.contains_key(HOLD));
8694            store
8695                .update(&id, |q| {
8696                    // Check the persisted migration, without another read upgrade.
8697                    assert!(!q.actions.contains_key(HOLD));
8698                    assert!(!upgrade_repair_choice(q, &state));
8699                    q.answer(ask::Answer::Choice(HOLD.to_owned()))
8700                })
8701                .unwrap();
8702            assert!(store.get(&id).unwrap().chosen_action().is_none());
8703            assert_eq!(
8704                approval_gate(&mut state, &pr, "feat: x", None, "abc")
8705                    .await
8706                    .unwrap(),
8707                ApprovalGate::Held
8708            );
8709        }
8710    }
8711
8712    #[tokio::test]
8713    async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
8714        crate::run::pin_test_home();
8715        let store = ask::Questions::open();
8716
8717        let mut held_state = run_state();
8718        held_state.config.graph.land_approval = true;
8719        let pr = green_pr();
8720        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
8721            .await
8722            .unwrap();
8723        let mut q = store
8724            .list()
8725            .into_iter()
8726            .find(|q| q.run == held_state.id)
8727            .expect("filed above");
8728        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
8729        store.put(&mut q).unwrap();
8730        assert_eq!(
8731            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
8732                .await
8733                .unwrap(),
8734            ApprovalGate::Held
8735        );
8736
8737        let mut abandoned_state = run_state();
8738        abandoned_state.config.graph.land_approval = true;
8739        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
8740            .await
8741            .unwrap();
8742        let mut q = store
8743            .list()
8744            .into_iter()
8745            .find(|q| q.run == abandoned_state.id)
8746            .expect("filed above");
8747        q.abandon("no answer within the timeout");
8748        store.put(&mut q).unwrap();
8749        assert_eq!(
8750            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
8751                .await
8752                .unwrap(),
8753            ApprovalGate::Pending,
8754            "an expired approval must ask again without merging"
8755        );
8756        assert_eq!(
8757            store.get(&q.id).unwrap().status,
8758            ask::QuestionStatus::Abandoned
8759        );
8760        assert_ne!(
8761            abandoned_state.land_approval.as_ref().unwrap().question,
8762            q.id
8763        );
8764    }
8765
8766    fn contested() -> ContestedHandoff {
8767        let finding = |id: &str, n: u32| crate::verdict::Finding {
8768            id: id.to_owned(),
8769            severity: crate::verdict::Severity::Major,
8770            file: Some("src/a.rs".to_owned()),
8771            line: Some(n),
8772            title: format!("problem {id}"),
8773            detail: String::new(),
8774        };
8775        ContestedHandoff {
8776            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
8777            rejecters: vec![(1, "alpha".to_owned())],
8778        }
8779    }
8780
8781    #[test]
8782    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
8783        let mut state = run_state();
8784        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
8785        state.contested_handoff = Some(contested());
8786        assert!(contested_to_ask(&state).is_some());
8787        state.config.graph.hold_contested_merge = false;
8788        assert!(
8789            contested_to_ask(&state).is_none(),
8790            "the switch restores today"
8791        );
8792    }
8793
8794    #[test]
8795    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
8796        let q = ask::Question::new(
8797            "run-1".to_owned(),
8798            APPROVAL_NODE.to_owned(),
8799            "land".to_owned(),
8800            "Merge?".to_owned(),
8801            String::new(),
8802            vec![APPROVE.to_owned(), HOLD.to_owned()],
8803        );
8804        let none = deputy_brief(&q, None);
8805        assert!(none.contains("could not be read"), "{none}");
8806        assert!(none.contains("Silence is a hold"), "{none}");
8807
8808        let mut state = run_state();
8809        state.pr = Some(crate::run::PrRecord {
8810            url: "https://example.test/pull/7".to_owned(),
8811            number: 7,
8812            state: "open".to_owned(),
8813            checks: "green".to_owned(),
8814            round: 0,
8815            rounds: 3,
8816            red_at_merge: Vec::new(),
8817        });
8818        state.contested_handoff = Some(contested());
8819        let b = deputy_brief(&q, Some(&state));
8820        assert!(b.contains("https://example.test/pull/7"), "{b}");
8821        assert!(b.contains("not recorded in this legacy question"), "{b}");
8822        assert!(!b.contains("This question was filed although"));
8823        state.contested_handoff = None;
8824        assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
8825    }
8826
8827    #[test]
8828    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
8829        for lang in ["en", "ja"] {
8830            let mut cfg = crate::config::Config::default();
8831            cfg.graph.language = lang.to_owned();
8832            let w = words(&cfg.graph.language);
8833            let text = w.approval_detail(
8834                "https://github.com/yukimemi/magi/pull/42",
8835                "main",
8836                "feat: x",
8837                Some(&contested()),
8838            );
8839            assert!(text.contains("pull/42"), "{text}");
8840            assert!(
8841                text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
8842                "{text}"
8843            );
8844            assert!(text.contains("R3-1-5"), "{text}");
8845            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
8846            assert!(text.contains("2"), "the rest are counted: {text}");
8847            assert!(text.contains("#1 (alpha)"), "{text}");
8848        }
8849        let plain = words("en").approval_detail("u", "main", "s", None);
8850        assert!(!plain.contains("reject"), "{plain}");
8851    }
8852
8853    #[tokio::test]
8854    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
8855        crate::run::pin_test_home();
8856        let mut state = run_state();
8857        state.config.graph.land_approval = false;
8858        state.contested_handoff = Some(contested());
8859        let pr = green_pr();
8860        let c = contested_to_ask(&state);
8861        assert_eq!(
8862            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8863                .await
8864                .unwrap(),
8865            ApprovalGate::Pending,
8866            "silence is a hold"
8867        );
8868        let store = ask::Questions::open();
8869        let filed: Vec<_> = store
8870            .list()
8871            .into_iter()
8872            .filter(|q| q.run == state.id)
8873            .collect();
8874        assert_eq!(filed.len(), 1);
8875        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
8876
8877        assert_eq!(
8878            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8879                .await
8880                .unwrap(),
8881            ApprovalGate::Pending
8882        );
8883        let mut q = store
8884            .list()
8885            .into_iter()
8886            .find(|q| q.run == state.id)
8887            .unwrap();
8888        assert_eq!(q.id, filed[0].id, "the same question after a resume");
8889        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8890        store.put(&mut q).unwrap();
8891        assert_eq!(
8892            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8893                .await
8894                .unwrap(),
8895            ApprovalGate::Approved
8896        );
8897    }
8898
8899    #[test]
8900    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
8901        let rows = parse_numstat(NUMSTAT);
8902        assert_eq!(
8903            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
8904            ["src/web.rs", "src/land.rs", "assets/logo.png"]
8905        );
8906        assert_eq!(rows[2].added, None, "a binary file has no line counts");
8907    }
8908    #[test]
8909    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
8910        // Reported from a real run: the merge question arrived in English on a
8911        // repository with `language = "ja"`. magi's own strings have to follow
8912        // that setting too - "it is a literal in Rust" is not an answer.
8913        let mut state = run_state();
8914        state.config.graph.language = "ja".to_owned();
8915        let pr = green_pr();
8916        let commits = ["c1".to_owned()];
8917
8918        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
8919        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
8920        assert!(ja.contains("squash されるコミット"), "{ja}");
8921        assert!(ja.contains("レビューコメント"), "{ja}");
8922        assert!(ja.contains("差分"), "{ja}");
8923        assert!(
8924            !ja.contains("Commits being squashed"),
8925            "no English left over"
8926        );
8927
8928        let w = words("ja");
8929        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
8930        assert!(
8931            w.approval_detail("http://x/1", "main", "feat: x", None)
8932                .contains("パネル")
8933        );
8934
8935        // The evidence itself is language-neutral and must survive either way.
8936        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
8937        assert!(ja.contains("feat: x"), "nor is the merge subject");
8938
8939        // English stays the default, and a language magi cannot check falls
8940        // back to it rather than shipping a guess.
8941        state.config.graph.language = "en".to_owned();
8942        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
8943        assert!(en.contains("Commits being squashed"), "{en}");
8944        assert_eq!(words("Klingon").html_lang, "en");
8945    }
8946
8947    /// A `gh pr list` result naming exactly one pull request whose base and
8948    /// merge time both fit the run is exactly the case
8949    /// [`find_external_merge`] exists to act on.
8950    #[test]
8951    fn pick_open_pr_classifies_by_count_and_base() {
8952        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
8953        assert_eq!(
8954            pick_open_pr(one, "main").unwrap(),
8955            OpenPr::One {
8956                url: "https://x/pull/58".into(),
8957                title: "t".into()
8958            }
8959        );
8960        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
8961        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
8962        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
8963                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
8964        assert_eq!(
8965            pick_open_pr(two, "main").unwrap(),
8966            OpenPr::Many(vec!["u1".into(), "u2".into()])
8967        );
8968        // A fork's same-named branch is not a pull request for this head.
8969        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
8970        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
8971        assert!(pick_open_pr("not json", "main").is_err());
8972        // An incomplete record is an error, never "nothing open".
8973        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
8974        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
8975    }
8976
8977    #[test]
8978    fn pick_merged_pr_picks_the_unique_match() {
8979        let json = r#"[
8980            {"url": "https://github.com/o/r/pull/42", "number": 42,
8981             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
8982        ]"#;
8983        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
8984        let found = pick_merged_pr(json, "main", created_at)
8985            .expect("valid json")
8986            .expect("one unambiguous match");
8987        assert_eq!(found.url, "https://github.com/o/r/pull/42");
8988        assert_eq!(found.number, 42);
8989    }
8990
8991    /// Two candidates surviving the filter is exactly as uninformative as
8992    /// zero — a branch name can be reused across runs — so neither is
8993    /// preferred over the other and nothing is recorded automatically.
8994    #[test]
8995    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
8996        let json = r#"[
8997            {"url": "https://github.com/o/r/pull/42", "number": 42,
8998             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
8999            {"url": "https://github.com/o/r/pull/43", "number": 43,
9000             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
9001        ]"#;
9002        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9003        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9004    }
9005
9006    /// A pull request that targets a different base branch cannot be this
9007    /// run's, whatever its head branch is named — a reused branch name from
9008    /// an unrelated task must not be recorded as this run's merge.
9009    #[test]
9010    fn pick_merged_pr_ignores_a_different_base_branch() {
9011        let json = r#"[
9012            {"url": "https://github.com/o/r/pull/42", "number": 42,
9013             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
9014        ]"#;
9015        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9016        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9017    }
9018
9019    /// A pull request merged before this run was even created cannot be this
9020    /// run's winner, no matter how its head branch is spelled.
9021    #[test]
9022    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
9023        let json = r#"[
9024            {"url": "https://github.com/o/r/pull/42", "number": 42,
9025             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
9026        ]"#;
9027        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9028        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9029    }
9030
9031    #[test]
9032    fn slug_of_pr_url_reads_host_owner_and_repo() {
9033        assert_eq!(
9034            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
9035            Some("github.com/yukimemi/shun")
9036        );
9037    }
9038
9039    #[test]
9040    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
9041        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
9042        assert_eq!(slug_of_pr_url("not a url at all"), None);
9043        assert_eq!(slug_of_pr_url("https://github.com"), None);
9044    }
9045
9046    #[test]
9047    fn slug_of_repo_url_reads_host_owner_and_repo() {
9048        assert_eq!(
9049            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
9050            Some("github.com/yukimemi/magi")
9051        );
9052        assert_eq!(slug_of_repo_url("https://github.com"), None);
9053    }
9054
9055    #[test]
9056    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
9057        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
9058            .expect("same repo, different case");
9059    }
9060
9061    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
9062    /// own in-progress run and rewrote its status from a pull request in a
9063    /// completely different repository. This is the guard that must catch
9064    /// that even when an explicit (but wrong) id is given.
9065    #[test]
9066    fn ensure_same_repo_refuses_a_different_repo() {
9067        let err =
9068            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
9069        let msg = format!("{err:#}");
9070        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9071        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
9072    }
9073
9074    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
9075    /// instance mirroring a `github.com` repository's name, say) must not be
9076    /// treated as the same repository just because the trailing path
9077    /// matches.
9078    #[test]
9079    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
9080        let err = ensure_same_repo(
9081            "github.com/yukimemi/magi",
9082            "github.example.com/yukimemi/magi",
9083        )
9084        .unwrap_err();
9085        let msg = format!("{err:#}");
9086        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9087        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
9088    }
9089
9090    /// No winner decided yet means there is no branch to ask GitHub about at
9091    /// all — `find_external_merge` must return `None` without ever spawning
9092    /// `gh`, which this proves by never providing a real repository to spawn
9093    /// it in.
9094    #[tokio::test]
9095    async fn find_external_merge_returns_none_without_a_winner() {
9096        let state = RunState::new(
9097            PathBuf::from("/no/such/repo"),
9098            "main".to_owned(),
9099            "0000000000000000000000000000000000000000".to_owned(),
9100            "irrelevant".to_owned(),
9101            crate::config::Config::default(),
9102        );
9103        assert_eq!(find_external_merge(&state).await.unwrap(), None);
9104    }
9105
9106    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
9107        let mut run = RunState::new(
9108            PathBuf::from(repo),
9109            "main".to_owned(),
9110            "abcdef1234".to_owned(),
9111            "x".to_owned(),
9112            crate::config::Config::default(),
9113        );
9114        run.id = id.to_owned();
9115        run.status = status;
9116        run.driver_exited = true;
9117        run.pr = Some(crate::run::PrRecord {
9118            number: url.rsplit('/').next().unwrap().parse().unwrap(),
9119            url: url.to_owned(),
9120            state: state.to_owned(),
9121            checks: "red".to_owned(),
9122            round: 0,
9123            rounds: 2,
9124            red_at_merge: Vec::new(),
9125        });
9126        run.save_under(home).unwrap();
9127    }
9128
9129    fn recorded(home: &Path, id: &str) -> String {
9130        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9131        serde_json::from_str::<RunState>(&body)
9132            .unwrap()
9133            .pr
9134            .unwrap()
9135            .state
9136    }
9137
9138    const PR: &str = "https://github.com/o/r/pull/7";
9139
9140    #[test]
9141    fn write_through_updates_predecessors_and_siblings_only() {
9142        let tmp = tempfile::tempdir().unwrap();
9143        let h = tmp.path();
9144        pr_run(
9145            h,
9146            "20261004-100000-aaaa",
9147            "/repo/r",
9148            RunStatus::Superseded,
9149            PR,
9150            "open",
9151        );
9152        pr_run(
9153            h,
9154            "20261004-100100-bbbb",
9155            "/repo/r",
9156            RunStatus::Blocked,
9157            PR,
9158            "open",
9159        );
9160        // Not terminal: a driver may be writing it.
9161        pr_run(
9162            h,
9163            "20261004-100200-cccc",
9164            "/repo/r",
9165            RunStatus::Landing,
9166            PR,
9167            "open",
9168        );
9169        // Another repository's pull request with the same number.
9170        pr_run(
9171            h,
9172            "20261004-100300-dddd",
9173            "/repo/other",
9174            RunStatus::Blocked,
9175            "https://github.com/o/other/pull/7",
9176            "open",
9177        );
9178        // A different pull request of the same repository.
9179        pr_run(
9180            h,
9181            "20261004-100400-eeee",
9182            "/repo/r",
9183            RunStatus::Blocked,
9184            "https://github.com/o/r/pull/8",
9185            "open",
9186        );
9187        pr_run(
9188            h,
9189            "20261004-100500-ffff",
9190            "/repo/r",
9191            RunStatus::Merged,
9192            PR,
9193            "open",
9194        );
9195        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9196
9197        assert_eq!(
9198            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9199            2
9200        );
9201        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9202        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9203        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9204        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9205        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9206        // The source's own record is the caller's to write.
9207        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9208        // Idempotent.
9209        assert_eq!(
9210            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9211            0
9212        );
9213        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9214        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9215    }
9216
9217    #[test]
9218    fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9219        let home = tempfile::tempdir().unwrap();
9220        let live_id = "20261004-100000-aaaa";
9221        let unknown_id = "20261004-100100-bbbb";
9222        for id in [live_id, unknown_id] {
9223            pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9224            let mut s = RunState::load_under(id, home.path()).unwrap();
9225            s.driver_exited = false;
9226            if id == live_id {
9227                s.driver_pid = Some(std::process::id());
9228                s.driver_started_at = crate::proc::process_started_at(std::process::id());
9229            }
9230            s.save_under(home.path()).unwrap();
9231        }
9232        let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9233        assert_eq!(apply_pr_states(home.path(), &known), 0);
9234        for id in [live_id, unknown_id] {
9235            assert_eq!(recorded(home.path(), id), "open");
9236        }
9237    }
9238
9239    #[test]
9240    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9241        let tmp = tempfile::tempdir().unwrap();
9242        let h = tmp.path();
9243        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9244        pr_run(
9245            h,
9246            "20261004-100000-aaaa",
9247            "/repo/r",
9248            RunStatus::Superseded,
9249            &url(1),
9250            "open",
9251        );
9252        pr_run(
9253            h,
9254            "20261004-100100-bbbb",
9255            "/repo/r",
9256            RunStatus::Blocked,
9257            &url(2),
9258            "open",
9259        );
9260        pr_run(
9261            h,
9262            "20261004-100200-cccc",
9263            "/repo/r",
9264            RunStatus::Ready,
9265            &url(3),
9266            "open",
9267        );
9268        pr_run(
9269            h,
9270            "20261004-100300-dddd",
9271            "/repo/r",
9272            RunStatus::Ready,
9273            &url(4),
9274            "open",
9275        );
9276        pr_run(
9277            h,
9278            "20261004-100400-eeee",
9279            "/repo/r",
9280            RunStatus::Implementing,
9281            &url(1),
9282            "open",
9283        );
9284        assert_eq!(stale_open_prs(h).len(), 4);
9285
9286        let mut known = BTreeMap::new();
9287        known.insert(url(1), PrLifecycle::Merged);
9288        known.insert(url(2), PrLifecycle::Closed);
9289        known.insert(url(3), PrLifecycle::Open);
9290        // #4: the forge could not be read, so it has no answer.
9291        assert_eq!(apply_pr_states(h, &known), 2);
9292        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9293        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9294        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9295        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9296        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9297        assert_eq!(apply_pr_states(h, &known), 0);
9298    }
9299
9300    // --- the land loop against a scripted forge -------------------------
9301
9302    use std::collections::VecDeque;
9303    use std::sync::Mutex;
9304
9305    /// Answers views from a script (the last one repeats), merges from a
9306    /// queue, and records every call so a test can assert the order.
9307    struct Scripted {
9308        views: Mutex<VecDeque<Seen>>,
9309        merges: Mutex<VecDeque<(bool, String)>>,
9310        fix: Mutex<Option<Fixed>>,
9311        log: Mutex<Vec<&'static str>>,
9312        argvs: Mutex<Vec<Vec<String>>>,
9313        required: Mutex<Option<BTreeSet<String>>>,
9314        /// Set once a merge answered ok: the forge then reports `merged`,
9315        /// unless `queued` says the merge only entered a queue.
9316        merged: Mutex<bool>,
9317        queued: Mutex<bool>,
9318        /// Views fail once a merge answered ok.
9319        unreadable_after_merge: Mutex<bool>,
9320        /// Workflow runs asked to rerun, and whether that call fails.
9321        reruns: Mutex<Vec<String>>,
9322        rerun_fails: Mutex<bool>,
9323        /// Unix seconds; every poll moves it on by `POLL`.
9324        clock: Mutex<i64>,
9325    }
9326
9327    impl Scripted {
9328        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9329            Self {
9330                views: Mutex::new(views.into()),
9331                merges: Mutex::new(
9332                    merges
9333                        .into_iter()
9334                        .map(|(ok, m)| (ok, m.to_owned()))
9335                        .collect(),
9336                ),
9337                fix: Mutex::new(None),
9338                log: Mutex::new(Vec::new()),
9339                argvs: Mutex::new(Vec::new()),
9340                required: Mutex::new(None),
9341                merged: Mutex::new(false),
9342                queued: Mutex::new(false),
9343                unreadable_after_merge: Mutex::new(false),
9344                reruns: Mutex::new(Vec::new()),
9345                rerun_fails: Mutex::new(false),
9346                clock: Mutex::new(1_000),
9347            }
9348        }
9349        fn argvs(&self) -> Vec<Vec<String>> {
9350            self.argvs.lock().unwrap().clone()
9351        }
9352        fn calls(&self) -> Vec<&'static str> {
9353            self.log.lock().unwrap().clone()
9354        }
9355    }
9356
9357    impl Forge for Scripted {
9358        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9359            self.log.lock().unwrap().push("view");
9360            if *self.unreadable_after_merge.lock().unwrap()
9361                && !self.argvs.lock().unwrap().is_empty()
9362            {
9363                anyhow::bail!("forge unreachable");
9364            }
9365            let mut v = self.views.lock().unwrap();
9366            let mut seen = if v.len() > 1 {
9367                v.pop_front().unwrap()
9368            } else {
9369                v[0].clone()
9370            };
9371            if *self.merged.lock().unwrap() {
9372                seen.pr.state = PrLifecycle::Merged;
9373            }
9374            Ok(seen)
9375        }
9376        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9377            self.log.lock().unwrap().push("merge");
9378            self.argvs.lock().unwrap().push(argv.to_vec());
9379            let out = self
9380                .merges
9381                .lock()
9382                .unwrap()
9383                .pop_front()
9384                .expect("unscripted merge");
9385            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9386            {
9387                *self.merged.lock().unwrap() = true;
9388            }
9389            Ok(out)
9390        }
9391        async fn poll(&self) {
9392            self.log.lock().unwrap().push("poll");
9393            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9394        }
9395        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9396            self.log.lock().unwrap().push("rerun");
9397            self.reruns.lock().unwrap().push(run.to_owned());
9398            if *self.rerun_fails.lock().unwrap() {
9399                anyhow::bail!("gh run rerun: no");
9400            }
9401            Ok(())
9402        }
9403        fn now(&self) -> i64 {
9404            *self.clock.lock().unwrap()
9405        }
9406        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9407            self.required.lock().unwrap().clone()
9408        }
9409        async fn fix(
9410            &self,
9411            _state: &mut RunState,
9412            _pr: &PrState,
9413            _round: usize,
9414            _budget: usize,
9415            _reason: &str,
9416            _logs: &str,
9417        ) -> Result<Fixed> {
9418            self.log.lock().unwrap().push("fix");
9419            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
9420        }
9421    }
9422
9423    const REFUSED: &str =
9424        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
9425
9426    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
9427        let mut pr = green_pr();
9428        pr.checks = checks;
9429        pr.blocking = Blocking::of(merge_state);
9430        if !comments {
9431            pr.review_comments.clear();
9432        }
9433        Seen {
9434            pr,
9435            title: "feat: x".to_owned(),
9436            failing_urls: Vec::new(),
9437            head: head.to_owned(),
9438            rollup_head: head.to_owned(),
9439            merge_state: merge_state.to_owned(),
9440            contexts: Vec::new(),
9441            base: "main".to_owned(),
9442        }
9443    }
9444
9445    fn landing_state() -> RunState {
9446        crate::run::pin_test_home();
9447        let mut state = run_state();
9448        state.config.graph.land_approval = false;
9449        state
9450    }
9451
9452    #[test]
9453    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
9454        assert!(!awaiting_new_head(None, "aaa"));
9455        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
9456        assert!(awaiting_new_head(Some("abc123"), "def456"));
9457        assert!(awaiting_new_head(Some("abc123"), ""));
9458    }
9459
9460    #[test]
9461    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
9462        let open = |c, m: &str| seen("a", c, m, false);
9463        let table = [
9464            (None, false, Refused::Pending),
9465            (
9466                Some(open(Checks::Pending, "BLOCKED")),
9467                false,
9468                Refused::Pending,
9469            ),
9470            (
9471                Some(open(Checks::Unknown, "BLOCKED")),
9472                false,
9473                Refused::Pending,
9474            ),
9475            (
9476                Some(open(Checks::Green, "UNKNOWN")),
9477                false,
9478                Refused::Pending,
9479            ),
9480            (Some(open(Checks::Green, "")), false, Refused::Pending),
9481            (
9482                Some(open(Checks::Green, "BLOCKED")),
9483                false,
9484                Refused::Recheck,
9485            ),
9486            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
9487        ];
9488        for (after, rechecked, want) in table {
9489            assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
9490        }
9491        let mut closed = open(Checks::Green, "CLEAN");
9492        closed.pr.state = PrLifecycle::Closed;
9493        assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
9494    }
9495
9496    #[tokio::test]
9497    async fn a_normal_landing_merges_on_the_first_look() {
9498        let mut state = landing_state();
9499        let forge = Scripted::new(
9500            vec![seen("a", Checks::Green, "CLEAN", false)],
9501            vec![(true, "")],
9502        );
9503        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9504            .await
9505            .unwrap();
9506        // One fresh read, then the head-bound merge.
9507        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9508        assert_eq!(state.status, RunStatus::Merged);
9509    }
9510
9511    #[tokio::test]
9512    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
9513        let mut state = landing_state();
9514        let forge = Scripted::new(
9515            vec![seen("a", Checks::Green, "CLEAN", false)],
9516            std::iter::repeat_n((true, ""), 100).collect(),
9517        );
9518        *forge.queued.lock().unwrap() = true;
9519        let task = async {
9520            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9521                .await
9522                .unwrap();
9523        };
9524        // Still open after the command succeeded: it keeps watching and
9525        // never records a merge (it stops at the wait ceiling instead).
9526        task.await;
9527        assert_ne!(state.status, RunStatus::Merged);
9528    }
9529
9530    #[tokio::test]
9531    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
9532        let mut state = landing_state();
9533        let forge = Scripted::new(
9534            vec![seen("a", Checks::Green, "CLEAN", false)],
9535            std::iter::repeat_n((true, ""), 100).collect(),
9536        );
9537        *forge.unreadable_after_merge.lock().unwrap() = true;
9538        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9539            .await
9540            .ok();
9541        assert_ne!(state.status, RunStatus::Merged);
9542    }
9543
9544    #[tokio::test]
9545    async fn a_task_public_head_is_awaited_on_land_entry() {
9546        let mut state = landing_state();
9547        state.pr_head = Some("magi/task-abcd-1234".into());
9548        state.pr_pushed_head = Some("new".into());
9549        let forge = Scripted::new(
9550            vec![
9551                seen("old", Checks::Green, "CLEAN", false),
9552                seen("new", Checks::Green, "CLEAN", false),
9553            ],
9554            vec![(true, "")],
9555        );
9556        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9557            .await
9558            .unwrap();
9559        assert_eq!(
9560            forge.calls(),
9561            ["view", "poll", "view", "view", "merge", "view"]
9562        );
9563        assert_eq!(state.status, RunStatus::Merged);
9564    }
9565
9566    #[tokio::test]
9567    async fn a_superseded_public_head_owner_cannot_reenter_land() {
9568        let mut state = landing_state();
9569        state.pr_taken_over_by = Some("next".into());
9570        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
9571        assert!(
9572            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9573                .await
9574                .is_err()
9575        );
9576        assert!(forge.calls().is_empty());
9577    }
9578
9579    #[tokio::test]
9580    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
9581        let mut state = landing_state();
9582        let forge = Scripted::new(
9583            vec![
9584                seen("old", Checks::Green, "CLEAN", true),
9585                // The forge has not moved to the new head yet: still green.
9586                seen("old", Checks::Green, "CLEAN", true),
9587                seen("new", Checks::Pending, "BLOCKED", true),
9588                seen("new", Checks::Green, "CLEAN", true),
9589            ],
9590            vec![(true, "")],
9591        );
9592        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9593            head: "NEW".to_owned(),
9594        });
9595        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9596            .await
9597            .unwrap();
9598        assert_eq!(
9599            forge.calls(),
9600            [
9601                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
9602                "view"
9603            ]
9604        );
9605        assert_eq!(state.status, RunStatus::Merged);
9606    }
9607
9608    #[tokio::test]
9609    async fn a_head_that_never_arrives_stops_naming_both_commits() {
9610        let mut state = landing_state();
9611        let forge = Scripted::new(
9612            vec![
9613                seen("old", Checks::Green, "CLEAN", true),
9614                seen("someone-elses", Checks::Green, "CLEAN", true),
9615            ],
9616            vec![],
9617        );
9618        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9619            head: "mine".to_owned(),
9620        });
9621        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9622            .await
9623            .unwrap();
9624        assert!(!forge.calls().contains(&"merge"));
9625        let why = state.merge.as_ref().unwrap().detail.clone();
9626        assert!(
9627            why.contains("mine") && why.contains("someone-elses"),
9628            "{why}"
9629        );
9630        assert_eq!(state.status, RunStatus::Blocked);
9631    }
9632
9633    #[tokio::test]
9634    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
9635        let mut state = landing_state();
9636        let forge = Scripted::new(
9637            vec![
9638                seen("a", Checks::Green, "CLEAN", false),
9639                seen("a", Checks::Green, "CLEAN", false),
9640                seen("a", Checks::Pending, "BLOCKED", false),
9641                seen("a", Checks::Pending, "BLOCKED", false),
9642                seen("a", Checks::Green, "CLEAN", false),
9643            ],
9644            vec![(false, REFUSED), (true, "")],
9645        );
9646        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9647            .await
9648            .unwrap();
9649        assert_eq!(
9650            forge.calls(),
9651            [
9652                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
9653                "view"
9654            ]
9655        );
9656        assert_eq!(state.status, RunStatus::Merged);
9657    }
9658
9659    #[tokio::test]
9660    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
9661        let mut state = landing_state();
9662        let forge = Scripted::new(
9663            vec![
9664                seen("a", Checks::Green, "CLEAN", false),
9665                seen("a", Checks::Green, "BLOCKED", false),
9666            ],
9667            vec![(false, REFUSED), (false, REFUSED)],
9668        );
9669        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9670            .await
9671            .unwrap();
9672        // One re-look is allowed for the forge's own lag, then it is final.
9673        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
9674        let why = state.merge.as_ref().unwrap().detail.clone();
9675        assert!(
9676            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
9677            "{why}"
9678        );
9679        assert_eq!(state.status, RunStatus::Blocked);
9680    }
9681
9682    #[test]
9683    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
9684        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
9685        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
9686        // The pull request is still on the commit before the push.
9687        assert_eq!(bound_head("old", "old", Some("new")), None);
9688        // The checks are the previous commit's.
9689        assert_eq!(bound_head("new", "old", Some("new")), None);
9690        assert_eq!(bound_head("new", "old", None), None);
9691        // Nothing readable.
9692        assert_eq!(bound_head("", "", None), None);
9693        assert_eq!(bound_head("", "", Some("new")), None);
9694        assert_eq!(bound_head("abc", "", None), None);
9695    }
9696
9697    fn view_json(head: &str) -> String {
9698        format!(
9699            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
9700            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
9701            "reviews":[],"comments":[]}}"#
9702        )
9703    }
9704
9705    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
9706        format!(
9707            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
9708            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
9709            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
9710            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
9711        )
9712    }
9713
9714    #[test]
9715    fn rollup_is_bound_to_the_commit_in_the_same_node() {
9716        // The view already points at the new head, but the node still answers
9717        // for the old commit with its red check: the head stays unbound.
9718        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
9719        assert_eq!(s.rollup_head, "old");
9720        assert_eq!(s.pr.checks, Checks::Red);
9721        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
9722        assert_eq!(s.failing_urls.len(), 1);
9723    }
9724
9725    #[test]
9726    fn checks_come_from_the_node_not_the_view() {
9727        let view = view_json("new").replace(
9728            r#""reviews""#,
9729            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
9730        );
9731        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
9732        assert_eq!(s.pr.checks, Checks::Green);
9733        assert!(s.pr.failing.is_empty());
9734        assert_eq!(
9735            bound_head(&s.head, &s.rollup_head, Some("new")),
9736            Some("new")
9737        );
9738    }
9739
9740    #[test]
9741    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
9742        for node in [
9743            None,
9744            Some("not json".to_owned()),
9745            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
9746            Some(node_json("new", "SUCCESS", true)),
9747        ] {
9748            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
9749            assert!(s.rollup_head.is_empty());
9750            assert_eq!(s.pr.checks, Checks::Unknown);
9751            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
9752        }
9753    }
9754
9755    #[test]
9756    fn the_merge_command_is_pinned_to_the_observed_head() {
9757        let argv = merge_argv_at(7, "feat: x", "deadbeef");
9758        let at = argv
9759            .iter()
9760            .position(|a| a == "--match-head-commit")
9761            .unwrap();
9762        assert_eq!(argv[at + 1], "deadbeef");
9763    }
9764
9765    #[tokio::test]
9766    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
9767        let mut state = landing_state();
9768        // The pull request is on the pushed head but the rollup is still the
9769        // previous commit's red, non-required result.
9770        let mut stale = seen("new", Checks::Red, "CLEAN", false);
9771        stale.rollup_head = "old".to_owned();
9772        let forge = Scripted::new(
9773            vec![seen("old", Checks::Green, "CLEAN", true), stale],
9774            vec![],
9775        );
9776        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9777            head: "new".to_owned(),
9778        });
9779        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9780            .await
9781            .unwrap();
9782        assert!(!forge.calls().contains(&"merge"));
9783        assert_eq!(state.status, RunStatus::Blocked);
9784        let why = state.merge.as_ref().unwrap().detail.clone();
9785        assert!(why.contains("new") && why.contains("old"), "{why}");
9786    }
9787
9788    #[test]
9789    fn a_refusal_read_against_another_commits_checks_is_pending() {
9790        let mut after = seen("a", Checks::Green, "BLOCKED", false);
9791        after.rollup_head = "old".to_owned();
9792        assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
9793    }
9794
9795    #[tokio::test]
9796    async fn a_matching_head_with_red_non_required_checks_still_merges() {
9797        let mut state = landing_state();
9798        let forge = Scripted::new(
9799            vec![seen("a", Checks::Red, "CLEAN", false)],
9800            vec![(true, "")],
9801        );
9802        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9803            .await
9804            .unwrap();
9805        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9806        assert_eq!(state.status, RunStatus::Merged);
9807    }
9808
9809    #[tokio::test]
9810    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
9811        let mut state = landing_state();
9812        let forge = Scripted::new(
9813            vec![
9814                seen("a", Checks::Green, "CLEAN", false),
9815                seen("a", Checks::Green, "CLEAN", false),
9816                // Re-viewed after the refusal: someone pushed.
9817                seen("b", Checks::Green, "BLOCKED", false),
9818                seen("b", Checks::Green, "CLEAN", false),
9819            ],
9820            vec![(false, REFUSED), (true, "")],
9821        );
9822        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9823            .await
9824            .unwrap();
9825        assert_eq!(
9826            forge.calls(),
9827            [
9828                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
9829            ]
9830        );
9831        assert_eq!(state.status, RunStatus::Merged);
9832    }
9833
9834    fn merged_view(head: &str) -> Seen {
9835        let mut m = seen(head, Checks::Green, "CLEAN", false);
9836        m.pr.state = PrLifecycle::Merged;
9837        m
9838    }
9839
9840    fn has(argv: &[String], flag: &str) -> bool {
9841        argv.iter().any(|a| a == flag)
9842    }
9843
9844    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
9845        let at = argv.iter().position(|a| a == flag)?;
9846        argv.get(at + 1).map(String::as_str)
9847    }
9848
9849    const URL: &str = "https://github.com/o/r/pull/42";
9850
9851    #[tokio::test]
9852    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
9853        let mut state = landing_state();
9854        let forge = Scripted::new(
9855            vec![
9856                seen("abc", Checks::Green, "CLEAN", false),
9857                seen("abc", Checks::Green, "CLEAN", false),
9858            ],
9859            vec![(true, "")],
9860        );
9861        land_with(&mut state, URL, &forge).await.unwrap();
9862        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9863        let argv = &forge.argvs()[0];
9864        assert!(has(argv, "--squash") && has(argv, "--subject"));
9865        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
9866        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
9867        assert_eq!(state.status, RunStatus::Merged);
9868        assert!(state.land_armed_head.is_none());
9869    }
9870
9871    #[tokio::test]
9872    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
9873        let mut state = landing_state();
9874        state.land_armed_head = Some("a".to_owned());
9875        let forge = Scripted::new(
9876            vec![seen("a", Checks::Green, "CLEAN", false)],
9877            vec![(true, ""), (true, "")],
9878        );
9879        land_with(&mut state, URL, &forge).await.unwrap();
9880        let argvs = forge.argvs();
9881        assert!(has(&argvs[0], "--disable-auto"));
9882        assert!(!has(&argvs[1], "--auto"));
9883        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
9884        assert_eq!(state.status, RunStatus::Merged);
9885        assert!(state.land_armed_head.is_none());
9886    }
9887
9888    #[tokio::test]
9889    async fn an_approval_never_carries_over_to_a_new_head() {
9890        crate::run::pin_test_home();
9891        let mut state = run_state();
9892        state.config.graph.land_approval = true;
9893        let pr = green_pr();
9894        let store = ask::Questions::open();
9895
9896        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
9897            .await
9898            .unwrap();
9899        let mut q = store
9900            .list()
9901            .into_iter()
9902            .find(|q| q.run == state.id)
9903            .unwrap();
9904        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9905        store.put(&mut q).unwrap();
9906        assert_eq!(
9907            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
9908                .await
9909                .unwrap(),
9910            ApprovalGate::Approved,
9911            "the same head keeps its approval"
9912        );
9913
9914        // A new head is a new question, not the old word.
9915        assert_eq!(
9916            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
9917                .await
9918                .unwrap(),
9919            ApprovalGate::Pending
9920        );
9921        let all: Vec<_> = store
9922            .list()
9923            .into_iter()
9924            .filter(|q| q.run == state.id)
9925            .collect();
9926        assert_eq!(all.len(), 2);
9927
9928        // A question recorded before heads were tracked is not reused either.
9929        state.land_approval = None;
9930        assert_eq!(
9931            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
9932                .await
9933                .unwrap(),
9934            ApprovalGate::Pending
9935        );
9936        let open = store
9937            .list()
9938            .into_iter()
9939            .filter(|q| q.run == state.id && q.status.open())
9940            .count();
9941        assert_eq!(open, 1, "the superseded question was retired");
9942    }
9943
9944    #[tokio::test]
9945    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
9946        let mut state = landing_state();
9947        let forge = Scripted::new(
9948            vec![
9949                seen("a", Checks::Green, "CLEAN", false),
9950                // The fresh read before the merge: someone pushed.
9951                seen("b", Checks::Green, "CLEAN", false),
9952            ],
9953            vec![(true, "")],
9954        );
9955        land_with(&mut state, URL, &forge).await.unwrap();
9956        let argvs = forge.argvs();
9957        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
9958        assert!(!has(&argvs[0], "--auto"));
9959        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
9960        assert_eq!(state.status, RunStatus::Merged);
9961    }
9962
9963    fn passing(label: &str) -> CheckInfo {
9964        CheckInfo {
9965            label: label.to_owned(),
9966            verdict: Verdict::Pass,
9967            required: Some(false),
9968            run: None,
9969        }
9970    }
9971
9972    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
9973        (label.to_owned(), run.map(str::to_owned))
9974    }
9975
9976    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
9977        LandReruns {
9978            head: head.to_owned(),
9979            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
9980        }
9981    }
9982
9983    #[test]
9984    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
9985        let f = [failing("test (macos)", Some("7"))];
9986        assert_eq!(
9987            rerun_step(&f, None, "h", 100),
9988            RerunStep::Rerun(vec!["7".to_owned()])
9989        );
9990        let rec = reran("h", &[("7", 100)]);
9991        let grace = crate::release_watch::RERUN_GRACE;
9992        assert_eq!(
9993            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
9994            RerunStep::Grace
9995        );
9996        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
9997    }
9998
9999    #[test]
10000    fn a_failure_without_a_run_id_goes_to_the_fixer() {
10001        let f = [failing("a", Some("7")), failing("ci/external", None)];
10002        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
10003        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
10004    }
10005
10006    #[test]
10007    fn a_moved_head_forgets_the_reruns() {
10008        let f = [failing("a", Some("7"))];
10009        let rec = reran("old", &[("7", 100)]);
10010        assert_eq!(
10011            rerun_step(&f, Some(&rec), "new", 101),
10012            RerunStep::Rerun(vec!["7".to_owned()])
10013        );
10014    }
10015
10016    #[test]
10017    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
10018        let f = [
10019            failing("a", Some("7")),
10020            failing("b", Some("7")),
10021            failing("c", Some("8")),
10022        ];
10023        assert_eq!(
10024            rerun_step(&f, None, "h", 0),
10025            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
10026        );
10027        let rec = reran("h", &[("7", 0)]);
10028        assert_eq!(
10029            rerun_step(&f, Some(&rec), "h", 1),
10030            RerunStep::Rerun(vec!["8".to_owned()])
10031        );
10032    }
10033
10034    #[test]
10035    fn only_blocking_failures_are_candidates_for_a_rerun() {
10036        let check = |label: &str, verdict, required, run: &str| CheckInfo {
10037            label: label.to_owned(),
10038            verdict,
10039            required,
10040            run: Some(run.to_owned()),
10041        };
10042        let got = rerunnable(&[
10043            check("passed", Verdict::Pass, Some(true), "1"),
10044            check("optional", Verdict::Fail, Some(false), "2"),
10045            check("required", Verdict::Fail, Some(true), "3"),
10046            check("unknown", Verdict::Fail, None, "4"),
10047        ]);
10048        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
10049        assert_eq!(labels, ["required", "unknown"]);
10050    }
10051
10052    fn red(head: &str, run: Option<&str>) -> Seen {
10053        let mut s = seen(head, Checks::Red, "BLOCKED", false);
10054        s.pr.failing = vec!["test (macos)".to_owned()];
10055        s.contexts = vec![CheckInfo {
10056            label: "test (macos)".to_owned(),
10057            verdict: Verdict::Fail,
10058            required: Some(true),
10059            run: run.map(str::to_owned),
10060        }];
10061        s
10062    }
10063
10064    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
10065
10066    #[tokio::test]
10067    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
10068        let mut state = landing_state();
10069        let forge = Scripted::new(
10070            vec![
10071                red("a", Some("7")),
10072                seen("a", Checks::Pending, "BLOCKED", false),
10073                seen("a", Checks::Green, "CLEAN", false),
10074            ],
10075            vec![(true, "")],
10076        );
10077        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10078        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10079        assert!(!forge.calls().contains(&"fix"));
10080        assert_eq!(state.status, RunStatus::Merged);
10081        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10082        let argvs = forge.argvs();
10083        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
10084        assert!(
10085            state
10086                .events
10087                .iter()
10088                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
10089        );
10090    }
10091
10092    #[tokio::test]
10093    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
10094        let mut state = landing_state();
10095        let forge = Scripted::new(
10096            vec![
10097                red("a", Some("7")),
10098                seen("a", Checks::Green, "CLEAN", false),
10099            ],
10100            vec![(true, "")],
10101        );
10102        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10103        // The red view repeats for the grace, then the fixer is asked once.
10104        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10105        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
10106        let calls = forge.calls();
10107        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
10108        let fix_at = calls.iter().position(|c| *c == "fix");
10109        assert!(fix_at.is_none_or(|f| f > rerun_at));
10110    }
10111
10112    #[tokio::test]
10113    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
10114        let mut state = landing_state();
10115        let mut v = red("a", Some("7"));
10116        v.pr.checks = Checks::Pending;
10117        v.contexts.push(CheckInfo {
10118            label: "slow".to_owned(),
10119            verdict: Verdict::Pending,
10120            required: Some(true),
10121            run: Some("8".to_owned()),
10122        });
10123        let forge = Scripted::new(
10124            vec![v, seen("a", Checks::Green, "CLEAN", false)],
10125            vec![(true, "")],
10126        );
10127        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10128        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10129        assert_eq!(state.status, RunStatus::Merged);
10130    }
10131
10132    #[test]
10133    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10134        let mut v = red("a", Some("7"));
10135        v.contexts.push(CheckInfo {
10136            label: "same run, still going".to_owned(),
10137            verdict: Verdict::Pending,
10138            required: Some(true),
10139            run: Some("7".to_owned()),
10140        });
10141        assert!(rerunnable(&v.contexts).is_empty());
10142    }
10143
10144    #[tokio::test]
10145    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10146        let mut state = landing_state();
10147        state.config.graph.land_rounds = 0;
10148        let forge = Scripted::new(
10149            vec![
10150                red("a", Some("7")),
10151                seen("a", Checks::Green, "CLEAN", false),
10152            ],
10153            vec![(true, "")],
10154        );
10155        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10156        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10157        assert_eq!(state.status, RunStatus::Merged);
10158        assert!(!forge.calls().contains(&"fix"));
10159        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10160    }
10161
10162    #[tokio::test]
10163    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
10164        let mut state = landing_state();
10165        state.config.graph.land_rounds = 1;
10166        let forge = Scripted::new(
10167            vec![
10168                seen("a", Checks::Green, "CLEAN", true),
10169                red("b", Some("7")),
10170                seen("b", Checks::Green, "CLEAN", false),
10171            ],
10172            vec![(true, "")],
10173        );
10174        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10175            head: "b".to_owned(),
10176        });
10177        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10178        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10179        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
10180        assert_eq!(state.pr.as_ref().unwrap().round, 1);
10181        assert_eq!(state.status, RunStatus::Merged);
10182        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
10183        assert_eq!(
10184            value_of(&forge.argvs()[0], "--match-head-commit"),
10185            Some("b")
10186        );
10187    }
10188
10189    #[tokio::test]
10190    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
10191        let mut state = landing_state();
10192        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10193        *forge.rerun_fails.lock().unwrap() = true;
10194        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10195        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10196        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
10197        assert_eq!(
10198            forge.calls().iter().filter(|c| **c == "fix").count(),
10199            1,
10200            "{:?}",
10201            forge.calls()
10202        );
10203    }
10204
10205    #[tokio::test]
10206    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
10207        let mut state = landing_state();
10208        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10209        // The record was written just before the previous pass died.
10210        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
10211        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10212        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10213        assert!(forge.reruns.lock().unwrap().is_empty());
10214    }
10215
10216    #[tokio::test]
10217    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
10218        let mut state = landing_state();
10219        state.config.graph.rerun_flaky_checks = false;
10220        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10221        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10222        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10223        assert!(forge.reruns.lock().unwrap().is_empty());
10224        assert!(forge.calls().contains(&"fix"));
10225
10226        let mut state = landing_state();
10227        let forge = Scripted::new(vec![red("a", None)], vec![]);
10228        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10229        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10230        assert!(forge.reruns.lock().unwrap().is_empty());
10231        assert!(forge.calls().contains(&"fix"));
10232    }
10233
10234    fn names(xs: &[&str]) -> BTreeSet<String> {
10235        xs.iter().map(|x| (*x).to_owned()).collect()
10236    }
10237
10238    #[test]
10239    fn a_required_check_the_rollup_never_listed_is_named() {
10240        let req = names(&["build"]);
10241        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
10242        assert!(why.contains("never reported: build"), "{why}");
10243        assert!(!why.contains("probably waiting for a review"), "{why}");
10244    }
10245
10246    #[test]
10247    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
10248        let why = waiting_on("BLOCKED", &[passing("review")], None);
10249        assert!(why.contains("could not be read"), "{why}");
10250        assert!(!why.contains("probably waiting for a review"), "{why}");
10251    }
10252
10253    #[test]
10254    fn all_required_reported_keeps_the_review_guess() {
10255        let req = names(&["build"]);
10256        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10257        assert!(why.contains("probably waiting for a review"), "{why}");
10258        assert!(!why.contains("never reported"), "{why}");
10259    }
10260
10261    #[test]
10262    fn required_names_match_the_rollup_ignoring_case_only() {
10263        let req = names(&["Build"]);
10264        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10265        assert!(!why.contains("never reported"), "{why}");
10266    }
10267
10268    #[test]
10269    fn required_contexts_are_read_from_protection_and_rulesets() {
10270        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
10271        assert_eq!(
10272            parse_classic_required(classic),
10273            Some(names(&["build", "lint"]))
10274        );
10275        let rules = r#"[{"type":"pull_request","parameters":{}},
10276            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
10277        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
10278        assert_eq!(parse_ruleset_required("nope"), None);
10279        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
10280    }
10281
10282    #[test]
10283    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
10284        let shown = BTreeSet::new();
10285        let ok = seen("a", Checks::Green, "CLEAN", false);
10286        let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
10287        assert!(guard(Some(&ok)));
10288        assert!(!guard(None));
10289        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
10290        let mut stale = ok.clone();
10291        stale.rollup_head = "old".to_owned();
10292        assert!(!guard(Some(&stale)));
10293        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
10294        assert!(!guard(Some(&merged_view("a"))));
10295    }
10296
10297    #[test]
10298    fn the_rollup_node_carries_whether_each_check_is_required() {
10299        let node = node_json("new", "SUCCESS", false)
10300            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
10301        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
10302        assert_eq!(s.contexts.len(), 1);
10303        assert_eq!(s.contexts[0].required, Some(true));
10304        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
10305        assert_eq!(s.contexts[0].required, None);
10306    }
10307
10308    #[tokio::test]
10309    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
10310        let mut state = landing_state();
10311        state.land_armed_head = Some("a".to_owned());
10312        let forge = Scripted::new(
10313            vec![seen("a", Checks::Green, "CLEAN", true)],
10314            vec![(false, "disable exploded")],
10315        );
10316        land_with(&mut state, URL, &forge).await.unwrap();
10317        assert!(!forge.calls().contains(&"fix"));
10318        assert_eq!(state.status, RunStatus::Blocked);
10319        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
10320        let why = state.merge.as_ref().unwrap().detail.clone();
10321        assert!(why.contains("disable exploded"), "{why}");
10322    }
10323}