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.config.graph.land_approval {
650        reasons.push(ApprovalReason::EveryMerge);
651    }
652    if state.config.graph.hold_contested_merge {
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 _ = writeln!(s, "- {label}");
733    }
734    s.push_str(if ja {
735        "\n再考後の各レビュアーの最終票:\n"
736    } else {
737        "\nFinal reviewer votes after reconsideration:\n"
738    });
739    if let Some(round) = state.reviews.last() {
740        let votes = round.final_votes();
741        for r in &round.reviews {
742            let vote = votes
743                .iter()
744                .find(|(seat, _, _)| *seat == r.reviewer)
745                .map(|(_, _, v)| v.label())
746                .unwrap_or(if ja { "記録なし" } else { "not recorded" });
747            let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
748            let rv = round
749                .reconsideration
750                .iter()
751                .find(|rv| rv.reviewer == r.reviewer);
752            if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
753                let initial = r.vote.map(|v| v.label()).unwrap_or("?");
754                let _ = writeln!(
755                    s,
756                    "  {}: {initial} → {vote}",
757                    if ja { "再考" } else { "reconsideration" }
758                );
759                let _ = writeln!(
760                    s,
761                    "  {}: {}",
762                    if ja { "理由" } else { "reason" },
763                    reason(if rv.reason.trim().is_empty() {
764                        if ja { "記録なし" } else { "not recorded" }
765                    } else {
766                        &rv.reason
767                    })
768                );
769            } else {
770                let _ = writeln!(
771                    s,
772                    "  {}: {}",
773                    if ja {
774                        "初回票を保持・理由"
775                    } else {
776                        "initial vote retained; reason"
777                    },
778                    reason(if r.summary.trim().is_empty() {
779                        if ja { "記録なし" } else { "not recorded" }
780                    } else {
781                        &r.summary
782                    })
783                );
784                if let Some(rv) = rv {
785                    let _ = writeln!(
786                        s,
787                        "  {}: {}",
788                        if ja {
789                            "再投票の回答なし"
790                        } else {
791                            "no reconsideration vote"
792                        },
793                        reason(rv.failed.as_deref().unwrap_or(if ja {
794                            "理由の記録なし"
795                        } else {
796                            "reason not recorded"
797                        }))
798                    );
799                }
800            }
801            if let Some(failed) = &r.failed {
802                let _ = writeln!(
803                    s,
804                    "  {}: {}",
805                    if ja { "回答なし" } else { "no answer" },
806                    reason(failed)
807                );
808            }
809        }
810    } else {
811        s.push_str(if ja {
812            "レビュー記録なし\n"
813        } else {
814            "No review recorded\n"
815        });
816    }
817    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" });
818    let findings = state.last_round_findings();
819    for f in &findings {
820        let _ = writeln!(
821            s,
822            "[{}] {} {}:{}: {}\n{}",
823            f.id,
824            f.severity,
825            f.file.as_deref().unwrap_or("?"),
826            f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
827            approval_excerpt(&f.title, TITLE_CHARS),
828            reason(&f.detail)
829        );
830        let mut evidence = false;
831        for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
832            state
833                .operator_fixes
834                .iter()
835                .filter_map(|request| request.fix.as_ref()),
836        ) {
837            if fix.addressed.contains(&f.id) {
838                evidence = true;
839                let _ = writeln!(
840                    s,
841                    "  {} ({}): {}",
842                    if ja {
843                        "採用申告(解決未確認)"
844                    } else {
845                        "claimed addressed (resolution unconfirmed)"
846                    },
847                    fix.agent,
848                    reason(&fix.notes)
849                );
850            }
851            for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
852                evidence = true;
853                let _ = writeln!(
854                    s,
855                    "  {}: {}",
856                    if ja { "不採用理由" } else { "declined" },
857                    reason(&rejection.why)
858                );
859            }
860        }
861        if !evidence {
862            s.push_str(if ja {
863                "  採用・解決確認の記録なし\n"
864            } else {
865                "  No adoption or resolution evidence recorded\n"
866            });
867        }
868    }
869    if findings.is_empty() {
870        s.push_str(if ja {
871            "最終ラウンドに指摘の記録なし\n"
872        } else {
873            "No findings recorded in the last round\n"
874        });
875    }
876    s
877}
878
879/// Shared choices for web, CLI and consultation questions.
880pub fn approval_choices(state: &RunState) -> Vec<String> {
881    let mut choices = vec![
882        APPROVE.to_owned(),
883        HOLD.to_owned(),
884        REPAIR.to_owned(),
885        crate::merge_decision::WITHDRAW.to_owned(),
886        crate::merge_decision::DISMISS.to_owned(),
887    ];
888    choices.extend(
889        state
890            .config
891            .agents
892            .iter()
893            .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
894    );
895    choices
896}
897
898/// Extend unanswered legacy cards in place. Answered cards keep their choices;
899/// a legacy repair answer gains only its missing dispatch action.
900pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
901    if q.node != APPROVAL_NODE
902        || !state
903            .land_approval
904            .as_ref()
905            .is_some_and(|a| a.question == q.id)
906    {
907        return false;
908    }
909    let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
910    if q.status.open() {
911        // Older cards attached Resume to hold; a hold must remain answerable
912        // without dispatching a question action.
913        q.actions.remove(HOLD);
914        for choice in approval_choices(state) {
915            if !q.choices.contains(&choice) {
916                q.choices.push(choice);
917            }
918        }
919        if q.merge_target.is_none() {
920            q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
921                pr: pr.url.clone(),
922                head: state.land_approval.as_ref().unwrap().head.clone(),
923                language: state.config.graph.language.clone(),
924            });
925        }
926    }
927    for choice in q.choices.clone() {
928        if (q.status.open()
929            && crate::merge_decision::stops(&choice)
930            && choice != HOLD
931            && choice != crate::merge_decision::DISMISS)
932            || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
933        {
934            q.actions.insert(
935                choice,
936                ask::ChoiceAction::Resume {
937                    run: state.id.clone(),
938                },
939            );
940        }
941    }
942    before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
943}
944
945/// Whether `quote` is a verbatim part of the owner's `message` and nothing in
946/// the whole message carries a hedge, a condition, a negation, a question or a
947/// retraction. Used by `deputy::destructive`.
948///
949/// The whole message is read, not just the quote's sentence: a condition or a
950/// second thought in another sentence ("Merge it. Only if CI passes.") makes
951/// the approval conditional all the same. Doubt anywhere is `false`.
952pub fn unhedged(message: &str, quote: &str) -> bool {
953    let quote = quote.trim();
954    !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
955}
956
957/// Hedging, conditional, negated or interrogative wording. ASCII words are
958/// matched as whole words so `note` is not `not`. A bare negation or stop word
959/// (`no`, `stop`, `nope`, ...) anywhere in the message voids the approval.
960fn hedged(text: &str) -> bool {
961    const WORDS: &[&str] = &[
962        "maybe",
963        "probably",
964        "perhaps",
965        "might",
966        "if",
967        "unless",
968        "not",
969        "no",
970        "nope",
971        "stop",
972        "dont",
973        "abort",
974        "revert",
975        "undo",
976        "never",
977        "wait",
978        "hold",
979        "cancel",
980        "but",
981        "think",
982        "guess",
983        "suppose",
984        "unsure",
985        "yet",
986        "except",
987        "only",
988        "cannot",
989        "should",
990        "once",
991        "provided",
992        "providing",
993        "when",
994        "whenever",
995        "after",
996        "until",
997        "before",
998        "assuming",
999        "given",
1000        "while",
1001        "whether",
1002        "depending",
1003        "pending",
1004    ];
1005    const JA: &[&str] = &[
1006        "かも",
1007        "たぶん",
1008        "多分",
1009        "なら",
1010        "たら",
1011        "ちょっと待",
1012        "しないで",
1013        "しない",
1014        "保留",
1015        "まだ",
1016        "ただし",
1017        "やめ",
1018        "いや",
1019        "止め",
1020        "だめ",
1021        "ダメ",
1022        "じゃない",
1023        "ではない",
1024        "ですか",
1025        "かな",
1026        "でしょう",
1027        "思う",
1028        "でも",
1029        "けど",
1030        "ただ",
1031        "次第",
1032        "場合",
1033        "限り",
1034        "条件",
1035        "とき",
1036        "まで",
1037        "後で",
1038    ];
1039    if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
1040        return true;
1041    }
1042    text.to_lowercase()
1043        .replace('\u{2019}', "'")
1044        .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
1045        .filter(|w| !w.is_empty())
1046        .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
1047}
1048
1049/// Wording that takes back what the rest of the message said. Bare negation
1050/// and stop words are `hedged`'s whole-word list, not substrings here.
1051fn retracts(message: &str) -> bool {
1052    let lower = message.to_lowercase();
1053    [
1054        "やっぱ",
1055        "待って",
1056        "撤回",
1057        "never mind",
1058        "actually",
1059        "on second thought",
1060    ]
1061    .iter()
1062    .any(|w| lower.contains(w))
1063        || lower
1064            .split(|c: char| !c.is_ascii_alphabetic())
1065            .any(|w| w == "wait")
1066}
1067
1068/// Graph node recorded on the approval question.
1069///
1070/// The phone keys its high-stakes card off this rather than off the choice
1071/// strings, so renaming a button cannot silently downgrade the card that
1072/// guards the one irreversible action magi takes.
1073pub const APPROVAL_NODE: &str = "land-approval";
1074
1075/// Unified diff lines carried in the panel before it is truncated.
1076///
1077/// Four hundred: the panel is read on a 390px phone, where a diff line often
1078/// wraps to two rows, so this is already a few thousand rows of scrolling -
1079/// past that nobody is reading, and the bytes still count against the panel's
1080/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
1081/// cut and which worktree holds the whole patch.
1082pub const DIFF_MAX_LINES: usize = 400;
1083
1084/// What the owner's answer to the approval question means.
1085#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1086pub enum Approval {
1087    /// The owner said [`APPROVE`]. Merge.
1088    Merge,
1089    /// Repair the existing PR, then ask again; never approval to merge.
1090    Repair,
1091    /// Anything else, including silence. Leave the pull request open.
1092    Hold,
1093}
1094
1095/// Read the owner's answer, where `None` is an unanswered question.
1096///
1097/// Silence is a hold. A timed-out question means the owner never saw it or
1098/// never decided, and defaulting an irreversible merge to "yes" would make this
1099/// gate worse than no gate at all: it would merge unattended while claiming to
1100/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
1101/// function does not recognise holds too.
1102pub fn approval(answer: Option<&str>) -> Approval {
1103    match answer {
1104        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1105        Some(a) if a.trim() == REPAIR => Approval::Repair,
1106        _ => Approval::Hold,
1107    }
1108}
1109
1110/// What [`approval_gate`] found on one check of the owner's merge decision.
1111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1112enum ApprovalGate {
1113    /// The owner said [`APPROVE`]. Merge.
1114    Approved,
1115    /// The owner explicitly requested repair, without authorizing a merge.
1116    Repair,
1117    /// The owner said anything else, the question timed out, or it was
1118    /// closed with no decision recorded.
1119    Held,
1120    /// Filed and still waiting - the caller parks rather than blocking on it.
1121    Pending,
1122}
1123
1124/// Escape text for HTML, including both quote characters.
1125///
1126/// Every string in the panel is agent-influenced: a branch name, a file path, a
1127/// commit subject, a review comment. The sandboxed frame stops such text from
1128/// *running*, but it does not stop a `<` from ending the document early or a
1129/// `"` from ending an attribute and inventing a new one - the panel would then
1130/// render a lie, or not render at all. Both quotes are escaped because the same
1131/// function is used inside attributes, where remembering which quote style the
1132/// caller used is one mistake away from an injected attribute.
1133fn esc(s: &str) -> String {
1134    let mut out = String::with_capacity(s.len());
1135    for c in s.chars() {
1136        match c {
1137            '&' => out.push_str("&amp;"),
1138            '<' => out.push_str("&lt;"),
1139            '>' => out.push_str("&gt;"),
1140            '"' => out.push_str("&quot;"),
1141            '\'' => out.push_str("&#39;"),
1142            _ => out.push(c),
1143        }
1144    }
1145    out
1146}
1147
1148/// One row of the diffstat table.
1149#[derive(Debug, Clone, PartialEq, Eq)]
1150struct StatRow {
1151    path: String,
1152    /// `None` for a binary file, which `git` reports as `-`.
1153    added: Option<u64>,
1154    removed: Option<u64>,
1155}
1156
1157impl StatRow {
1158    /// Lines touched, for sorting. A binary file counts as zero rather than as
1159    /// unknown, which puts it at the bottom where it needs no attention.
1160    fn churn(&self) -> u64 {
1161        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1162    }
1163}
1164
1165/// Parse `git diff --numstat` into rows, biggest churn first.
1166///
1167/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
1168/// terminal width, so counting its characters would print fabricated numbers in
1169/// the one table an operator approves an irreversible action from.
1170fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1171    let mut rows: Vec<StatRow> = numstat
1172        .lines()
1173        .filter_map(|line| {
1174            let mut parts = line.splitn(3, '\t');
1175            let added = parts.next()?.trim();
1176            let removed = parts.next()?.trim();
1177            let path = parts.next()?.trim();
1178            if path.is_empty() {
1179                return None;
1180            }
1181            Some(StatRow {
1182                path: path.to_owned(),
1183                added: added.parse().ok(),
1184                removed: removed.parse().ok(),
1185            })
1186        })
1187        .collect();
1188    // Path breaks the tie so the same change always renders the same table; an
1189    // operator comparing two panels should not see rows shuffle.
1190    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1191    rows
1192}
1193
1194/// How one diff line is shown: a gutter character, a style, and the body to
1195/// print - which is the line minus its marker, so the marker appears exactly
1196/// once, in the gutter.
1197///
1198/// The gutter is why this exists at all. The operator may be colour blind, or
1199/// reading in sunlight with the screen dimmed, so an added line is never
1200/// distinguished by its background alone: `+` and `-` sit in a fixed column,
1201/// the same mark they already read in a terminal.
1202fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1203    if line.starts_with("+++") || line.starts_with("---") {
1204        (" ", "color:#57606a;font-weight:600", line)
1205    } else if let Some(body) = line.strip_prefix('+') {
1206        ("+", "background:#e6ffec;color:#0a3622", body)
1207    } else if let Some(body) = line.strip_prefix('-') {
1208        ("-", "background:#ffebe9;color:#5c1a17", body)
1209    } else if line.starts_with("@@") {
1210        ("~", "background:#eef2ff;color:#3730a3", line)
1211    } else if let Some(body) = line.strip_prefix(' ') {
1212        (" ", "", body)
1213    } else {
1214        (" ", "color:#57606a;font-weight:600", line)
1215    }
1216}
1217
1218/// The handful of words the approval panel says in its own voice.
1219///
1220/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
1221/// the operator asked why the merge question spoke English on a repository
1222/// configured for Japanese, and "because that string is a literal in Rust" is
1223/// not an answer. Only the languages magi can actually check are translated;
1224/// anything else falls back to English rather than shipping a guess, and that
1225/// fallback is deliberate.
1226struct Words {
1227    html_lang: &'static str,
1228    task: &'static str,
1229    what_changed: &'static str,
1230    review_verdict: &'static str,
1231    reviewer: &'static str,
1232    reviewer_no_answer: &'static str,
1233    checks: &'static str,
1234    nothing_failing: &'static str,
1235    files_changed: &'static str,
1236    commits: &'static str,
1237    no_commits: &'static str,
1238    comments: &'static str,
1239    no_comments: &'static str,
1240    diff: &'static str,
1241    truncated: &'static str,
1242    lands_as: &'static str,
1243}
1244
1245const EN: Words = Words {
1246    html_lang: "en",
1247    task: "Task",
1248    what_changed: "What changed",
1249    review_verdict: "Review verdict",
1250    reviewer: "Reviewer",
1251    reviewer_no_answer: "produced no answer",
1252    checks: "Checks",
1253    nothing_failing: "Nothing failing.",
1254    files_changed: "file(s) changed",
1255    commits: "Commits being squashed",
1256    no_commits: "No commit subjects could be read from the branch.",
1257    comments: "External PR review comments",
1258    no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1259    diff: "Diff",
1260    truncated: "Truncated",
1261    lands_as: "They land as one commit titled",
1262};
1263
1264const JA: Words = Words {
1265    html_lang: "ja",
1266    task: "タスク",
1267    what_changed: "変更内容",
1268    review_verdict: "レビューの結論",
1269    reviewer: "レビュアー",
1270    reviewer_no_answer: "回答なし",
1271    checks: "チェック",
1272    nothing_failing: "失敗しているものはありません。",
1273    files_changed: "ファイル変更",
1274    commits: "squash されるコミット",
1275    no_commits: "ブランチからコミット件名を読めませんでした。",
1276    comments: "外部 PR レビューコメント",
1277    no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1278    diff: "差分",
1279    truncated: "省略",
1280    lands_as: "これらは次の件名の1コミットとして入ります:",
1281};
1282
1283impl Words {
1284    /// The clause after the merge subject. Split out because word order moves:
1285    /// Japanese puts the subject before the verb, so a shared template with a
1286    /// hole in the middle would read as machine translation.
1287    fn lands_as_tail(&self) -> &'static str {
1288        if self.html_lang == "ja" {
1289            "。この件名も承認の対象です。"
1290        } else {
1291            ", which you are approving too."
1292        }
1293    }
1294
1295    /// The question's own one-line summary, which is what a phone shows first.
1296    fn approval_summary(&self, number: u64, subject: &str) -> String {
1297        if self.html_lang == "ja" {
1298            format!("プルリクエスト #{number} をマージ: {subject}")
1299        } else {
1300            format!("merge pull request #{number}: {subject}")
1301        }
1302    }
1303
1304    /// The body under the summary, above the panel.
1305    fn approval_detail(
1306        &self,
1307        url: &str,
1308        base: &str,
1309        subject: &str,
1310        contested: Option<&ContestedHandoff>,
1311    ) -> String {
1312        let body = if self.html_lang == "ja" {
1313            format!(
1314                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1315                 できる状態です。差分の要約・パッチ・squash されるコミットは\
1316                 下のパネルにあります。"
1317            )
1318        } else {
1319            format!(
1320                "{url} is green and ready to squash into `{base}` as `{subject}`. \
1321                 The panel holds the diffstat, the patch and the commits being squashed."
1322            )
1323        };
1324        match contested {
1325            Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1326            None => body,
1327        }
1328    }
1329
1330    /// Why this question exists although merge approvals are off: the open
1331    /// blocking findings and who rejected. Short enough for a phone.
1332    fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1333        const SHOWN: usize = 5;
1334        const TITLE_CHARS: usize = 100;
1335        let ja = self.html_lang == "ja";
1336        let mut out = if ja {
1337            format!(
1338                "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1339                 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1340            )
1341        } else {
1342            format!(
1343                "{url} is held for approval although merge approvals are off: the \
1344                 review ended with blocking findings still open and a reviewer \
1345                 voting reject.\n"
1346            )
1347        };
1348        for f in c.findings.iter().take(SHOWN) {
1349            let at = match (&f.file, f.line) {
1350                (Some(file), Some(line)) => format!("{file}:{line}"),
1351                (Some(file), None) => file.clone(),
1352                _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1353            };
1354            let title: String = f.title.chars().take(TITLE_CHARS).collect();
1355            let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1356        }
1357        if c.findings.len() > SHOWN {
1358            let more = c.findings.len() - SHOWN;
1359            let _ = writeln!(
1360                out,
1361                "{}",
1362                if ja {
1363                    format!("- ほか {more} 件")
1364                } else {
1365                    format!("- and {more} more")
1366                }
1367            );
1368        }
1369        let seats: Vec<String> = c
1370            .rejecters
1371            .iter()
1372            .map(|(seat, agent)| format!("#{seat} ({agent})"))
1373            .collect();
1374        let _ = write!(
1375            out,
1376            "{} {}",
1377            if ja {
1378                "却下したレビュアー:"
1379            } else {
1380                "Rejected by reviewer:"
1381            },
1382            seats.join(", ")
1383        );
1384        out
1385    }
1386
1387    /// The truncation note, written whole in each language for the same reason.
1388    fn truncated_note(
1389        &self,
1390        omitted: usize,
1391        total: usize,
1392        shown: usize,
1393        where_: &str,
1394        base: &str,
1395        head: &str,
1396    ) -> String {
1397        if self.html_lang == "ja" {
1398            format!(
1399                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1400                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1401                 プルリクエストにあります。"
1402            )
1403        } else {
1404            format!(
1405                "{omitted} of {total} diff lines omitted after the first {shown}. \
1406                 The whole patch is in <code>{where_}</code> \
1407                 (<code>git diff {base}...{head}</code>) and on the pull request."
1408            )
1409        }
1410    }
1411}
1412
1413/// Pick the panel's language. Codes and names both, because `[graph] language`
1414/// has always accepted either.
1415fn words(language: &str) -> &'static Words {
1416    if crate::lang::is_japanese(language) {
1417        &JA
1418    } else {
1419        &EN
1420    }
1421}
1422
1423/// The approval panel's html: what is about to land, and the evidence for it.
1424///
1425/// Pure, so the whole document is asserted in tests without `gh`, without a
1426/// network and without a repository. The caller gathers `diffstat`
1427/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
1428/// being squashed) and `subject` (what the squash will be called) from the
1429/// winner's worktree.
1430///
1431/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
1432/// content security policy blocks all three: anything of the sort here would be
1433/// dead markup that misleads the next reader into thinking it works.
1434pub fn approval_panel(
1435    state: &RunState,
1436    pr: &PrState,
1437    diffstat: &str,
1438    diff: &str,
1439    commits: &[String],
1440    subject: &str,
1441) -> String {
1442    let rows = parse_numstat(diffstat);
1443    let w = words(&state.config.graph.language);
1444    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1445
1446    let _ = writeln!(
1447        h,
1448        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1449         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1450        w.html_lang
1451    );
1452    let _ = writeln!(
1453        h,
1454        "<title>merge #{} — {}</title>\n</head>",
1455        pr.number,
1456        esc(subject)
1457    );
1458    h.push_str(
1459        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1460         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1461         word-break:break-word\">\n",
1462    );
1463
1464    // The decision, in the words the operator is approving.
1465    let _ = writeln!(
1466        h,
1467        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1468         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1469         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1470         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1471         <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1472        pr.number,
1473        esc(&state.base_branch),
1474        esc(subject),
1475        esc(&state.id),
1476        esc(&pr.url),
1477        esc(&pr.url),
1478    );
1479
1480    let _ = writeln!(
1481        h,
1482        "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1483        esc(&approval_evidence(state))
1484    );
1485
1486    // The task, verbatim: the operator's own words for what was asked, so the
1487    // panel does not make them reconstruct the request from a diffstat.
1488    let _ = writeln!(
1489        h,
1490        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1491         <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1492        w.task,
1493        esc(&state.instruction)
1494    );
1495
1496    // The winner's own account of what it did and why, when there is one.
1497    if let Some(summary) = state
1498        .winner()
1499        .map(|c| c.summary.as_str())
1500        .filter(|s| !s.is_empty())
1501    {
1502        let _ = writeln!(
1503            h,
1504            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1505             <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1506            w.what_changed,
1507            esc(summary)
1508        );
1509    }
1510
1511    // Only the last round's reviewer prose is current. Its votes and
1512    // remaining findings are shown explicitly in the evidence above.
1513    if let Some(round) = state.reviews.last() {
1514        let _ = writeln!(
1515            h,
1516            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1517            w.review_verdict
1518        );
1519        for r in &round.reviews {
1520            // A seat the review loop counted as answered has real prose in
1521            // `summary`; one it counted against `incomplete` (see
1522            // `graph::Runner::review_loop`) never produced any and left it
1523            // empty - which must not be read back as a blank verdict, since
1524            // an empty box here looks like "nothing to say" rather than
1525            // "never answered".
1526            let body = match &r.failed {
1527                Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1528                None => esc(&r.summary),
1529            };
1530            let _ = writeln!(
1531                h,
1532                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1533                 border-radius:6px\">\
1534                 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1535                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1536                w.reviewer,
1537                r.reviewer,
1538                esc(&r.agent),
1539                body,
1540            );
1541        }
1542    }
1543
1544    let _ = writeln!(
1545        h,
1546        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1547        w.checks,
1548        esc(pr.checks.as_str())
1549    );
1550    if pr.failing.is_empty() {
1551        let _ = writeln!(
1552            h,
1553            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1554            w.nothing_failing
1555        );
1556    } else {
1557        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1558        for f in &pr.failing {
1559            let _ = writeln!(h, "<li>{}</li>", esc(f));
1560        }
1561        h.push_str("</ul>\n");
1562    }
1563
1564    // Diffstat as a real table, so a phone reads what moved without scrolling
1565    // sideways through a terminal bar chart.
1566    let _ = writeln!(
1567        h,
1568        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1569        rows.len(),
1570        w.files_changed
1571    );
1572    h.push_str(
1573        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1574         <thead><tr>\
1575         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1576         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1577         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1578         </th></tr></thead>\n<tbody>\n",
1579    );
1580    let mut total_added = 0u64;
1581    let mut total_removed = 0u64;
1582    for r in &rows {
1583        total_added += r.added.unwrap_or(0);
1584        total_removed += r.removed.unwrap_or(0);
1585        let cell = |n: Option<u64>| match n {
1586            Some(n) => n.to_string(),
1587            None => "bin".to_owned(),
1588        };
1589        let _ = writeln!(
1590            h,
1591            "<tr>\
1592             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1593             font-family:ui-monospace,monospace\">{}</td>\
1594             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1595             color:#0a3622\">{}</td>\
1596             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1597             color:#5c1a17\">{}</td></tr>",
1598            esc(&r.path),
1599            cell(r.added),
1600            cell(r.removed),
1601        );
1602    }
1603    let _ = writeln!(
1604        h,
1605        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1606         <td style=\"padding:4px 2px\">total</td>\
1607         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1608         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1609         </tr></tfoot>\n</table>"
1610    );
1611
1612    // The commits being squashed, and the subject that replaces them.
1613    let _ = writeln!(
1614        h,
1615        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1616        w.commits
1617    );
1618    if commits.is_empty() {
1619        h.push_str(&format!(
1620            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1621            w.no_commits
1622        ));
1623    } else {
1624        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1625        for c in commits {
1626            let _ = writeln!(h, "<li>{}</li>", esc(c));
1627        }
1628        h.push_str("</ol>\n");
1629    }
1630    let _ = writeln!(
1631        h,
1632        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1633        w.lands_as,
1634        esc(subject),
1635        w.lands_as_tail()
1636    );
1637
1638    // The review comments that shaped this branch, and who asked for them.
1639    let _ = writeln!(
1640        h,
1641        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1642        w.comments
1643    );
1644    if pr.review_comments.is_empty() {
1645        h.push_str(&format!(
1646            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1647            w.no_comments
1648        ));
1649    } else {
1650        for c in &pr.review_comments {
1651            let anchor = match (&c.path, c.line) {
1652                (Some(p), Some(l)) => format!("{p}:{l}"),
1653                (Some(p), None) => p.clone(),
1654                _ => "pull request thread".to_owned(),
1655            };
1656            let _ = writeln!(
1657                h,
1658                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1659                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1660                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1661                esc(&c.author),
1662                esc(&anchor),
1663                esc(&tail(&c.body, 800)),
1664            );
1665        }
1666    }
1667
1668    // The patch itself.
1669    let total = diff.lines().count();
1670    let shown = total.min(DIFF_MAX_LINES);
1671    let _ = writeln!(
1672        h,
1673        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1674        w.diff
1675    );
1676    h.push_str(
1677        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1678         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1679    );
1680    for line in diff.lines().take(shown) {
1681        let (gutter, style, body) = diff_row(line);
1682        let _ = writeln!(
1683            h,
1684            "<div style=\"display:flex;{style}\">\
1685             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1686             border-right:1px solid #d0d7de\">{gutter}</span>\
1687             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1688            esc(body),
1689        );
1690    }
1691    h.push_str("</div>\n");
1692    if total > shown {
1693        let omitted = total - shown;
1694        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1695        let where_ = state.winner().map_or_else(
1696            || state.repo.display().to_string(),
1697            |w| w.worktree.display().to_string(),
1698        );
1699        let _ = writeln!(
1700            h,
1701            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1702             font-size:13px\">{}: {}</p>",
1703            w.truncated,
1704            w.truncated_note(
1705                omitted,
1706                total,
1707                shown,
1708                &esc(&where_),
1709                &esc(&state.base_branch),
1710                &esc(head),
1711            ),
1712        );
1713    }
1714
1715    h.push_str("</body>\n</html>\n");
1716    h
1717}
1718
1719/// Historical contested hand-off evidence for the question detail. Approval
1720/// itself is decided by `approval_reasons`, including reject-only cases.
1721fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1722    if state.config.graph.hold_contested_merge {
1723        state.contested_handoff.clone()
1724    } else {
1725        None
1726    }
1727}
1728
1729/// What a merge-approval question's deputy is told: the pull request, the run,
1730/// what each answer does, and the evidence saved when the question was filed.
1731///
1732/// A snapshot taken when the deputy is attached, so it says so and points at
1733/// `magi show` / `gh pr view` for anything current. `state` is `None` for a run
1734/// that cannot be read; what is missing is named as missing, and no past
1735/// approval basis is rebuilt from today's state.
1736pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1737    let mut s = format!(
1738        "This is the merge approval for run {run} (`magi show {run}`). The question's \
1739         own text above names the pull request. Answering `{APPROVE}` squash-merges \
1740         it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1741         request open. Silence is a hold: the owner not answering never merges. Only \
1742         the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1743         own words, merges. Whether their wording is a clear, unconditional instruction \
1744         is your judgement alone: if it is doubtful, conditional, retracted or a \
1745         question, do not settle - ask back with `magi ask --thread`. Doubt and \
1746         silence are a hold.\n\n\
1747         This brief is a snapshot from when you were attached: check `magi show {run}` \
1748         and `gh pr view` (read-only) before telling the owner anything current. \
1749         You run with permission to write the question record, and what keeps you \
1750         from touching anything else is this brief and your instructions - so do \
1751         not change files, branches or the pull request.",
1752        run = q.run
1753    );
1754    if let Some(target) = &q.merge_target {
1755        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));
1756    }
1757    if q.choices.iter().any(|c| c == REPAIR) {
1758        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.");
1759    }
1760    if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1761    {
1762        s.push_str("\n\nSaved approval evidence (at question creation):\n");
1763        s.push_str(&q.detail);
1764    } else {
1765        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.");
1766    }
1767    let Some(state) = state else {
1768        s.push_str(
1769            "\n\nThe run's record could not be read, so the pull request, the panel \
1770             summary and any contested findings are not known to you beyond the \
1771             question's own text. Say so to the owner rather than guessing.",
1772        );
1773        return s;
1774    };
1775    if let Some(pr) = &state.pr {
1776        s.push_str(&format!(
1777            "\n\nPull request #{} {} (recorded state: {}, last seen).",
1778            pr.number, pr.url, pr.state
1779        ));
1780    }
1781    s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1782    if let Some(w) = state.winner() {
1783        s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1784    }
1785
1786    s
1787}
1788
1789// Releasing a task permits work to resume; it does not renew merge consent.
1790// Check the persisted operator decision both when reusing a card and at the
1791// final merge boundary, since a hold/release can arrive between those reads.
1792fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1793    if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1794        return Ok(false);
1795    }
1796    let Some(task_id) = state
1797        .origin
1798        .as_ref()
1799        .and_then(|origin| origin.task.as_ref())
1800    else {
1801        return Ok(false);
1802    };
1803    let task = crate::queue::Queue::open()
1804        .get(task_id)
1805        .context("cannot verify operator decisions for the merge approval")?;
1806    Ok(task
1807        .operator_decided_at
1808        .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1809}
1810
1811/// Ask the owner before merging, with the whole case attached as a panel.
1812///
1813/// The evidence is gathered from the winner's own worktree with the `git` CLI,
1814/// never from the network, so a phone on a slow link gets the diff magi is
1815/// looking at rather than a link it has to go and open.
1816///
1817/// Never blocks. `land` used to sit inside [`ask::ask_and_wait`]'s poll loop
1818/// for up to a day right here, which held the whole run's task claim - and
1819/// the daemon's one slot with it - for exactly as long as the owner took to
1820/// notice their phone. [`ApprovalGate::Pending`] is the answer that lets the
1821/// caller park the run and hand the slot back instead: the question is on
1822/// disk either way, so nothing about the wait itself changes, only who is
1823/// blocked on it.
1824///
1825/// Idempotent across resumes: called again for a run already waiting on its
1826/// own question, this finds that question by [`crate::ask::Questions::list`]
1827/// rather than filing a second one - asking twice would double the
1828/// notification for one decision, and leave the first question's panel an
1829/// orphan nobody's answer ever reaches.
1830async fn approval_gate(
1831    state: &mut RunState,
1832    pr: &PrState,
1833    subject: &str,
1834    contested: Option<&ContestedHandoff>,
1835    head: &str,
1836) -> Result<ApprovalGate> {
1837    let store = ask::Questions::open();
1838    // An answer belongs to the commit its panel showed. A question recorded
1839    // for another head - or none recorded at all, as for a question filed
1840    // before heads were tracked - is never reused: a fix or rebase push made
1841    // a commit the owner has not seen, and their word does not carry over.
1842    let reusable = state.land_approval.as_ref().and_then(|a| {
1843        store
1844            .list()
1845            .into_iter()
1846            .find(|q| q.id == a.question)
1847            .filter(|q| {
1848                a.head.eq_ignore_ascii_case(head)
1849                    || q.resolution()
1850                        .as_deref()
1851                        .is_some_and(crate::merge_decision::stops)
1852            })
1853    });
1854    let reusable = match reusable {
1855        Some(q) if merge_consent_superseded(state, &q)? => None,
1856        other => other,
1857    };
1858    if reusable.is_none() {
1859        for stale in store
1860            .list()
1861            .into_iter()
1862            .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1863        {
1864            let why =
1865                "the pull request head or operator decision changed; asked again for approval";
1866            if let Err(e) = store.update(&stale.id, |q| {
1867                q.abandon(why);
1868                Ok(())
1869            }) {
1870                tracing::warn!("could not retire the superseded approval question: {e:#}");
1871            }
1872        }
1873    }
1874
1875    let q = match reusable {
1876        Some(mut q) => {
1877            if upgrade_repair_choice(&mut q, state) {
1878                q = store
1879                    .update(&q.id, |current| {
1880                        upgrade_repair_choice(current, state);
1881                        Ok(())
1882                    })?
1883                    .0;
1884            }
1885            q
1886        }
1887        None => {
1888            let worktree = match state.winner() {
1889                Some(w) => w.worktree.clone(),
1890                None => state.repo.clone(),
1891            };
1892            // The diff is built from the commit being approved, not from the
1893            // branch name, which may already point somewhere else.
1894            let head = if head.is_empty() {
1895                state
1896                    .winner()
1897                    .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1898            } else {
1899                head.to_owned()
1900            };
1901            // The diff is against what the remote's base holds, never the
1902            // local branch of that name; unreadable means less evidence.
1903            let remote = &state.config.merge.remote;
1904            let tracking = format!("{remote}/{}", state.base_branch);
1905            let base = if git::rev_exists(&worktree, &tracking).await {
1906                tracking
1907            } else {
1908                String::new()
1909            };
1910            let range = format!("{base}...{head}");
1911            // A failed `git` must not decide the merge: the panel degrades to
1912            // less evidence and the owner still chooses. Merging because the
1913            // diff could not be read would be the worst of both.
1914            let numstat = if base.is_empty() {
1915                String::new()
1916            } else {
1917                git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1918                    .await
1919                    .map(|o| o.stdout)
1920                    .unwrap_or_default()
1921            };
1922            let diff = if base.is_empty() {
1923                String::new()
1924            } else {
1925                git::diff(&worktree, &base, &head).await.unwrap_or_default()
1926            };
1927            let commits: Vec<String> = if base.is_empty() {
1928                Vec::new()
1929            } else {
1930                git::git_raw(
1931                    &worktree,
1932                    &[
1933                        "log",
1934                        "--reverse",
1935                        "--format=%s",
1936                        &format!("{base}..{head}"),
1937                    ],
1938                )
1939                .await
1940                .map(|o| o.stdout)
1941                .unwrap_or_default()
1942                .lines()
1943                .filter(|l| !l.trim().is_empty())
1944                .map(str::to_owned)
1945                .collect()
1946            };
1947
1948            let w = words(&state.config.graph.language);
1949            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1950            let mut fresh = ask::Question::new(
1951                state.id.clone(),
1952                APPROVAL_NODE.to_owned(),
1953                "land".to_owned(),
1954                w.approval_summary(pr.number, subject),
1955                format!(
1956                    "{}\n\n{}",
1957                    approval_evidence(state),
1958                    w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1959                ),
1960                approval_choices(state),
1961            );
1962            fresh.merge_target = Some(crate::merge_decision::Target {
1963                pr: pr.url.clone(),
1964                head: head.clone(),
1965                language: state.config.graph.language.clone(),
1966            });
1967            if fresh.choices.iter().any(|c| c == REPAIR) {
1968                fresh.actions.insert(
1969                    REPAIR.to_owned(),
1970                    ask::ChoiceAction::Resume {
1971                        run: state.id.clone(),
1972                    },
1973                );
1974                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.");
1975            }
1976            for choice in fresh.choices.clone() {
1977                if crate::merge_decision::stops(&choice)
1978                    && choice != HOLD
1979                    && choice != crate::merge_decision::DISMISS
1980                {
1981                    fresh.actions.insert(
1982                        choice,
1983                        ask::ChoiceAction::Resume {
1984                            run: state.id.clone(),
1985                        },
1986                    );
1987                }
1988            }
1989            store
1990                .put_panel(&mut fresh, &html, &[])
1991                .context("write the merge approval panel")?;
1992            store
1993                .put_executable(&mut fresh)
1994                .context("file the merge approval question")?;
1995            state.land_approval = Some(LandApproval {
1996                question: fresh.id.clone(),
1997                head: head.clone(),
1998            });
1999            state.event(
2000                "land",
2001                format!("asking for merge approval ({})", fresh.short()),
2002            );
2003            state.save()?;
2004            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
2005                // A broken webhook is not a reason to lose the merge: the
2006                // question is already on disk and the web UI already shows
2007                // it, so the operator still has a way in.
2008                tracing::warn!(
2009                    "could not notify about merge approval question {}: {e:#} - \
2010                     the web UI is the only surface for it now",
2011                    fresh.short()
2012                );
2013            }
2014            fresh
2015        }
2016    };
2017
2018    Ok(match q.status {
2019        ask::QuestionStatus::Open => ApprovalGate::Pending,
2020        // Nobody answered before `state.config.graph.answer_timeout` passed,
2021        // or the question was closed with no decision recorded underneath
2022        // this run - either way there is nothing left to wait on.
2023        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
2024        // The merge gate does not speak `--thread`: an owner who talked back
2025        // instead of choosing never reaches `Answered`, so this arm only
2026        // ever sees an actual decision.
2027        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
2028            Approval::Merge => ApprovalGate::Approved,
2029            Approval::Repair if !state.reviews.is_empty() => {
2030                if !state.merge_repairs.iter().any(|r| r.question == q.id) {
2031                    state.merge_repairs.push(crate::run::MergeRepair {
2032                        question: q.id.clone(),
2033                        head: head.to_owned(),
2034                        pr: pr.url.clone(),
2035                        reviews: state.reviews.clone(),
2036                        gate: state.gate.clone(),
2037                        gate_head: state.gate_head.clone(),
2038                        gate_fixes: state.gate_fixes.clone(),
2039                        pre_gate: state.pre_gate.clone(),
2040                        phase: crate::run::MergeRepairPhase::Requested,
2041                        failure: None,
2042                    });
2043                    state.land_approval = None;
2044                    state.event(
2045                        "repair",
2046                        "owner requested fix findings and re-review; merge is not approved",
2047                    );
2048                    state.save()?;
2049                }
2050                ApprovalGate::Repair
2051            }
2052            Approval::Repair | Approval::Hold => ApprovalGate::Held,
2053        },
2054    })
2055}
2056
2057/// Fold a rollup's entries into one verdict plus the failing checks' labels.
2058/// No I/O. An empty rollup is `Unknown`, never green.
2059fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
2060    let mut failing = Vec::new();
2061    let mut pending = false;
2062    let mut unknown = false;
2063    for check in rollup {
2064        match check.verdict() {
2065            Verdict::Pass => {}
2066            Verdict::Pending => pending = true,
2067            Verdict::Fail => failing.push(check.label()),
2068            Verdict::Unknown => unknown = true,
2069        }
2070    }
2071    let checks = if rollup.is_empty() {
2072        Checks::Unknown
2073    } else if pending {
2074        Checks::Pending
2075    } else if !failing.is_empty() {
2076        Checks::Red
2077    } else if unknown {
2078        Checks::Unknown
2079    } else {
2080        Checks::Green
2081    };
2082    (checks, failing)
2083}
2084
2085/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
2086/// output into a [`PrState`]. No I/O.
2087pub fn parse_pr(json: &str) -> Result<PrState> {
2088    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2089    let state = match raw.state.to_ascii_uppercase().as_str() {
2090        "OPEN" => PrLifecycle::Open,
2091        "MERGED" => PrLifecycle::Merged,
2092        "CLOSED" => PrLifecycle::Closed,
2093        other => bail!("unknown pull request state `{other}`"),
2094    };
2095
2096    let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2097
2098    let mut review_comments = Vec::new();
2099    for r in raw.reviews {
2100        push_if_outstanding(
2101            &mut review_comments,
2102            ReviewComment {
2103                author: r.author.login,
2104                path: None,
2105                line: None,
2106                body: r.body,
2107            },
2108        );
2109    }
2110    for c in raw.comments {
2111        push_if_outstanding(
2112            &mut review_comments,
2113            ReviewComment {
2114                author: c.author.login,
2115                path: None,
2116                line: None,
2117                body: c.body,
2118            },
2119        );
2120    }
2121
2122    Ok(PrState {
2123        url: raw.url,
2124        number: raw.number,
2125        state,
2126        checks,
2127        failing,
2128        review_comments,
2129        blocking: Blocking::of(&raw.merge_state_status),
2130    })
2131}
2132
2133/// One rollup entry as the release watcher reads it.
2134#[derive(Debug, Clone, PartialEq, Eq)]
2135pub(crate) struct CheckView {
2136    pub name: String,
2137    pub verdict: Verdict,
2138    /// Workflow run behind the check, when its url names one.
2139    pub run: Option<String>,
2140    pub url: Option<String>,
2141}
2142
2143/// A pull request's lifecycle, head commit and per-check verdicts, without
2144/// [`rollup_verdict`]'s aggregation (a pending check anywhere hides a failure
2145/// from it, which is exactly what the release watcher must not inherit).
2146#[derive(Debug, Clone, PartialEq, Eq)]
2147pub(crate) struct RollupView {
2148    pub url: String,
2149    pub number: u64,
2150    pub state: PrLifecycle,
2151    pub head: String,
2152    pub checks: Vec<CheckView>,
2153}
2154
2155/// Parse `gh pr view --json url,number,state,headRefOid,statusCheckRollup`
2156/// output. No I/O.
2157pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2158    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2159    let state = match raw.state.to_ascii_uppercase().as_str() {
2160        "OPEN" => PrLifecycle::Open,
2161        "MERGED" => PrLifecycle::Merged,
2162        "CLOSED" => PrLifecycle::Closed,
2163        other => bail!("unknown pull request state `{other}`"),
2164    };
2165    let checks = raw
2166        .status_check_rollup
2167        .iter()
2168        .map(|c| CheckView {
2169            name: c.label(),
2170            verdict: c.verdict(),
2171            run: c.url().and_then(run_of),
2172            url: c.url().map(str::to_owned),
2173        })
2174        .collect();
2175    Ok(RollupView {
2176        url: raw.url,
2177        number: raw.number,
2178        state,
2179        head: raw.head_ref_oid,
2180        checks,
2181    })
2182}
2183
2184/// Read just a pull request's lifecycle state - open, merged, or closed -
2185/// with none of the checks/reviews/comments [`land`] itself needs to decide
2186/// what to do next.
2187///
2188/// For a caller that only ever wants one fact and must not risk anything
2189/// else: `magi fold --merged` uses this to confirm a URL the operator hands
2190/// it is actually a merged pull request *before* touching a run's state, so a
2191/// typo or a still-open PR fails loudly instead of quietly recording a merge
2192/// that never happened.
2193pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2194    let view = gh(
2195        repo,
2196        &[
2197            "pr".to_owned(),
2198            "view".to_owned(),
2199            pr_url.to_owned(),
2200            "--json".to_owned(),
2201            "state".to_owned(),
2202        ],
2203    )
2204    .await?;
2205    if !view.0 {
2206        bail!("gh pr view {pr_url}: {}", view.1);
2207    }
2208    // `parse_pr` reads every other field of `GhPr` as its serde default
2209    // (empty string, empty vec, zero) when this narrower `--json` selection
2210    // does not carry them - harmless, since only `.state` is read back.
2211    Ok(parse_pr(&view.1)?.state)
2212}
2213
2214/// A pull request the operator merged outside of `land::land`'s own loop,
2215/// found by asking GitHub about the run's own winning branch rather than
2216/// requiring the operator to go and find the URL themselves.
2217#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2218pub struct ExternalMerge {
2219    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
2220    pub url: String,
2221    /// The pull request's number.
2222    pub number: u64,
2223}
2224
2225#[derive(Debug, Deserialize)]
2226#[serde(rename_all = "camelCase")]
2227struct GhMergedPr {
2228    url: String,
2229    number: u64,
2230    merged_at: String,
2231    base_ref_name: String,
2232}
2233
2234/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
2235/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
2236/// decide whether exactly one of the pull requests it lists could actually
2237/// be *this* run's.
2238///
2239/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
2240/// from the run's own short id, so a collision with some other, unrelated
2241/// task's merged pull request from a same-named branch is rare but not
2242/// impossible once branches are deleted and ids run out. Filtering on
2243/// `base_ref_name` (the branch this run actually targets) and `merged_at`
2244/// (which cannot predate the run itself) rules that case out. More than one
2245/// survivor is exactly as uninformative as zero — something this run cannot
2246/// tell apart from another — so only a unique survivor is returned.
2247fn pick_merged_pr(
2248    json: &str,
2249    base_branch: &str,
2250    created_at: Timestamp,
2251) -> Result<Option<ExternalMerge>> {
2252    let raw: Vec<GhMergedPr> =
2253        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2254    let mut matches: Vec<ExternalMerge> = Vec::new();
2255    for pr in raw {
2256        if pr.base_ref_name != base_branch {
2257            continue;
2258        }
2259        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2260            continue;
2261        };
2262        if merged_at < created_at {
2263            continue;
2264        }
2265        matches.push(ExternalMerge {
2266            url: pr.url,
2267            number: pr.number,
2268        });
2269    }
2270    if matches.len() == 1 {
2271        Ok(matches.pop())
2272    } else {
2273        Ok(None)
2274    }
2275}
2276
2277/// What `gh pr list --head <branch> --base <base> --state open` found.
2278#[derive(Debug, Clone, PartialEq, Eq)]
2279pub enum OpenPr {
2280    /// Nothing open: the caller creates one.
2281    None,
2282    /// Exactly one: the caller adopts it instead of creating a second.
2283    One {
2284        /// The pull request's URL.
2285        url: String,
2286        /// Its current title.
2287        title: String,
2288    },
2289    /// More than one: magi does not pick between them.
2290    Many(Vec<String>),
2291}
2292
2293#[derive(Debug, Deserialize)]
2294#[serde(rename_all = "camelCase")]
2295struct GhOpenPr {
2296    // `url` and `baseRefName` are required: a record missing either must be a
2297    // parse error, not a pull request that silently fails the base filter and
2298    // reads as "none open" (which would go on to create a duplicate).
2299    url: String,
2300    #[serde(default)]
2301    title: String,
2302    base_ref_name: String,
2303    // A fork's branch of the same name is not the task head; `gh pr list --head`
2304    // matches by branch name alone, so the filter has to be ours.
2305    #[serde(default)]
2306    is_cross_repository: bool,
2307}
2308
2309/// Pure half of [`find_open_pr`]: classify the raw `--json
2310/// number,url,title,baseRefName` output. Entries whose base is not `base` are
2311/// dropped even though the query already filtered on it, so a stub or an old
2312/// `gh` that ignores `--base` cannot get a pull request into the wrong branch
2313/// adopted.
2314pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2315    let raw: Vec<GhOpenPr> =
2316        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2317    let mut hits: Vec<GhOpenPr> = raw
2318        .into_iter()
2319        .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2320        .collect();
2321    Ok(match hits.len() {
2322        0 => OpenPr::None,
2323        1 => {
2324            let p = hits.remove(0);
2325            OpenPr::One {
2326                url: p.url,
2327                title: p.title,
2328            }
2329        }
2330        _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2331    })
2332}
2333
2334/// Open pull requests whose head is `branch` and whose base is `base`. A
2335/// failing `gh` is an error carrying its own output, never "none": guessing
2336/// there is how a duplicate gets created.
2337pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2338    let (ok, out) = gh(
2339        repo,
2340        &[
2341            "pr".to_owned(),
2342            "list".to_owned(),
2343            "--head".to_owned(),
2344            branch.to_owned(),
2345            "--base".to_owned(),
2346            base.to_owned(),
2347            "--state".to_owned(),
2348            "open".to_owned(),
2349            "--json".to_owned(),
2350            "number,url,title,baseRefName,isCrossRepository".to_owned(),
2351        ],
2352    )
2353    .await?;
2354    if !ok {
2355        bail!("gh pr list failed: {out}");
2356    }
2357    pick_open_pr(&out, base)
2358}
2359
2360#[derive(Debug, Deserialize)]
2361#[serde(rename_all = "camelCase")]
2362struct GhPrHead {
2363    head_ref_name: String,
2364    base_ref_name: String,
2365    state: String,
2366    // Required, like `GhOpenPr`'s fields: a record that cannot say whether the
2367    // head lives in a fork must be a parse error, never "same repository".
2368    is_cross_repository: bool,
2369    // Required too: it is what ties the pull request to the commits that were
2370    // actually proven to be on the base.
2371    head_ref_oid: String,
2372}
2373
2374/// Why [`closable`] said no, and whether asking again later could say yes.
2375#[derive(Debug, Clone, PartialEq, Eq)]
2376pub struct Refusal {
2377    /// A later attempt may succeed (the head moved, the view was unreadable);
2378    /// `false` means this pull request is simply not the run's to close.
2379    pub retry: bool,
2380    /// What was wrong.
2381    pub why: String,
2382}
2383
2384impl Refusal {
2385    fn final_(why: String) -> Self {
2386        Self { retry: false, why }
2387    }
2388}
2389
2390/// Pure half of [`close_superseded_pr`]: read `gh pr view --json
2391/// headRefName,baseRefName,state,isCrossRepository` and say whether closing
2392/// is safe, `Err` carrying the reason when it is not.
2393///
2394/// Closing is outward-facing, so every property is checked on what the forge
2395/// says now, not on what magi recorded: the head must be exactly `branch` in
2396/// this repository (a fork's branch of the same name is somebody else's), the
2397/// base must be `base`, and the pull request must still be open.
2398pub fn closable(
2399    json: &str,
2400    branch: &str,
2401    base: &str,
2402    verified: &[String],
2403) -> std::result::Result<(), Refusal> {
2404    let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2405        retry: true,
2406        why: format!("could not read the pull request ({e})"),
2407    })?;
2408    if pr.head_ref_name != branch {
2409        return Err(Refusal::final_(format!(
2410            "its head is `{}`, not this run's `{branch}`",
2411            pr.head_ref_name
2412        )));
2413    }
2414    if pr.is_cross_repository {
2415        return Err(Refusal::final_("its head lives in a fork".to_owned()));
2416    }
2417    if pr.base_ref_name != base {
2418        return Err(Refusal::final_(format!(
2419            "it targets `{}`, not `{base}`",
2420            pr.base_ref_name
2421        )));
2422    }
2423    if !pr.state.eq_ignore_ascii_case("open") {
2424        return Err(Refusal::final_(format!(
2425            "it is already {}",
2426            pr.state.to_ascii_lowercase()
2427        )));
2428    }
2429    // Last, so a pull request that is not this run's at all is reported as
2430    // such. A head that is this branch but not a commit checked against the
2431    // base (somebody pushed since) may well get checked next time: retry.
2432    if !verified.contains(&pr.head_ref_oid) {
2433        return Err(Refusal {
2434            retry: true,
2435            why: format!(
2436                "its head {} is not a commit this run checked against the base",
2437                crate::already::short_sha(&pr.head_ref_oid)
2438            ),
2439        });
2440    }
2441    Ok(())
2442}
2443
2444/// Does `remote` point at something a forge could host - a URL or an scp-style
2445/// `user@host:path` - rather than a filesystem path or nothing at all? Judged
2446/// from the URL alone, so it answers the same on every machine, whatever `gh`
2447/// happens to be installed or logged in to.
2448async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2449    let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2450        return false;
2451    };
2452    is_forge_url(url.trim())
2453}
2454
2455fn is_forge_url(url: &str) -> bool {
2456    url.contains("://") && !url.starts_with("file://")
2457        || url
2458            .split_once(':')
2459            .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2460}
2461
2462/// Does this `gh` failure mean there is no GitHub to ask, as opposed to a
2463/// request that failed?
2464fn forge_unavailable(message: &str) -> bool {
2465    message.contains("known GitHub host") || message.contains("spawn gh")
2466}
2467
2468/// The comment left on a pull request closed because its change is already on
2469/// the base.
2470pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2471    let how = match evidence.proof {
2472        crate::already::Proof::PatchId => format!(
2473            "carried by commit {} on `{base}` with the same patch",
2474            evidence.names()
2475        ),
2476        crate::already::Proof::Ancestry => {
2477            format!("already in the history of `{base}` as {}", evidence.names())
2478        }
2479        crate::already::Proof::Tree => format!(
2480            "already part of `{base}` (merging this branch changes nothing at {})",
2481            crate::already::short_sha(&evidence.tip)
2482        ),
2483    };
2484    format!(
2485        "Closing: everything this branch adds is {how}, so there is nothing left to \
2486         land. This pull request was closed automatically after that was verified; \
2487         reopen it if you disagree."
2488    )
2489}
2490
2491/// Close the open pull request for `branch`, if there is one and it is
2492/// provably this run's, with a comment naming what supersedes it. `Ok(Ok(url))` is
2493/// the URL closed; `Ok(Err(why))` is a final "nothing to close" (no pull request,
2494/// not this run's, no forge); `Err` is anything a later attempt could resolve (a
2495/// failed `gh` call, a head that moved since it was checked), which the caller
2496/// must not treat as settled.
2497///
2498/// The recorded `state.pr` is preferred, else the forge is asked for an open
2499/// pull request on `branch`; either way the candidate is re-read and passed
2500/// through [`closable`] before anything is written; `verified` lists the
2501/// commits proven to be on the base, and the pull request's head must be one.
2502pub async fn close_superseded_pr(
2503    state: &mut RunState,
2504    branch: &str,
2505    evidence: &crate::already::Evidence,
2506    verified: &[String],
2507) -> Result<std::result::Result<String, String>> {
2508    let repo = state.repo.clone();
2509    let base = state.base_branch.clone();
2510    let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2511        Some(p) => p.url.clone(),
2512        // No recorded pull request. A forge that cannot be asked at all (no
2513        // GitHub remote, no `gh`) says nothing about this run, so that is
2514        // "none found"; any other lookup failure is an error, because "could
2515        // not look" is not "nothing there" and the caller must retry.
2516        None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2517            return Ok(Err(
2518                "the remote is not a forge, so there is no pull request".to_owned(),
2519            ));
2520        }
2521        None => match find_open_pr(&repo, branch, &base).await {
2522            Err(e) if forge_unavailable(&format!("{e:#}")) => {
2523                return Ok(Err(format!("no forge to ask: {e:#}")));
2524            }
2525            Err(e) => return Err(e),
2526            Ok(OpenPr::One { url, .. }) => url,
2527            Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2528            Ok(OpenPr::Many(urls)) => {
2529                return Ok(Err(format!(
2530                    "{} open pull requests name it; not choosing between them",
2531                    urls.len()
2532                )));
2533            }
2534        },
2535    };
2536    let (ok, view) = gh(
2537        &repo,
2538        &[
2539            "pr".to_owned(),
2540            "view".to_owned(),
2541            url.clone(),
2542            "--json".to_owned(),
2543            "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2544        ],
2545    )
2546    .await?;
2547    if !ok {
2548        bail!("gh pr view {url} failed: {view}");
2549    }
2550    if let Err(refusal) = closable(&view, branch, &base, verified) {
2551        // A pull request whose head cannot be tied to what was proven is not
2552        // one to walk away from: the caller keeps the run resumable.
2553        if refusal.retry {
2554            bail!("left {url} open: {}", refusal.why);
2555        }
2556        return Ok(Err(format!("left {url} open: {}", refusal.why)));
2557    }
2558    let (ok, out) = gh(
2559        &repo,
2560        &[
2561            "pr".to_owned(),
2562            "close".to_owned(),
2563            url.clone(),
2564            "--comment".to_owned(),
2565            superseded_comment(&base, evidence),
2566        ],
2567    )
2568    .await?;
2569    if !ok {
2570        bail!("gh pr close {url} failed: {out}");
2571    }
2572    if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2573        p.state = "closed".to_owned();
2574    }
2575    Ok(Ok(url))
2576}
2577
2578/// `gh pr edit <url> --title <title>`, for an adopted pull request whose title
2579/// differs from the one this run computed. Only the title: the body may have
2580/// been edited by the owner and cannot be compared.
2581pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2582    let (title, _) = crate::github_text::prepare(state, title, "");
2583    edit_pr_title(repo, url, &title).await
2584}
2585
2586/// [`set_pr_title`] for a title the owner explicitly chose to post unmodified
2587/// (`github_text::POST_ORIGINAL` or `github_text::USE_REWRITE`): the posting
2588/// gate is skipped on purpose.
2589pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2590    edit_pr_title(repo, url, title).await
2591}
2592
2593/// Post both fields of the owner's chosen rewrite to an adopted pull request,
2594/// unmodified and without the posting gate.
2595pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2596    let (ok, _) = gh(
2597        repo,
2598        &[
2599            "pr".to_owned(),
2600            "edit".to_owned(),
2601            url.to_owned(),
2602            "--title".to_owned(),
2603            title.to_owned(),
2604            "--body".to_owned(),
2605            body.to_owned(),
2606        ],
2607    )
2608    .await
2609    .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2610    if !ok {
2611        // Command output may echo the chosen text; keep it out of run events.
2612        bail!("gh pr edit failed while posting the chosen rewrite");
2613    }
2614    Ok(())
2615}
2616
2617async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2618    let (ok, out) = gh(
2619        repo,
2620        &[
2621            "pr".to_owned(),
2622            "edit".to_owned(),
2623            url.to_owned(),
2624            "--title".to_owned(),
2625            title.to_owned(),
2626        ],
2627    )
2628    .await?;
2629    if !ok {
2630        bail!("gh pr edit failed: {out}");
2631    }
2632    Ok(())
2633}
2634
2635/// Ask GitHub whether this run's winning candidate branch was actually merged
2636/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2637/// --merged` exists to close, minus the operator having to find the URL by
2638/// hand.
2639///
2640/// `Ok(None)` covers every case where nothing can be said with confidence: no
2641/// winner decided yet (nothing to check a branch for), no merged pull request
2642/// found, or [`pick_merged_pr`] found more than one candidate and would not
2643/// guess between them. Never wired to a weaker, URL-less signal like
2644/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2645/// separately, precisely because it cannot drive an automatic correction on
2646/// its own (see that function's own doc).
2647pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2648    let Some(winner) = state.winner() else {
2649        return Ok(None);
2650    };
2651    let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2652    let out = gh(
2653        &state.repo,
2654        &[
2655            "pr".to_owned(),
2656            "list".to_owned(),
2657            "--head".to_owned(),
2658            branch.clone(),
2659            "--state".to_owned(),
2660            "merged".to_owned(),
2661            "--json".to_owned(),
2662            "url,number,mergedAt,baseRefName".to_owned(),
2663        ],
2664    )
2665    .await?;
2666    if !out.0 {
2667        bail!("gh pr list --head {branch}: {}", out.1);
2668    }
2669    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2670}
2671
2672/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2673/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2674///
2675/// Deliberately never consulted by [`find_external_merge`]: a base branch
2676/// that has moved since the run started can make an old, abandoned branch
2677/// look like an ancestor of the *current* base for reasons that have nothing
2678/// to do with a merge (a later commit that happens to supersede it, an
2679/// unrelated squash), and there is no pull request URL here to confirm
2680/// against or to land through anyway. Its only honest use is a weaker
2681/// notice — "this looks merged, go check" — never an automatic rewrite of
2682/// `status`/`merge`.
2683pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2684    let out = tokio::process::Command::new("git")
2685        .args(["merge-base", "--is-ancestor", branch, base_branch])
2686        .current_dir(repo)
2687        .quiet()
2688        .stdin(std::process::Stdio::null())
2689        .output()
2690        .await
2691        .context("spawn git merge-base --is-ancestor")?;
2692    Ok(out.status.success())
2693}
2694
2695/// Parse `host/owner/repo` out of a forge URL, with no network access.
2696///
2697/// The host is part of the slug, not discarded: `owner/repo` alone would
2698/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2699/// repository, which is exactly the mix-up the same-repo guard exists to
2700/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2701/// shape at all.
2702fn forge_slug(url: &str) -> Option<(String, &str)> {
2703    let rest = url.rsplit("://").next()?;
2704    let (host, path) = rest.split_once('/')?;
2705    if host.is_empty() {
2706        return None;
2707    }
2708    Some((host.to_ascii_lowercase(), path))
2709}
2710
2711/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2712/// access - the first half of the same-repo guard [`correct_manual_merge`]
2713/// applies before it writes anything.
2714///
2715/// Returns `None` for anything that does not look like
2716/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2717/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2718pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2719    let (host, path) = forge_slug(url)?;
2720    let mut segments = path.split('/');
2721    let owner = segments.next()?;
2722    let repo = segments.next()?;
2723    let kind = segments.next()?;
2724    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2725        return None;
2726    }
2727    Some(format!("{host}/{owner}/{repo}"))
2728}
2729
2730/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2731/// suffix), the shape `gh repo view --json url` returns - the other half of
2732/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2733fn slug_of_repo_url(url: &str) -> Option<String> {
2734    let (host, path) = forge_slug(url)?;
2735    let mut segments = path.split('/');
2736    let owner = segments.next()?;
2737    let repo = segments.next()?;
2738    if owner.is_empty() || repo.is_empty() {
2739        return None;
2740    }
2741    Some(format!("{host}/{owner}/{repo}"))
2742}
2743
2744/// Refuse to correct a run against a pull request from a different
2745/// repository than the one it is recorded against.
2746///
2747/// This is the guard the shun/8c75 incident argued for: an operator ran
2748/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2749/// in a different repository, omitted the run id, and the id defaulted to
2750/// this machine's most recently created run - an unrelated, still-in-progress
2751/// run in a completely different repository - which then had its `status`
2752/// rewritten to `merged` from a pull request it had nothing to do with.
2753/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2754/// CLI help), but a mistyped or stale id could still name a run in a
2755/// different repository than the one the URL belongs to, so this checks that
2756/// independently rather than trusting the id alone.
2757///
2758/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2759/// mismatch names both slugs rather than just refusing, so an operator whose
2760/// local checkout's `origin` is a fork of the repository the pull request was
2761/// opened against (a legitimate setup this cannot tell apart from a genuine
2762/// mix-up) can judge for themselves rather than being blocked with no way to
2763/// see why.
2764pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2765    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2766        return Ok(());
2767    }
2768    bail!(
2769        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2770         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2771         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2772         verify by hand before treating this as a false positive)"
2773    );
2774}
2775
2776/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2777/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2778///
2779/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2780/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2781/// repository the same way GitHub itself would recognize it, so the
2782/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2783/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2784/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2785/// a same-named one on a GitHub Enterprise host.
2786async fn repo_slug(repo: &Path) -> Result<String> {
2787    let out = gh(
2788        repo,
2789        &[
2790            "repo".to_owned(),
2791            "view".to_owned(),
2792            "--json".to_owned(),
2793            "url".to_owned(),
2794        ],
2795    )
2796    .await?;
2797    if !out.0 {
2798        bail!("gh repo view --json url: {}", out.1);
2799    }
2800    #[derive(Debug, Deserialize)]
2801    struct GhRepo {
2802        url: String,
2803    }
2804    let parsed: GhRepo = serde_json::from_str(&out.1)
2805        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2806    slug_of_repo_url(&parsed.url)
2807        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2808}
2809
2810/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2811/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2812/// would have written them had magi opened and merged this pull request
2813/// itself.
2814///
2815/// This is `magi fold --merged`'s whole implementation, and also what the
2816/// web `fold-merged` route calls once it has a URL in hand — an operator
2817/// recovery path for a merge magi could not finish on its own: a PR title too
2818/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2819/// closed by hand with a pull request magi never opened and so never
2820/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2821/// directly keeps this one authoritative: a merged pull request decides
2822/// `Step::Done { merged: true }` on the very first read, before any of
2823/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2824/// safe to call here even though this pull request was never magi's own.
2825///
2826/// [`ensure_same_repo`] is checked before anything else: a pull request from
2827/// a different repository than the one `state` is recorded against is
2828/// refused outright, regardless of its lifecycle. This is the guard for a
2829/// URL an *operator* hands in - the CLI or the web route - where a stale or
2830/// mistyped run id could otherwise get corrected from an unrelated
2831/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2832/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2833/// goes through [`correct_confirmed_external_merge`] instead, which skips
2834/// this check: its `url` was never operator-supplied, it comes from
2835/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2836/// it is already guaranteed to name a pull request in that same repository -
2837/// re-deriving and re-checking the repository here would only be a second
2838/// `gh repo view` call that can fail for reasons that have nothing to do with
2839/// correctness (a rate limit, a network blip), turning a self-heal that would
2840/// otherwise have succeeded into a run left `Blocked` for another pass.
2841///
2842/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2843/// URL fails loudly without writing anything, rather than handing an open
2844/// pull request to the full autonomous loop by accident.
2845///
2846/// Correcting `status` this way does not run `bump::after_merge`
2847/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2848/// which this path never goes through. A release version bump the change
2849/// might have earned is therefore not filed automatically and has to be
2850/// requested by hand - recorded as an event on the run so the gap is visible
2851/// to whoever reads it later, not just wherever this was called from. Follow-up
2852/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2853///
2854/// Returns the status before and after, so every caller (CLI, janitor, web
2855/// route) can build its own log line or response from the same pair rather
2856/// than each re-deriving it.
2857pub async fn correct_manual_merge(
2858    state: &mut RunState,
2859    url: &str,
2860) -> Result<(RunStatus, RunStatus)> {
2861    let Some(pr_slug) = slug_of_pr_url(url) else {
2862        bail!(
2863            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2864             this pull request belongs to"
2865        );
2866    };
2867    let run_slug = repo_slug(&state.repo).await?;
2868    ensure_same_repo(&run_slug, &pr_slug)?;
2869    correct_merge(state, url).await
2870}
2871
2872/// The janitor's own entry point into the same correction
2873/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2874/// same-repo guard - see that function's own doc for why skipping it here is
2875/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2876/// calls this with a `url` [`find_external_merge`] already found by querying
2877/// `state.repo`'s own remote, so the guard could never do anything here but
2878/// fail on its own transient errors.
2879///
2880/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2881/// because it lives in a different module.
2882pub(crate) async fn correct_confirmed_external_merge(
2883    state: &mut RunState,
2884    url: &str,
2885) -> Result<(RunStatus, RunStatus)> {
2886    correct_merge(state, url).await
2887}
2888
2889/// Same pull request, same repository: the url, or the number within one repo.
2890fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2891    let Some(pr) = a.pr.as_ref() else {
2892        return false;
2893    };
2894    if !url.is_empty()
2895        && pr
2896            .url
2897            .trim_end_matches('/')
2898            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2899    {
2900        return true;
2901    }
2902    number > 0
2903        && pr.number == number
2904        && match (a.repo.canonicalize(), repo.canonicalize()) {
2905            (Ok(x), Ok(y)) => x == y,
2906            _ => a.repo == repo,
2907        }
2908}
2909
2910/// Rewrite `pr.state` to a final state on every run record under `home` that
2911/// `decide` picks, and report how many were rewritten.
2912///
2913/// Lifecycle writes through to other runs are deliberately narrow (task-head
2914/// ownership is recorded separately): only a **terminal** run (never one a
2915/// driver may still be writing), only a proven-stopped driver with no daemon
2916/// claim (the shared liveness judge), only a record
2917/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2918/// The record is read as folding reads it (no schema check: every bump so far
2919/// only added fields, and the whole struct round-trips) and written through
2920/// [`RunState::save_under`], the path every record uses, so nothing but
2921/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2922/// A run that cannot be read or written is skipped with a warning.
2923fn rewrite_open_prs(
2924    home: &Path,
2925    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2926) -> usize {
2927    let now = Timestamp::now();
2928    let mut changed = 0;
2929    for id in crate::run::list_ids_in(&home.join("runs")) {
2930        let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2931            continue;
2932        };
2933        let path = home.join("runs").join(&id).join("run.json");
2934        let Ok(body) = std::fs::read_to_string(&path) else {
2935            continue;
2936        };
2937        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2938            continue;
2939        };
2940        if !state.status.done()
2941            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2942            || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2943                != crate::run::Liveness::Dead
2944        {
2945            continue;
2946        }
2947        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2948            continue;
2949        };
2950        if let Some(pr) = state.pr.as_mut() {
2951            pr.state = to.as_str().to_owned();
2952        }
2953        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2954        state.event(
2955            "land",
2956            format!("recorded {url} as {}: another run settled it", to.as_str()),
2957        );
2958        match state.save_under(home) {
2959            Ok(()) => changed += 1,
2960            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2961        }
2962    }
2963    changed
2964}
2965
2966/// A run reached a final state for its pull request: tell every other terminal
2967/// run in the same repository that names the same pull request (handed-over
2968/// predecessors, blocked attempts, anything), so their records stop saying
2969/// `open`. Best effort; `run`'s own record is the caller's.
2970pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2971    if to == PrLifecycle::Open {
2972        return;
2973    }
2974    let Some(home) = crate::run::try_home() else {
2975        return;
2976    };
2977    write_pr_state_through_in(&home, run, to);
2978}
2979
2980pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2981    let Some(pr) = run.pr.as_ref() else {
2982        return 0;
2983    };
2984    let (url, number) = (pr.url.clone(), pr.number);
2985    rewrite_open_prs(home, &mut |other| {
2986        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2987    })
2988}
2989
2990/// Terminal runs whose record still says their pull request is open, as
2991/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
2992pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2993    let now = Timestamp::now();
2994    let mut out = Vec::new();
2995    for id in crate::run::list_ids_in(&home.join("runs")) {
2996        let path = home.join("runs").join(&id).join("run.json");
2997        let Ok(body) = std::fs::read_to_string(&path) else {
2998            continue;
2999        };
3000        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
3001            continue;
3002        };
3003        if let Some(pr) = state.pr.as_ref()
3004            && state.status.done()
3005            && pr.state == "open"
3006            && !pr.url.is_empty()
3007            && !crate::daemon::is_working_on(home, &id, now)
3008        {
3009            out.push((id, state.repo.clone(), pr.url.clone()));
3010        }
3011    }
3012    out
3013}
3014
3015/// Apply forge answers (`pr url -> state`) to every stale terminal record.
3016/// A url with no answer (the forge was unreadable) changes nothing.
3017pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
3018    rewrite_open_prs(home, &mut |s| {
3019        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
3020    })
3021}
3022
3023/// One-time (and idempotent) repair of records that froze an `open` pull
3024/// request: ask the forge about each distinct pull request that a terminal run
3025/// still calls open - at most `max_lookups` of them - and rewrite the merged
3026/// and closed ones. A genuinely open pull request is left alone, and a lookup
3027/// that fails is "unknown, change nothing". Returns `(records rewritten,
3028/// lookups that failed)`.
3029pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
3030    let mut known = BTreeMap::new();
3031    let mut failed = 0;
3032    let mut seen = BTreeSet::new();
3033    for (_, repo, url) in stale_open_prs(home) {
3034        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
3035            continue;
3036        }
3037        match lifecycle(&repo, &url).await {
3038            Ok(state) => {
3039                known.insert(url, state);
3040            }
3041            Err(e) => {
3042                tracing::warn!("repair pr state of {url}: {e:#}");
3043                failed += 1;
3044            }
3045        }
3046    }
3047    (apply_pr_states(home, &known), failed)
3048}
3049
3050async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
3051    match lifecycle(&state.repo, url).await? {
3052        PrLifecycle::Merged => {}
3053        other => bail!(
3054            "{url} is {}, not merged; refusing to record {} as merged on a guess",
3055            other.as_str(),
3056            state.id
3057        ),
3058    }
3059    let before = state.status;
3060    if let Err(e) = land(state, url).await {
3061        // `land` sets `status` to `Landing` and saves before its first read
3062        // of the pull request — see its own doc — so a failure here (a
3063        // transient `gh` hiccup between the two forge reads this function
3064        // makes) can leave the run stuck on that in-between value with
3065        // nothing left driving it. Land it on the same terminal shape an
3066        // automated `land` failure lands on instead of leaving it stuck.
3067        state.status = RunStatus::Blocked;
3068        state.event("fold", format!("manual-merge correction failed: {e:#}"));
3069        state.save()?;
3070        return Err(e).context(format!("confirming the merge of {url}"));
3071    }
3072    state.event(
3073        "fold",
3074        "operator recorded this pull request as a manual merge; this run never \
3075         re-entered `land`, so `bump::after_merge` did not run for it - a release \
3076         bump this change might warrant has to be filed by hand",
3077    );
3078    // Unlike the bump, the findings the merge left open are filed on this
3079    // path too: nothing else would ever carry them forward.
3080    if state.status == RunStatus::Merged {
3081        crate::followup::after_merge(state, url).await;
3082    }
3083    state.save()?;
3084    Ok((before, state.status))
3085}
3086
3087/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
3088/// comments. No I/O.
3089///
3090/// `gh pr view` does not surface inline comments, and inline is exactly where
3091/// both review bots put their findings - a landing loop that read only the
3092/// top-level thread would never see the thing it is supposed to fix.
3093pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3094    let raw: Vec<GhInline> =
3095        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3096    let mut out = Vec::new();
3097    for c in raw {
3098        push_if_outstanding(
3099            &mut out,
3100            ReviewComment {
3101                author: c.user.login,
3102                path: c.path,
3103                line: c.line,
3104                body: c.body,
3105            },
3106        );
3107    }
3108    Ok(out)
3109}
3110
3111/// Keep a comment only when it asks for something.
3112///
3113/// An inline comment always does: it names a file and a line. A top-level
3114/// comment is dropped when it is empty, when it is magi's own, or when it is
3115/// [noise](is_noise).
3116fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3117    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3118        return;
3119    }
3120    if comment.path.is_none() && is_noise(&comment.body) {
3121        return;
3122    }
3123    out.push(comment);
3124}
3125
3126/// Is this comment body machinery rather than a finding?
3127///
3128/// Two tests, both structural, because guessing from prose is how a "looks
3129/// good to me" turns into a fix round:
3130///
3131/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
3132///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
3133///    footer.
3134/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
3135///    horizontal rules, and the bot's own status banner are removed, every
3136///    remaining line is a task-list item. That is exactly the shape of the
3137///    comment the Claude review job posts while it is still working.
3138///
3139/// Anything else is input, including bot prose. A bot that writes a paragraph
3140/// has said something, and the fix prompt tells the fixer it may decline a
3141/// comment with an argument - a wasted sentence in a prompt is cheaper than a
3142/// missed finding.
3143pub fn is_noise(body: &str) -> bool {
3144    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3145        return true;
3146    }
3147    let mut content = false;
3148    for line in strip_blocks(body).lines() {
3149        let line = unquote(line);
3150        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3151            continue;
3152        }
3153        content = true;
3154        break;
3155    }
3156    !content
3157}
3158
3159/// Remove HTML comments and collapsed `<details>` blocks.
3160fn strip_blocks(body: &str) -> String {
3161    let mut out = String::with_capacity(body.len());
3162    let mut rest = body;
3163    loop {
3164        let open = ["<!--", "<details>"]
3165            .iter()
3166            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3167            .min_by_key(|(i, _)| *i);
3168        let Some((at, tag)) = open else {
3169            out.push_str(rest);
3170            return out;
3171        };
3172        out.push_str(&rest[..at]);
3173        let after = &rest[at + tag.len()..];
3174        let close = if tag == "<!--" { "-->" } else { "</details>" };
3175        match after.find(close) {
3176            Some(end) => rest = &after[end + close.len()..],
3177            // Unterminated: the rest of the body is inside the block.
3178            None => return out,
3179        }
3180    }
3181}
3182
3183/// Strip blockquote markers, which both bots wrap their callouts in.
3184fn unquote(line: &str) -> &str {
3185    let mut s = line.trim();
3186    while let Some(rest) = s.strip_prefix('>') {
3187        s = rest.trim_start();
3188    }
3189    s.trim()
3190}
3191
3192/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
3193fn is_checklist(line: &str) -> bool {
3194    let rest = line
3195        .strip_prefix("- ")
3196        .or_else(|| line.strip_prefix("* "))
3197        .unwrap_or("");
3198    let rest = rest.trim_start();
3199    matches!(
3200        rest.get(..3),
3201        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3202    )
3203}
3204
3205/// A heading, a horizontal rule, or a callout tag - shape, never content.
3206fn is_decoration(line: &str) -> bool {
3207    line.starts_with('#')
3208        || line.starts_with("[!")
3209        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3210}
3211
3212/// A line that is nothing but emphasis and links.
3213///
3214/// Both review jobs open with a status banner
3215/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
3216/// to a line-based test and asks for nothing, so it is measured the same way a
3217/// heading is: strip the markup, and if no word survives, it was decoration.
3218fn is_banner(line: &str) -> bool {
3219    let plain = drop_spans(line, "**", "**");
3220    let plain = if plain.contains("](") {
3221        drop_spans(&plain, "[", ")")
3222    } else {
3223        plain
3224    };
3225    !plain.chars().any(char::is_alphanumeric)
3226}
3227
3228/// Remove every `open` .. `close` span, including the delimiters. An
3229/// unterminated span swallows the rest of the input, which is what a reader
3230/// sees too.
3231fn drop_spans(s: &str, open: &str, close: &str) -> String {
3232    let mut out = String::with_capacity(s.len());
3233    let mut rest = s;
3234    while let Some(at) = rest.find(open) {
3235        out.push_str(&rest[..at]);
3236        let after = &rest[at + open.len()..];
3237        match after.find(close) {
3238            Some(end) => rest = &after[end + close.len()..],
3239            None => return out,
3240        }
3241    }
3242    out.push_str(rest);
3243    out
3244}
3245
3246/// The lock that keeps at most one run per repository actually moving the
3247/// base branch at a time: a rebase push, or `gh pr merge`.
3248///
3249/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
3250/// running a fix round in the winner's own worktree, waiting on the owner's
3251/// approval - touches nothing a *different* run in the same repository could
3252/// collide with, and holding a lock across any of that would serialise one
3253/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
3254/// resume, which is precisely the "must not wait on another task" property
3255/// the daemon's slot-freeing exists to give a resume. Only the two moments
3256/// that actually write to the shared base branch need mutual exclusion, and
3257/// both are brief.
3258///
3259/// One entry per repository, each its own `tokio::sync::Mutex`, so two
3260/// different repositories' runs never wait on each other. The outer
3261/// `std::sync::Mutex` guards only the map itself, held long enough to find or
3262/// insert an entry and clone its `Arc`, never across an `.await`.
3263fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3264    static LOCKS: std::sync::LazyLock<
3265        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3266    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3267    LOCKS
3268        .lock()
3269        .unwrap_or_else(std::sync::PoisonError::into_inner)
3270        .entry(repo.to_path_buf())
3271        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3272        .clone()
3273}
3274
3275/// `owner/repo` out of a pull request url, falling back to the checkout's
3276/// directory name when the url is not the usual `host/owner/repo/pull/N`.
3277fn repo_label(repo: &Path, pr_url: &str) -> String {
3278    let parts: Vec<&str> = pr_url.split('/').collect();
3279    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3280        && at >= 2
3281        && !parts[at - 1].is_empty()
3282        && !parts[at - 2].is_empty()
3283    {
3284        return format!("{}/{}", parts[at - 2], parts[at - 1]);
3285    }
3286    repo.file_name()
3287        .map(|n| n.to_string_lossy().into_owned())
3288        .unwrap_or_default()
3289}
3290
3291/// The operator-facing sentence for a merge that went ahead with red checks,
3292/// or `None` when the checks were not red. Judged on `checks`, not on
3293/// `failing`, which can be non-empty on a green observation.
3294fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3295    (pr.checks == Checks::Red).then(|| {
3296        let details = crate::notices::format_list(&pr.failing)
3297            .map(|names| format!(": {names}"))
3298            .unwrap_or_default();
3299        format!(
3300            "Merged {repo_name} PR #{} with red checks{details} ({})",
3301            pr.number, pr.url
3302        )
3303    })
3304}
3305
3306/// After a merge that succeeded: record which checks were red and tell the
3307/// operator. The decision to merge is already made; this only makes it audible.
3308/// Best-effort - a broken notifier never fails the run.
3309async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3310    let repo_name = repo_label(&state.repo, &pr.url);
3311    let Some(summary) = red_merge_summary(&repo_name, pr) else {
3312        return;
3313    };
3314    if let Some(rec) = state.pr.as_mut() {
3315        rec.red_at_merge = pr.failing.clone();
3316    }
3317    state.event("land", summary.clone());
3318    // The notice pages through `[notify]` itself, once, unless a question
3319    // already carries the cause.
3320    crate::notices::raise_with(
3321        crate::notices::merged_red(&state.id, &summary),
3322        &state.config.notify,
3323    );
3324}
3325
3326/// Run the loop against a real pull request until it merges or the budget runs
3327/// out.
3328///
3329/// The caller decides whether landing happens at all: this is only reached when
3330/// `graph.land` is on. Returns the last observation, so the caller can report
3331/// what magi was looking at when it stopped.
3332pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3333    land_with(state, pr_url, &GhForge).await
3334}
3335
3336/// The forge calls whose timing the loop's decisions depend on, behind a seam
3337/// so a test can script what the pull request looks like from one observation
3338/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
3339trait Forge {
3340    async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3341        let (ok, out) = gh(
3342            repo,
3343            &[
3344                "pr".into(),
3345                "close".into(),
3346                pr_url.into(),
3347                "--comment".into(),
3348                comment.into(),
3349            ],
3350        )
3351        .await?;
3352        if !ok {
3353            bail!("PR closure failed: {out}");
3354        }
3355        Ok(())
3356    }
3357    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3358    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3359    async fn poll(&self);
3360    /// Rerun the failed jobs of one workflow run.
3361    async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3362    /// Unix seconds, behind the seam so a test owns the clock.
3363    fn now(&self) -> i64;
3364    /// The check contexts the base branch requires, for a stop reason only.
3365    /// `None` when they could not be read, which is not the same as none.
3366    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3367    #[allow(clippy::too_many_arguments)]
3368    async fn fix(
3369        &self,
3370        state: &mut RunState,
3371        pr: &PrState,
3372        round: usize,
3373        budget: usize,
3374        reason: &str,
3375        logs: &str,
3376    ) -> Result<Fixed>;
3377}
3378
3379struct GhForge;
3380
3381impl Forge for GhForge {
3382    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3383        observe(repo, pr_url).await
3384    }
3385    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3386        gh(repo, argv).await
3387    }
3388    async fn poll(&self) {
3389        tokio::time::sleep(POLL).await;
3390    }
3391    async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3392        let args = [
3393            "run".to_owned(),
3394            "rerun".to_owned(),
3395            run.to_owned(),
3396            "--failed".to_owned(),
3397        ];
3398        let (ok, out) = gh(repo, &args).await?;
3399        if !ok {
3400            bail!("gh run rerun {run}: {out}");
3401        }
3402        Ok(())
3403    }
3404    fn now(&self) -> i64 {
3405        jiff::Timestamp::now().as_second()
3406    }
3407    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3408        required_contexts_of(repo, base).await
3409    }
3410    async fn fix(
3411        &self,
3412        state: &mut RunState,
3413        pr: &PrState,
3414        round: usize,
3415        budget: usize,
3416        reason: &str,
3417        logs: &str,
3418    ) -> Result<Fixed> {
3419        fix_round(state, pr, round, budget, reason, logs).await
3420    }
3421}
3422
3423/// Percent-encode a branch name for a URL path segment (`/` included).
3424fn encode_path_segment(s: &str) -> String {
3425    let mut out = String::new();
3426    for b in s.bytes() {
3427        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3428            out.push(b as char);
3429        } else {
3430            let _ = write!(out, "%{b:02X}");
3431        }
3432    }
3433    out
3434}
3435
3436/// Read the required contexts of `base` from classic branch protection and
3437/// from rulesets, once, for a stop reason. Organisation-level rulesets that
3438/// these two endpoints do not list are missed. Any unreadable source makes the
3439/// whole answer `None`: a partial set would read as a complete one.
3440async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3441    let enc = encode_path_segment(base);
3442    let mut all = BTreeSet::new();
3443    // Classic protection answers 404 for an unprotected branch, which is
3444    // "nothing required here", not a failure to read.
3445    let classic = gh(
3446        repo,
3447        &[
3448            "api".to_owned(),
3449            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3450        ],
3451    )
3452    .await
3453    .ok()?;
3454    if classic.0 {
3455        all.extend(parse_classic_required(&classic.1)?);
3456    } else if !classic.1.contains("404") {
3457        return None;
3458    }
3459    let rules = gh(
3460        repo,
3461        &[
3462            "api".to_owned(),
3463            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3464        ],
3465    )
3466    .await
3467    .ok()?;
3468    if !rules.0 {
3469        return None;
3470    }
3471    all.extend(parse_ruleset_required(&rules.1)?);
3472    Some(all)
3473}
3474
3475/// `required_status_checks` of classic protection: `contexts` plus the
3476/// `checks[].context` form.
3477fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3478    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3479    let mut out = BTreeSet::new();
3480    for c in v.get("contexts")?.as_array()? {
3481        out.insert(c.as_str()?.to_owned());
3482    }
3483    for c in v
3484        .get("checks")
3485        .and_then(|c| c.as_array())
3486        .into_iter()
3487        .flatten()
3488    {
3489        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3490            out.insert(name.to_owned());
3491        }
3492    }
3493    Some(out)
3494}
3495
3496/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
3497fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3498    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3499    let mut out = BTreeSet::new();
3500    for rule in v.as_array()? {
3501        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3502            continue;
3503        }
3504        let checks = rule
3505            .pointer("/parameters/required_status_checks")?
3506            .as_array()?;
3507        for c in checks {
3508            out.insert(c.get("context")?.as_str()?.to_owned());
3509        }
3510    }
3511    Some(out)
3512}
3513
3514/// Is the observation older than the commit a fix round pushed?
3515///
3516/// The forge takes a few seconds to move the pull request to a new head and
3517/// attach that head's check runs, and until it has, the rollup is the previous
3518/// head's - all green, which is exactly what a merge decision must not read.
3519/// An absent or different head counts as not yet: guessing "close enough"
3520/// would reopen the hole.
3521fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3522    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3523}
3524
3525/// The commit an observation may be decided on, or `None` while it cannot be
3526/// trusted to describe one.
3527///
3528/// `None` when a pushed commit is awaited and the pull request is not on it,
3529/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3530/// last commit's) is not the head's: that is the previous commit's checks. The
3531/// caller re-polls on `None`. A rollup that cannot be read (including one
3532/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3533/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3534fn bound_head<'a>(
3535    seen_head: &'a str,
3536    rollup_head: &str,
3537    awaiting: Option<&str>,
3538) -> Option<&'a str> {
3539    if seen_head.is_empty()
3540        || awaiting_new_head(awaiting, seen_head)
3541        || !rollup_head.eq_ignore_ascii_case(seen_head)
3542    {
3543        return None;
3544    }
3545    Some(seen_head)
3546}
3547
3548/// What a refused `gh pr merge` means.
3549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3550enum Refused {
3551    /// The branch policy is not satisfied *yet*: go back to waiting.
3552    Pending,
3553    /// Checks have settled and the merge state still says no, seen for the
3554    /// first time. The forge updates `mergeStateStatus` a moment after the last
3555    /// check finishes, so one more look is allowed before believing it.
3556    Recheck,
3557    /// Nothing is in flight and the policy still refuses: a person has to
3558    /// supply what it asks for (a review, say).
3559    Final,
3560}
3561
3562/// Judged from the pull request's state after the refusal, never from the
3563/// refusal's wording, which belongs to the forge and changes.
3564fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3565    let Some(after) = after else {
3566        // Unreadable is not evidence of anything; the next loop reads again.
3567        return Refused::Pending;
3568    };
3569    if after.pr.state != PrLifecycle::Open {
3570        return Refused::Final;
3571    }
3572    // The branch moved after it was observed (the forge refuses a merge pinned
3573    // to the old commit): not a verdict on anything, look again.
3574    if !after.head.eq_ignore_ascii_case(observed_head) {
3575        return Refused::Pending;
3576    }
3577    // The same binding the loop applies: checks of another commit say nothing.
3578    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3579        return Refused::Pending;
3580    }
3581    let state = after.merge_state.to_ascii_uppercase();
3582    if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3583        || state.is_empty()
3584        || state == "UNKNOWN"
3585    {
3586        return Refused::Pending;
3587    }
3588    if rechecked {
3589        Refused::Final
3590    } else {
3591        Refused::Recheck
3592    }
3593}
3594
3595/// Take auto-merge back from the forge and forget that it was armed.
3596///
3597/// `Err` carries the forge's message: the caller must not push or move on, as
3598/// an armed merge left behind could still fire for a commit nobody approved.
3599async fn disarm<F: Forge>(
3600    forge: &F,
3601    state: &mut RunState,
3602    repo: &Path,
3603    number: u64,
3604) -> std::result::Result<(), String> {
3605    let argv = disable_automerge_argv(number);
3606    let out = {
3607        let merge_lock = repo_merge_lock(repo);
3608        let _merge_slot = merge_lock.lock().await;
3609        forge.merge(repo, &argv).await
3610    };
3611    match out {
3612        Ok((true, _)) => {
3613            state.land_armed_head = None;
3614            state.event("land", "auto-merge disabled");
3615            state.save().map_err(|e| format!("{e:#}"))?;
3616            Ok(())
3617        }
3618        Ok((false, msg)) => Err(msg),
3619        Err(e) => Err(format!("{e:#}")),
3620    }
3621}
3622
3623/// [`stop`], first taking back an armed auto-merge so a pull request magi
3624/// gave up on does not merge on its own later. A failure to disarm is added
3625/// to the reason rather than hiding it.
3626async fn stop_disarmed<F: Forge>(
3627    forge: &F,
3628    state: &mut RunState,
3629    repo: &Path,
3630    pr: &PrState,
3631    why: &str,
3632) -> Result<()> {
3633    if state.land_armed_head.is_none() {
3634        return stop(state, repo, pr, why).await;
3635    }
3636    match disarm(forge, state, repo, pr.number).await {
3637        Ok(()) => stop(state, repo, pr, why).await,
3638        Err(e) => {
3639            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3640            stop(state, repo, pr, &why).await
3641        }
3642    }
3643}
3644
3645/// Check the persisted card again around awaits that can outlive an owner tap.
3646fn owner_requested_stop(state: &RunState) -> Result<bool> {
3647    let Some(binding) = &state.land_approval else {
3648        return Ok(false);
3649    };
3650    let q = ask::Questions::open().get(&binding.question)?;
3651    Ok(q.run == state.id
3652        && q.node == APPROVAL_NODE
3653        && q.resolution()
3654            .as_deref()
3655            .is_some_and(crate::merge_decision::stops))
3656}
3657
3658/// Read stop choices before checks, repair or rebase. These are about stopping
3659/// ownership, so a changed head never cancels them as it cancels merge consent.
3660async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3661    use crate::merge_decision as md;
3662    let Some(binding) = &state.land_approval else {
3663        return Ok(false);
3664    };
3665    let q = ask::Questions::open().get(&binding.question)?;
3666    let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3667        return Ok(false);
3668    };
3669    if q.run != state.id || q.node != APPROVAL_NODE {
3670        bail!("stop decision names another run");
3671    }
3672    let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3673        pr: pr.url.clone(),
3674        head: binding.head.clone(),
3675        language: state.config.graph.language.clone(),
3676    });
3677    if target.pr != pr.url {
3678        bail!("stop decision names another PR");
3679    }
3680    if state.merge_decision.is_none() {
3681        state.merge_decision = Some(md::Decision {
3682            question: q.id.clone(),
3683            target,
3684            choice: choice.clone(),
3685            closed: false,
3686            replacement_run: None,
3687            replacement_pr: None,
3688        });
3689        state.save()?;
3690    }
3691    let repo = state.repo.clone();
3692    // Always disable, including arms left by an older driver.
3693    if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3694        stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3695        return Ok(true);
3696    }
3697    if choice.eq_ignore_ascii_case(HOLD) {
3698        stop(
3699            state,
3700            &repo,
3701            pr,
3702            "owner held the decision: PR left open; no further repair or merge work",
3703        )
3704        .await?;
3705        return Ok(true);
3706    }
3707    if md::selected_agent(&choice).is_some()
3708        || state
3709            .origin
3710            .as_ref()
3711            .and_then(|o| o.task.as_ref())
3712            .is_some()
3713    {
3714        let owners: Vec<_> = crate::queue::Queue::open()
3715            .list()
3716            .into_iter()
3717            .filter(|task| task.runs.last() == Some(&state.id))
3718            .collect();
3719        let owner = (owners.len() == 1).then(|| &owners[0]);
3720        if !owner.is_some_and(|task| {
3721            task.runs.last() == Some(&state.id)
3722                && (!task
3723                    .operator_decided_at
3724                    .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3725                    || task.resumption.as_ref().is_some_and(|release| {
3726                        release.question_id.as_deref() == Some(q.id.as_str())
3727                            && task.operator_decided_at == Some(release.at)
3728                    }))
3729        }) {
3730            stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3731            return Ok(true);
3732        }
3733    }
3734    if pr.state == PrLifecycle::Merged {
3735        stop(
3736            state,
3737            &repo,
3738            pr,
3739            "PR already merged; withdrawal/replacement cannot be applied",
3740        )
3741        .await?;
3742        return Ok(true);
3743    }
3744    let comment = format!(
3745        "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3746        q.id, choice, state.id
3747    );
3748    if pr.state == PrLifecycle::Open {
3749        if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3750            stop(
3751                state,
3752                &repo,
3753                pr,
3754                &format!(
3755                    "owner requested closure but it failed: {e}; task held, no replacement started"
3756                ),
3757            )
3758            .await?;
3759            return Ok(true);
3760        }
3761    }
3762    let confirmed = forge.view(&repo, &pr.url).await?;
3763    if confirmed.pr.state != PrLifecycle::Closed {
3764        stop(
3765            state,
3766            &repo,
3767            pr,
3768            "PR closure was not confirmed; task held, no replacement started",
3769        )
3770        .await?;
3771        return Ok(true);
3772    }
3773    *pr = confirmed.pr;
3774    state.merge_decision.as_mut().unwrap().closed = true;
3775    if let Some(record) = &mut state.pr {
3776        record.state = "closed".into();
3777    }
3778    state.status = RunStatus::Superseded;
3779    state.merge = Some(MergeOutcome {
3780        mode: MergeMode::Pr,
3781        ok: false,
3782        detail: format!("owner decision: {choice}; PR closure confirmed"),
3783        empty: false,
3784    });
3785    state.event(
3786        "land",
3787        format!(
3788            "{} applied: PR closure confirmed; old execution retired",
3789            choice
3790        ),
3791    );
3792    state.save()?;
3793    Ok(true)
3794}
3795
3796async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3797    crate::pr_head::ensure_owner(state)?;
3798    let reopening_hold = crate::merge_decision::resumable_hold(state);
3799    let repo = state.repo.clone();
3800    let budget = state.config.graph.land_rounds;
3801    let mut round = 0usize;
3802    // Counted apart from `round`: a rebase is not a fix, and a base that
3803    // moved is not the change's fault.
3804    let mut rebases = 0usize;
3805    let mut waited = Duration::ZERO;
3806    // Comment bodies the fixer has already been shown. A comment is
3807    // outstanding until it has been handed over once; after that it is a
3808    // recorded decision, not an open question, and re-feeding it would loop the
3809    // budget away on a comment the fixer already declined with an argument.
3810    let mut shown: BTreeSet<String> = BTreeSet::new();
3811    // Await the last successfully published head, including across resumes.
3812    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
3813    // Whether a settled-checks refusal has already been given its one re-look.
3814    let mut rechecked = false;
3815
3816    // Marks the run resumable through exactly this function, not through a
3817    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
3818    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
3819    // status specifically to know a resumed run belongs back in `land`
3820    // rather than at a second `gh pr create`. Set on every entry - fresh or
3821    // resumed - because a resume that parked here again must keep reading
3822    // `Landing`, not whatever a first pass through `merge` left behind.
3823    state.status = RunStatus::Landing;
3824    state.event("land", format!("watching {pr_url}"));
3825    state.save()?;
3826
3827    // An armed merge recorded by an earlier pass may or may not have reached
3828    // the forge (the record is written before the call). It is taken back on
3829    // the first observation, where a pull request is known to stop with.
3830    let mut resumed_armed = state.land_armed_head.is_some();
3831
3832    loop {
3833        let seen = forge.view(&repo, pr_url).await?;
3834        let mut pr = seen.pr.clone();
3835        pr.review_comments.retain(|c| !shown.contains(&c.body));
3836        state.pr = Some(crate::run::PrRecord {
3837            url: pr.url.clone(),
3838            number: pr.number,
3839            state: pr.state.as_str().to_owned(),
3840            checks: pr.checks.as_str().to_owned(),
3841            round,
3842            rounds: budget,
3843            red_at_merge: Vec::new(),
3844        });
3845        state.save()?;
3846
3847        crate::pr_head::ensure_owner(state)?;
3848        if reopening_hold {
3849            anyhow::ensure!(
3850                pr.state == PrLifecycle::Open,
3851                "held PR is no longer open; cannot request a fresh decision"
3852            );
3853            // An explicit resume authorizes a fresh question only. Preserve the
3854            // answered card, and never enter repair/rebase/merge on this pass.
3855            disarm(forge, state, &repo, pr.number)
3856                .await
3857                .map_err(anyhow::Error::msg)?;
3858            let previous = state.merge_decision.take();
3859            let binding = state.land_approval.take();
3860            state.config.graph.land_approval = true;
3861            let subject = merge_subject(
3862                crate::graph::landing_title(state, &seen.title),
3863                &crate::graph::landing_subject_source(state),
3864            );
3865            let contested = contested_to_ask(state);
3866            if let Err(error) =
3867                approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
3868            {
3869                state.merge_decision = previous;
3870                state.land_approval = binding;
3871                return Err(error);
3872            }
3873            state.end_pass_parked(crate::run::ParkCause::Question);
3874            state.parked = true;
3875            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()));
3876            state.save()?;
3877            return Ok(pr);
3878        }
3879        if owner_stop(state, &mut pr, forge).await? {
3880            return Ok(pr);
3881        }
3882
3883        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3884            // An approval already given for the same head is reused, so
3885            // nothing is asked twice. A failed disable
3886            // stops the run with the record kept: pushing on could change the
3887            // head under an arm that is still live.
3888            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3889                let why = format!(
3890                    "a previous pass may have armed auto-merge and it could not be disabled \
3891                     on resume: {e}"
3892                );
3893                stop(state, &repo, &pr, &why).await?;
3894                return Ok(pr);
3895            }
3896        }
3897
3898        // The head moved away from the one auto-merge was armed on, by
3899        // someone other than this loop. The arm is pinned and would refuse to
3900        // merge the new commit, but the approval was for the old one: take it
3901        // back and go through the normal path again.
3902        if pr.state == PrLifecycle::Open
3903            && !seen.head.is_empty()
3904            && state
3905                .land_armed_head
3906                .as_deref()
3907                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3908        {
3909            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3910                let why = format!(
3911                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
3912                );
3913                stop(state, &repo, &pr, &why).await?;
3914                return Ok(pr);
3915            }
3916        }
3917
3918        if pr.state == PrLifecycle::Open {
3919            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3920                if waited >= WAIT_CEILING {
3921                    let want = awaiting_head.as_deref().unwrap_or_default();
3922                    let why = format!(
3923                        "the pull request's checks were still not about one readable head after \
3924                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
3925                         someone may have pushed over it",
3926                        WAIT_CEILING.as_secs() / 60,
3927                        if want.is_empty() { "any" } else { want },
3928                        if seen.head.is_empty() {
3929                            "nothing readable"
3930                        } else {
3931                            &seen.head
3932                        },
3933                        if seen.rollup_head.is_empty() {
3934                            "nothing readable"
3935                        } else {
3936                            &seen.rollup_head
3937                        },
3938                    );
3939                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
3940                    return Ok(pr);
3941                }
3942                waited += POLL;
3943                forge.poll().await;
3944                continue;
3945            }
3946            // Reset once, when the awaited head first shows up; resetting on
3947            // every re-observation would let a standing refusal wait forever.
3948            if awaiting_head.take().is_some() {
3949                // The checks now being read belong to the new head; give them
3950                // the same grace a fresh pull request gets.
3951                waited = Duration::ZERO;
3952            }
3953        }
3954
3955        // A failed workflow run is rerun once, judged by its own run rather
3956        // than by the rollup's aggregate: another run still pending, or a fix
3957        // budget already spent, must not keep a flaky failure from its one
3958        // rerun. It spends no round and no attempt.
3959        if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
3960            let failing = rerunnable(&seen.contexts);
3961            let now = forge.now();
3962            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
3963                RerunStep::Rerun(runs) => {
3964                    let rec = state.land_reruns.get_or_insert_with(Default::default);
3965                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
3966                        *rec = LandReruns {
3967                            head: seen.head.clone(),
3968                            runs: BTreeMap::new(),
3969                        };
3970                    }
3971                    for run in &runs {
3972                        rec.runs.insert(run.clone(), now);
3973                    }
3974                    // Durable before the call: a restart must not
3975                    // rerun the same run twice. A save that fails
3976                    // means no rerun.
3977                    state.save()?;
3978                    let mut any = false;
3979                    for run in &runs {
3980                        let names = failing
3981                            .iter()
3982                            .filter(|(_, r)| r.as_deref() == Some(run))
3983                            .map(|(l, _)| l.as_str())
3984                            .collect::<Vec<_>>()
3985                            .join(", ");
3986                        match forge.rerun(&repo, run).await {
3987                            Ok(()) => {
3988                                any = true;
3989                                state.event(
3990                                    "land",
3991                                    format!("reran failed check(s) {names} (run {run})"),
3992                                );
3993                            }
3994                            // Stays recorded: never a retry loop.
3995                            Err(e) => state.event(
3996                                "land",
3997                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
3998                            ),
3999                        }
4000                    }
4001                    state.save()?;
4002                    if any {
4003                        waited = Duration::ZERO;
4004                        forge.poll().await;
4005                        continue;
4006                    }
4007                }
4008                RerunStep::Grace => {
4009                    if waited >= WAIT_CEILING {
4010                        let why = format!(
4011                            "checks were still failing after a rerun and {} minutes",
4012                            WAIT_CEILING.as_secs() / 60
4013                        );
4014                        stop(state, &repo, &pr, &why).await?;
4015                        return Ok(pr);
4016                    }
4017                    waited += POLL;
4018                    forge.poll().await;
4019                    continue;
4020                }
4021                RerunStep::Fix => {}
4022            }
4023        }
4024
4025        let step = decide(&pr, round, budget, waited);
4026        // Armed on exactly this head: the forge is doing the waiting. A
4027        // decision to merge (or keep waiting) is only watched, never re-armed
4028        // and never re-approved; anything else - a red check, a comment, a
4029        // conflict - falls through to its own arm, which disarms first.
4030        let armed_here = state
4031            .land_armed_head
4032            .as_deref()
4033            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4034        if armed_here && matches!(step, Step::Merge | Step::Wait) {
4035            if waited >= WAIT_CEILING {
4036                let required = if seen.base.is_empty() {
4037                    None
4038                } else {
4039                    forge.required_contexts(&repo, &seen.base).await
4040                };
4041                let why = format!(
4042                    "auto-merge was armed on {} but the pull request did not merge within {} \
4043                     minutes ({})",
4044                    seen.head,
4045                    WAIT_CEILING.as_secs() / 60,
4046                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4047                );
4048                stop_disarmed(forge, state, &repo, &pr, &why).await?;
4049                return Ok(pr);
4050            }
4051            waited += POLL;
4052            forge.poll().await;
4053            continue;
4054        }
4055        if armed_here && !matches!(step, Step::Done { .. }) {
4056            // Not merging or waiting any more: whatever is next may change the
4057            // head or give up, and neither may leave the arm standing.
4058            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4059                let why = format!("auto-merge could not be disabled: {e}");
4060                stop(state, &repo, &pr, &why).await?;
4061                return Ok(pr);
4062            }
4063        }
4064        match step {
4065            Step::Wait => {
4066                if waited >= WAIT_CEILING {
4067                    let why = format!(
4068                        "checks were still running after {} minutes",
4069                        WAIT_CEILING.as_secs() / 60
4070                    );
4071                    stop(state, &repo, &pr, &why).await?;
4072                    return Ok(pr);
4073                }
4074                waited += POLL;
4075                forge.poll().await;
4076            }
4077            Step::Done { merged } => {
4078                if merged {
4079                    state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4080                }
4081                // Auto-merge is pinned to the approved head, but the forge's
4082                // own handling of a later push is not something magi can
4083                // see: if the pull request merged on another commit, say so
4084                // loudly rather than record a clean landing.
4085                if let Some(armed) = state.land_armed_head.take() {
4086                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4087                        let msg = format!(
4088                            "{} merged on {} but the owner approved {armed}; review what landed",
4089                            pr.url, seen.head
4090                        );
4091                        tracing::warn!("{msg}");
4092                        state.event("land", msg);
4093                        // Only an arm left by an older build can get here.
4094                        // The wording is fixed so a repeat does not relight
4095                        // the notice.
4096                        crate::notices::raise_with(
4097                            crate::notices::Notice::warn(
4098                                &format!("merged-unapproved-head:{}", state.id),
4099                                "A pull request merged on a commit the owner did not approve; \
4100                                 review what landed",
4101                            )
4102                            .link(crate::notices::Link::Run {
4103                                id: state.id.clone(),
4104                            }),
4105                            &state.config.notify,
4106                        );
4107                    }
4108                }
4109                state.status = if merged {
4110                    RunStatus::Merged
4111                } else {
4112                    RunStatus::Ready
4113                };
4114                let detail = if merged {
4115                    format!("{} was merged", pr.url)
4116                } else {
4117                    format!("{} was closed without merging", pr.url)
4118                };
4119                state.merge = Some(MergeOutcome {
4120                    mode: MergeMode::Pr,
4121                    ok: merged,
4122                    detail: detail.clone(),
4123                    empty: false,
4124                });
4125                state.event("land", detail);
4126                state.save()?;
4127                write_pr_state_through(state, pr.state);
4128                return Ok(pr);
4129            }
4130            Step::Merge => {
4131                state.check_delivery_evidence(&seen.head)?;
4132                let subject = merge_subject(
4133                    crate::graph::landing_title(state, &seen.title),
4134                    &crate::graph::landing_subject_source(state),
4135                );
4136                // The owner sees the panel before the one irreversible step,
4137                // and an unanswered question is a hold: silence never merges.
4138                //
4139                // The dedicated policy includes existing consent and post-repair
4140                // decisions; repair choice availability never grants merge authority.
4141                let contested = contested_to_ask(state);
4142                if !approval_reasons(state).is_empty() {
4143                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4144                        .await?
4145                    {
4146                        ApprovalGate::Approved => {}
4147                        ApprovalGate::Repair => {
4148                            // The driver consumes the saved request under the existing
4149                            // run/task claim. No merge, no second run or task.
4150                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4151                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4152                                state.save()?;
4153                            }
4154                            return Ok(pr);
4155                        }
4156                        ApprovalGate::Held => {
4157                            // Another entry point may have saved a stop after
4158                            // the first read above. Apply its durable action.
4159                            if owner_stop(state, &mut pr, forge).await? {
4160                                return Ok(pr);
4161                            }
4162                            stop(
4163                                state,
4164                                &repo,
4165                                &pr,
4166                                "the owner did not approve the merge (held or unanswered)",
4167                            )
4168                            .await?;
4169                            return Ok(pr);
4170                        }
4171                        // Filed (or still standing from an earlier visit) and
4172                        // not yet answered. Park here rather than wait: the
4173                        // question survives on disk, the daemon hands this
4174                        // run's slot to something else, and a later resume
4175                        // re-enters `land`, finds the same question, and
4176                        // either merges or stops depending on what it says
4177                        // by then.
4178                        ApprovalGate::Pending => {
4179                            state.end_pass_parked(crate::run::ParkCause::Question);
4180                            state.parked = true;
4181                            state.event(
4182                                "land",
4183                                "parked awaiting merge approval - resumes once answered",
4184                            );
4185                            state.save()?;
4186                            return Ok(pr);
4187                        }
4188                    }
4189                }
4190                // Open and past the gate above, so `seen.head` is the commit
4191                // the checks were bound to and the owner approved.
4192                //
4193                // Merge directly, bound to that commit. Auto-merge is not
4194                // armed any more: the forge checks `--match-head-commit` only
4195                // when the request is made, so an arm outlives the head it
4196                // was made for, and a push of another commit whose
4197                // requirements are met first would merge without approval.
4198                // A direct merge is checked by the forge at the moment it
4199                // happens, so only the approved head can land.
4200                let observed_head = seen.head.clone();
4201                // Read the pull request again just before: the state seen
4202                // above can be a moment old.
4203                {
4204                    let fresh = forge.view(&repo, pr_url).await.ok();
4205                    if !direct_merge_is_safe(
4206                        fresh.as_ref(),
4207                        &observed_head,
4208                        &shown,
4209                        round,
4210                        budget,
4211                        waited,
4212                    ) {
4213                        if waited >= WAIT_CEILING {
4214                            let why = "the pull request did not settle on the approved head \
4215                                       before it could be merged";
4216                            stop(state, &repo, &pr, why).await?;
4217                            return Ok(pr);
4218                        }
4219                        state.event(
4220                            "land",
4221                            "the pull request changed before merging; looking again",
4222                        );
4223                        waited += POLL;
4224                        forge.poll().await;
4225                        continue;
4226                    }
4227                }
4228                let argv = merge_argv_at(pr.number, &subject, &observed_head);
4229                let out = {
4230                    let merge_lock = repo_merge_lock(&repo);
4231                    let _merge_slot = merge_lock.lock().await;
4232                    crate::pr_head::ensure_owner(state)?;
4233                    if let Some(task_id) = state
4234                        .origin
4235                        .as_ref()
4236                        .and_then(|origin| origin.task.as_ref())
4237                    {
4238                        let task = crate::queue::Queue::open()
4239                            .get(task_id)
4240                            .context("cannot verify the task owner immediately before merge")?;
4241                        if task.runs.last() != Some(&state.id)
4242                            || task.operator_held()
4243                            || task.status == crate::queue::TaskStatus::Done
4244                        {
4245                            bail!(
4246                                "task ownership or an operator hold superseded this merge approval"
4247                            );
4248                        }
4249                    }
4250                    if let Some(binding) = &state.land_approval {
4251                        let current = ask::Questions::open().get(&binding.question)?;
4252                        if merge_consent_superseded(state, &current)?
4253                            || current.run != state.id
4254                            || !binding.head.eq_ignore_ascii_case(&observed_head)
4255                            || approval(current.resolution().as_deref()) != Approval::Merge
4256                            || current.merge_target.as_ref().is_some_and(|t| {
4257                                t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4258                            })
4259                        {
4260                            bail!(
4261                                "merge decision is stale or no longer authorizes the reviewed head"
4262                            );
4263                        }
4264                    }
4265                    forge.merge(&repo, &argv).await?
4266                };
4267                if out.0 {
4268                    // Exit 0 is not proof of a merge: with a merge queue `gh`
4269                    // enqueues (or reports the pull request already queued)
4270                    // and succeeds while it is still open. Ask the forge; an
4271                    // unreadable answer is not a confirmation either, so it
4272                    // is looked at again rather than recorded as merged.
4273                    let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4274                        if c.pr.state == PrLifecycle::Merged {
4275                            state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4276                            true
4277                        } else {
4278                            false
4279                        }
4280                    });
4281                    if !confirmed {
4282                        if waited >= WAIT_CEILING {
4283                            let why = "the merge request succeeded but the pull request \
4284                                       could not be confirmed merged after waiting";
4285                            stop(state, &repo, &pr, why).await?;
4286                            return Ok(pr);
4287                        }
4288                        state.event(
4289                            "land",
4290                            "merge accepted but the pull request is not confirmed merged yet; waiting",
4291                        );
4292                        state.save()?;
4293                        waited += POLL;
4294                        forge.poll().await;
4295                        continue;
4296                    }
4297                    pr.state = PrLifecycle::Merged;
4298                    state.status = RunStatus::Merged;
4299                    state.merge = Some(MergeOutcome {
4300                        mode: MergeMode::Pr,
4301                        ok: true,
4302                        detail: format!("gh {}", argv.join(" ")),
4303                        empty: false,
4304                    });
4305                    // The last `state.pr` snapshot is whatever the poll before
4306                    // this merge observed - still `open` - and nothing below
4307                    // refreshes it from GitHub again, so the UI's round rail
4308                    // would otherwise keep animating a merged run forever.
4309                    if let Some(pr_record) = state.pr.as_mut() {
4310                        pr_record.state = pr.state.as_str().to_owned();
4311                    }
4312                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4313                    announce_red_merge(state, &pr).await;
4314                    state.save()?;
4315                    write_pr_state_through(state, pr.state);
4316                    return Ok(pr);
4317                }
4318                let after_seen = forge.view(&repo, pr_url).await.ok();
4319                let after = after_seen.as_ref().map(|s| s.pr.state);
4320                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4321                    state.pr_merged_head = after_seen
4322                        .as_ref()
4323                        .map(|s| s.head.clone())
4324                        .filter(|h| !h.is_empty());
4325                    pr.state = PrLifecycle::Merged;
4326                    state.status = RunStatus::Merged;
4327                    state.merge = Some(outcome);
4328                    if let Some(pr_record) = state.pr.as_mut() {
4329                        pr_record.state = pr.state.as_str().to_owned();
4330                    }
4331                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4332                    announce_red_merge(state, &pr).await;
4333                    state.save()?;
4334                    write_pr_state_through(state, pr.state);
4335                    return Ok(pr);
4336                }
4337                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4338                match verdict {
4339                    Refused::Final => {
4340                        let merge_state = after_seen
4341                            .as_ref()
4342                            .map(|s| s.merge_state.as_str())
4343                            .filter(|m| !m.is_empty())
4344                            .unwrap_or("unknown");
4345                        // Which refusal this was decides what a person has to
4346                        // do about it, so the two are worded apart; both carry
4347                        // the forge's own message.
4348                        let why = format!(
4349                            "the merge was refused: {} (merge state: {merge_state})",
4350                            out.1
4351                        );
4352                        stop(state, &repo, &pr, &why).await?;
4353                        return Ok(pr);
4354                    }
4355                    verdict => {
4356                        // Back to the top, which re-decides and passes the
4357                        // approval gate again, a poll later. `waited` is not
4358                        // reset here: only a pushed fix restarts it, or a
4359                        // standing refusal would wait forever.
4360                        if waited >= WAIT_CEILING {
4361                            let why = format!(
4362                                "the merge was still refused after {} minutes: {}",
4363                                WAIT_CEILING.as_secs() / 60,
4364                                out.1
4365                            );
4366                            stop(state, &repo, &pr, &why).await?;
4367                            return Ok(pr);
4368                        }
4369                        if verdict == Refused::Recheck {
4370                            rechecked = true;
4371                        }
4372                        state.event(
4373                            "land",
4374                            "merge refused while the branch policy is not satisfied yet; waiting",
4375                        );
4376                        state.save()?;
4377                        waited += POLL;
4378                        forge.poll().await;
4379                    }
4380                }
4381            }
4382            Step::Rebase => {
4383                // Bounded by the same budget as a fix, because a rebase that
4384                // keeps being needed means the base moves faster than this
4385                // run can land and a person should decide what to do. It
4386                // spends none of that budget: the change is not what is
4387                // wrong.
4388                if rebases >= budget {
4389                    let why = format!(
4390                        "the base moved under this branch {budget} time(s) and it still does \
4391                         not merge; rebasing again would only race it"
4392                    );
4393                    stop(state, &repo, &pr, &why).await?;
4394                    return Ok(pr);
4395                }
4396                rebases += 1;
4397                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4398                    stop(
4399                        state,
4400                        &repo,
4401                        &pr,
4402                        "the pull request conflicts and this run has no winning branch to rebase",
4403                    )
4404                    .await?;
4405                    return Ok(pr);
4406                };
4407                let base = state.base_branch.clone();
4408                state.event(
4409                    "land",
4410                    format!("{} no longer merges; rebasing onto {base}", pr.url),
4411                );
4412                state.save()?;
4413
4414                // Onto the base as the *remote* has it: the local ref may be
4415                // behind, and rebasing onto a stale base produces a branch
4416                // that conflicts all over again.
4417                let remote = state.config.merge.remote.clone();
4418                git::fetch(&repo, &remote, &base).await.ok();
4419                if owner_stop(state, &mut pr, forge).await? {
4420                    return Ok(pr);
4421                }
4422                let scratch = state.dir().join("rebase");
4423                let onto = format!("{remote}/{base}");
4424                let rebased =
4425                    match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
4426                        Ok(crate::rebase::Rebased::Applied) => Ok(None),
4427                        Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4428                        Err(e) => Err(e),
4429                    };
4430                if owner_stop(state, &mut pr, forge).await? {
4431                    return Ok(pr);
4432                }
4433                match rebased {
4434                    Ok(None) => {
4435                        let pushed = {
4436                            let merge_lock = repo_merge_lock(&repo);
4437                            let _merge_slot = merge_lock.lock().await;
4438                            if let Some(head) = &state.pr_head {
4439                                git::push_as(
4440                                    &repo,
4441                                    &state.config.merge.remote,
4442                                    &branch,
4443                                    head,
4444                                    state
4445                                        .pr_pushed_head
4446                                        .as_deref()
4447                                        .context("missing public head lease")?,
4448                                )
4449                                .await?
4450                            } else {
4451                                git::push_pinned(
4452                                    &repo,
4453                                    &state.config.merge.remote,
4454                                    &branch,
4455                                    &seen.head,
4456                                )
4457                                .await?
4458                            }
4459                        };
4460                        if !pushed.ok() {
4461                            let why = format!(
4462                                "rebased {branch} but could not push it: {}",
4463                                pushed.stderr.trim()
4464                            );
4465                            stop(state, &repo, &pr, &why).await?;
4466                            return Ok(pr);
4467                        }
4468                        // Bind to what was pushed: until the pull request is
4469                        // seen on it, the rollup is the old head's.
4470                        let head =
4471                            match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
4472                                Ok(head) => head,
4473                                Err(e) => {
4474                                    let why = format!(
4475                                        "rebased and pushed {branch} but could not read the pushed \
4476                                     commit: {e:#}"
4477                                    );
4478                                    stop(state, &repo, &pr, &why).await?;
4479                                    return Ok(pr);
4480                                }
4481                            };
4482                        crate::graph::refresh_reviewed_commits(state, &branch).await;
4483                        state.pr_pushed_head = Some(head.clone());
4484                        awaiting_head = Some(head);
4485                        rechecked = false;
4486                        state.event("land", format!("rebased {branch} onto {base}"));
4487                        state.save()?;
4488                        // The forge has to re-run its checks against the
4489                        // rebased head before anything else can be decided.
4490                        waited = Duration::ZERO;
4491                        tokio::time::sleep(POLL).await;
4492                    }
4493                    // The fixer's rounds are spent (or it could not finish):
4494                    // that is a decision for a person.
4495                    Ok(Some(conflict)) => {
4496                        let why = format!(
4497                            "{} conflicts with {base} and the rebase did not apply: {}",
4498                            pr.url,
4499                            conflict.chars().take(600).collect::<String>()
4500                        );
4501                        stop(state, &repo, &pr, &why).await?;
4502                        return Ok(pr);
4503                    }
4504                    Err(e) => {
4505                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
4506                        stop(state, &repo, &pr, &why).await?;
4507                        return Ok(pr);
4508                    }
4509                }
4510            }
4511            Step::GiveUp { reason } => {
4512                stop(state, &repo, &pr, &reason).await?;
4513                return Ok(pr);
4514            }
4515            Step::Fix { reason } => {
4516                round += 1;
4517                waited = Duration::ZERO;
4518                for c in &pr.review_comments {
4519                    shown.insert(c.body.clone());
4520                }
4521                state.event("land", format!("round {round}: {reason}"));
4522                state.save()?;
4523
4524                let logs = failing_logs(&repo, &seen.failing_urls).await;
4525                let was_red = pr.checks == Checks::Red;
4526                if owner_stop(state, &mut pr, forge).await? {
4527                    return Ok(pr);
4528                }
4529                let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4530                if owner_stop(state, &mut pr, forge).await? {
4531                    return Ok(pr);
4532                }
4533                match fixed {
4534                    Fixed::Committed { head } => {
4535                        // Whatever the next look shows may still be the
4536                        // previous head; see `awaiting_new_head`.
4537                        state.pr_pushed_head = Some(head.clone());
4538                        awaiting_head = Some(head);
4539                        rechecked = false;
4540                        waited = Duration::ZERO;
4541                        forge.poll().await;
4542                    }
4543                    Fixed::Declined if was_red => {
4544                        let why = format!(
4545                            "the fixer produced no commit while {} check(s) were failing \
4546                             ({}); stopping instead of looping on an unchanged tree",
4547                            pr.failing.len(),
4548                            pr.failing.join(", ")
4549                        );
4550                        stop(state, &repo, &pr, &why).await?;
4551                        return Ok(pr);
4552                    }
4553                    // Comment-driven round with no commit: the fixer read the
4554                    // comments and changed nothing, which is a decision it is
4555                    // allowed to make. The comments are recorded as shown, so
4556                    // the next observation sees a clean pull request.
4557                    Fixed::Declined => state.event(
4558                        "land",
4559                        format!("round {round}: fixer declined the comments, nothing committed"),
4560                    ),
4561                    Fixed::Failed(why) => {
4562                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4563                        return Ok(pr);
4564                    }
4565                }
4566                state.save()?;
4567            }
4568        }
4569    }
4570}
4571
4572/// One observation, plus the two things [`PrState`] deliberately does not carry:
4573/// the title (needed for the squash subject) and where the failing checks'
4574/// logs live.
4575#[derive(Clone)]
4576struct Seen {
4577    pr: PrState,
4578    title: String,
4579    failing_urls: Vec<(String, String)>,
4580    /// `headRefOid`: the commit this observation, checks included, is about.
4581    head: String,
4582    /// The commit the checks belong to, read from the same GraphQL node as
4583    /// the checks themselves. Empty when unreadable.
4584    rollup_head: String,
4585    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
4586    /// BEHIND and DRAFT together, and a stop reason has to say which.
4587    merge_state: String,
4588    /// Every check of the rollup, for naming what an armed merge waits on.
4589    contexts: Vec<CheckInfo>,
4590    /// `baseRefName`, to look up which contexts the base requires.
4591    base: String,
4592}
4593
4594/// One check of the rollup as far as a stop reason needs it.
4595#[derive(Clone)]
4596struct CheckInfo {
4597    label: String,
4598    verdict: Verdict,
4599    required: Option<bool>,
4600    /// The workflow run the check belongs to; `None` for an external status.
4601    run: Option<String>,
4602}
4603
4604/// What to do with a red head before a fix round is spent on it.
4605#[derive(Debug, Clone, PartialEq, Eq)]
4606enum RerunStep {
4607    /// Rerun these workflow runs (each not yet rerun for this head).
4608    Rerun(Vec<String>),
4609    /// Reran already and the forge may still report the old failure: wait.
4610    Grace,
4611    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
4612    Fix,
4613}
4614
4615/// Failed checks a rerun may help: a failure of a check that blocks (`required`
4616/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
4617/// pending checks in another run do not delay this run's rerun.
4618fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4619    contexts
4620        .iter()
4621        .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4622        // `gh run rerun --failed` refuses a run with a job still going.
4623        .filter(|c| {
4624            c.run.as_ref().is_none_or(|r| {
4625                !contexts.iter().any(|o| {
4626                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4627                        && o.run.as_ref() == Some(r)
4628                })
4629            })
4630        })
4631        .map(|c| (c.label.clone(), c.run.clone()))
4632        .collect()
4633}
4634
4635/// Pure: rerun once per run id, wait out the grace, then hand over.
4636fn rerun_step(
4637    failing: &[(String, Option<String>)],
4638    record: Option<&LandReruns>,
4639    head: &str,
4640    now: i64,
4641) -> RerunStep {
4642    // A failure with no workflow run (an external status) cannot be rerun, and
4643    // the fixer's push re-runs everything anyway.
4644    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4645        return RerunStep::Fix;
4646    }
4647    // Reruns belong to the head they were made on.
4648    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4649    let mut fresh: Vec<String> = Vec::new();
4650    let mut recent = false;
4651    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4652        match record.and_then(|r| r.runs.get(run)) {
4653            None => {
4654                if !fresh.iter().any(|f| f == run) {
4655                    fresh.push(run.to_owned());
4656                }
4657            }
4658            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4659            Some(_) => {}
4660        }
4661    }
4662    if !fresh.is_empty() {
4663        RerunStep::Rerun(fresh)
4664    } else if recent {
4665        RerunStep::Grace
4666    } else {
4667        RerunStep::Fix
4668    }
4669}
4670
4671/// Read the pull request: `gh pr view` for the top-level thread and merge
4672/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
4673/// inline review comments `gh pr view` does not report.
4674async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
4675    let view = gh(
4676        repo,
4677        &[
4678            "pr".to_owned(),
4679            "view".to_owned(),
4680            pr_url.to_owned(),
4681            "--json".to_owned(),
4682            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
4683                .to_owned(),
4684        ],
4685    )
4686    .await?;
4687    if !view.0 {
4688        bail!("gh pr view {pr_url}: {}", view.1);
4689    }
4690    let number = parse_pr(&view.1)?.number;
4691    let node = last_commit_node(repo, number).await;
4692    let mut seen = seen_from(&view.1, node.as_deref())?;
4693
4694    let inline = gh(
4695        repo,
4696        &[
4697            "api".to_owned(),
4698            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
4699        ],
4700    )
4701    .await?;
4702    if inline.0 {
4703        match parse_inline_comments(&inline.1) {
4704            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
4705            // An unreadable inline thread must not end a landing: the rollup
4706            // and the top-level thread are still real signal.
4707            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
4708        }
4709    } else {
4710        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
4711    }
4712    Ok(seen)
4713}
4714
4715/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
4716/// response. No I/O.
4717///
4718/// The commit oid and the checks are read from the *same* node, so the rollup
4719/// is bound to the commit it belongs to by construction; two reads that agree
4720/// before and after another response prove nothing about what that response
4721/// held. The view's own rollup is never used. A node that is missing,
4722/// unreadable, carries GraphQL errors, or whose checks run past the page
4723/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
4724/// as "look again" and never as a reason to merge.
4725fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
4726    let mut pr = parse_pr(view_json)?;
4727    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
4728
4729    let mut rollup_head = String::new();
4730    let mut failing_urls = Vec::new();
4731    let mut contexts = Vec::new();
4732    let mut checks = Checks::Unknown;
4733    let mut failing = Vec::new();
4734    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
4735        (checks, failing) = rollup_verdict(&rollup);
4736        failing_urls = rollup
4737            .iter()
4738            .filter(|c| c.verdict() == Verdict::Fail)
4739            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
4740            .collect();
4741        contexts = rollup
4742            .iter()
4743            .map(|c| CheckInfo {
4744                label: c.label(),
4745                verdict: c.verdict(),
4746                required: c.is_required,
4747                run: c.url().and_then(run_of),
4748            })
4749            .collect();
4750        rollup_head = oid;
4751    }
4752    pr.checks = checks;
4753    pr.failing = failing;
4754
4755    Ok(Seen {
4756        pr,
4757        title: raw.title,
4758        failing_urls,
4759        head: raw.head_ref_oid,
4760        rollup_head,
4761        merge_state: raw.merge_state_status,
4762        contexts,
4763        base: raw.base_ref_name,
4764    })
4765}
4766
4767/// The last commit's oid and its checks from one GraphQL response, `None`
4768/// when anything about it cannot be trusted.
4769fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
4770    let v: serde_json::Value = serde_json::from_str(json).ok()?;
4771    if v.get("errors").is_some_and(|e| !e.is_null()) {
4772        return None;
4773    }
4774    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
4775    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
4776    let contexts = commit.pointer("/statusCheckRollup/contexts");
4777    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
4778        // No rollup at all: the commit has no checks.
4779        return Some((oid.to_owned(), Vec::new()));
4780    };
4781    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
4782        return None;
4783    }
4784    let nodes = contexts.get("nodes")?.as_array()?;
4785    let rollup = nodes
4786        .iter()
4787        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
4788        .collect::<Result<Vec<_>, _>>()
4789        .ok()?;
4790    Some((oid.to_owned(), rollup))
4791}
4792
4793/// The pull request's last commit and its checks, in one response. `None`
4794/// when the forge could not be asked.
4795async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
4796    let out = gh(
4797        repo,
4798        &[
4799            "api".to_owned(),
4800            "graphql".to_owned(),
4801            "-F".to_owned(),
4802            "owner={owner}".to_owned(),
4803            "-F".to_owned(),
4804            "repo={repo}".to_owned(),
4805            "-F".to_owned(),
4806            format!("number={number}"),
4807            "-f".to_owned(),
4808            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
4809             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
4810             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
4811             ... on CheckRun{name status conclusion detailsUrl \
4812             isRequired(pullRequestNumber:$number)} \
4813             ... on StatusContext{context state targetUrl \
4814             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
4815                .to_owned(),
4816        ],
4817    )
4818    .await
4819    .ok()?;
4820    out.0.then_some(out.1)
4821}
4822
4823/// What a fix round did.
4824#[doc(hidden)]
4825#[derive(Debug, PartialEq)]
4826pub enum Fixed {
4827    /// The fixer committed something, and this is the head that was pushed.
4828    Committed {
4829        /// The commit now at the tip of the pushed branch.
4830        head: String,
4831    },
4832    /// The fixer ran and chose to change nothing.
4833    Declined,
4834    /// The fixer could not run, or said nothing usable.
4835    Failed(String),
4836}
4837
4838/// Hand the failures and the comments to the fixer, then commit and push.
4839///
4840/// The fixer works in the winner's own worktree so its commits land on the
4841/// branch the pull request is built from, and it runs with `allow_write` for
4842/// the same reason.
4843#[doc(hidden)]
4844pub async fn fix_round(
4845    state: &mut RunState,
4846    pr: &PrState,
4847    round: usize,
4848    budget: usize,
4849    reason: &str,
4850    logs: &str,
4851) -> Result<Fixed> {
4852    let winner = state
4853        .winner()
4854        .cloned()
4855        .context("landing needs a winning candidate; none is recorded on this run")?;
4856    let roles = state
4857        .config
4858        .resolve_roles()
4859        .context("resolve the roster for the fix round")?;
4860    // Same chain as the review loop (see `crate::fixer`): the configured
4861    // fixers in order, otherwise the winner's own author continuing its own
4862    // conversation - the competition is over, so its context is pure benefit.
4863    let attempts = crate::fixer::attempts(state, &roles, &winner);
4864    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4865
4866    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
4867    let mut artifacts = agent::artifacts_dir(&state.dir());
4868    if let Some(request) = state
4869        .merge_repairs
4870        .last()
4871        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
4872    {
4873        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
4874    }
4875    let prompt = if state.config.cache_dir().is_some() {
4876        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
4877    } else {
4878        prompt
4879    };
4880    // Read before the fixer runs: it normally commits for itself, so HEAD has
4881    // already moved by the time it returns and a later read would see no
4882    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
4883    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4884    // Each id at most once, forward only: the next one is asked only when the
4885    // call advances, and only the last attempt's result is judged below, so an
4886    // exhausted chain ends as a single failed fixer does.
4887    let mut last = None;
4888    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
4889        if owner_requested_stop(state)? {
4890            return Ok(Fixed::Declined);
4891        }
4892        let mut seat = seat_of(state, &seat_key, &spec.id);
4893        let stem = if i == 0 {
4894            format!("land-{round}")
4895        } else {
4896            format!("land-{round}-{}", spec.id)
4897        };
4898        let out = agent::invoke(
4899            &spec,
4900            &mut seat,
4901            &Invocation {
4902                scope: crate::agent::InvocationScope::Repository,
4903                coordination: state.config.coordination.as_ref(),
4904                fetched_base: state.fetched_base.as_ref(),
4905                cwd: &winner.worktree,
4906                prompt: &prompt,
4907                timeout: Duration::from_secs(state.config.graph.timeout_fix),
4908                allow_write: true,
4909                unsandboxed: false,
4910                sessions: state.config.graph.sessions,
4911                artifacts: &artifacts,
4912                stem: &stem,
4913                run: &state.id,
4914                node: "land",
4915                cache_dir: state.config.cache_dir().as_deref(),
4916                attachments: &[],
4917                writable: &[],
4918            },
4919        )
4920        .await;
4921        state.seats.insert(seat.key.clone(), seat);
4922        if let Some(next) = ids.get(i + 1)
4923            && agent::chain_advances(&out)
4924        {
4925            let (class, why) = crate::fixer::failure_of(&out);
4926            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
4927            continue;
4928        }
4929        last = Some(out);
4930        break;
4931    }
4932    let out = last.expect("the fixer chain always has an entry");
4933
4934    match out {
4935        Ok(o) if o.quota_exhausted() => {
4936            return Ok(Fixed::Failed(
4937                "rate limited (quota); the fixer could not run".to_owned(),
4938            ));
4939        }
4940        Ok(o) if !o.usable() => {
4941            return Ok(Fixed::Failed(format!(
4942                "the fixer produced nothing usable ({})",
4943                crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
4944            )));
4945        }
4946        Ok(_) => {}
4947        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4948        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4949    }
4950
4951    if owner_requested_stop(state)? {
4952        return Ok(Fixed::Declined);
4953    }
4954
4955    // An agent that edited files but never committed would otherwise push
4956    // nothing and look like a refusal.
4957    let wait = crate::repo_lock::WaitPolicy::new(
4958        Duration::from_secs(state.config.graph.timeout_resource_wait),
4959        || false,
4960    );
4961    let coordination = crate::repo_lock::SeatLease::operation_with(
4962        &winner.worktree,
4963        state.config.coordination.as_ref(),
4964        &wait,
4965    )
4966    .await?;
4967    match git::rescue_commit(
4968        &winner.worktree,
4969        &format!("magi: land round {round} fixes (uncommitted work)"),
4970    )
4971    .await
4972    {
4973        Ok(r) => state.note_withheld("land", &r.withheld),
4974        Err(e)
4975            if state
4976                .merge_repairs
4977                .last()
4978                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
4979        {
4980            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
4981        }
4982        Err(_) => {}
4983    }
4984    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4985    drop(coordination);
4986    if after == before {
4987        return Ok(Fixed::Declined);
4988    }
4989
4990    if owner_requested_stop(state)? {
4991        return Ok(Fixed::Declined);
4992    }
4993    crate::pr_head::ensure_owner(state)?;
4994    let remote = state.config.merge.remote.clone();
4995    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
4996    let push = if state.pr_head.is_some() {
4997        let expected = state
4998            .pr_pushed_head
4999            .as_deref()
5000            .context("missing public head lease")?;
5001        git::push_as(
5002            &winner.worktree,
5003            &remote,
5004            &winner.branch,
5005            &public_head,
5006            expected,
5007        )
5008        .await?
5009    } else {
5010        git::push(&winner.worktree, &remote, &winner.branch).await?
5011    };
5012    if !push.ok() {
5013        return Ok(Fixed::Failed(format!(
5014            "pushing {} to {remote} failed: {}",
5015            public_head, push.stderr
5016        )));
5017    }
5018    state.event(
5019        "land",
5020        format!("round {round}: pushed a fix to {public_head}"),
5021    );
5022    state.pr_pushed_head = Some(after.clone());
5023    state.save()?;
5024    Ok(Fixed::Committed { head: after })
5025}
5026
5027/// Fetch or create a seat, keeping its conversation across nodes.
5028pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5029    crate::fixer::seat_for(state, key, agent)
5030}
5031
5032/// Recheck the live PR and local worktree before spending the requested fix.
5033/// A moved/closed PR is held, never repaired using obsolete evidence.
5034pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5035    let seen = observe(&state.repo, url).await?;
5036    let winner = state.winner().context("no winning branch to repair")?;
5037    if seen.pr.state != PrLifecycle::Open || seen.head != head {
5038        anyhow::bail!(
5039            "PR is closed or its head changed; review the current head before requesting repair again"
5040        );
5041    }
5042    if git::rev_parse(&winner.worktree, "HEAD").await? != head
5043        || !git::is_clean(&winner.worktree).await?
5044    {
5045        anyhow::bail!(
5046            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5047        );
5048    }
5049    // Always disable any forge-side reservation, including one not recorded
5050    // by this run. A repair request is never merge authorization.
5051    let view = gh(
5052        &state.repo,
5053        &[
5054            "pr".into(),
5055            "view".into(),
5056            url.into(),
5057            "--json".into(),
5058            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5059        ],
5060    )
5061    .await?;
5062    if !view.0 {
5063        bail!("cannot check auto-merge: {}", view.1);
5064    }
5065    let live: serde_json::Value = serde_json::from_str(&view.1)?;
5066    let public = state.pr_branch().unwrap_or(&winner.branch);
5067    if live["state"] != "OPEN"
5068        || live["headRefOid"] != head
5069        || live["headRefName"] != public
5070        || live["baseRefName"] != state.base_branch
5071        || live["isCrossRepository"] != false
5072    {
5073        bail!("PR changed during repair preflight; review its current head explicitly");
5074    }
5075    let auto_merge = live
5076        .get("autoMergeRequest")
5077        .context("cannot read the PR auto-merge reservation; repair held")?;
5078    if !auto_merge.is_null() {
5079        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5080        if !out.0 {
5081            bail!("cannot disable auto-merge; repair held: {}", out.1);
5082        }
5083    }
5084    Ok(seen.pr)
5085}
5086
5087/// What the fixer is told.
5088fn fix_prompt(
5089    state: &RunState,
5090    pr: &PrState,
5091    round: usize,
5092    budget: usize,
5093    reason: &str,
5094    logs: &str,
5095) -> String {
5096    let mut s = format!(
5097        "Your patch is open as a pull request and it is not landing. Land round \
5098         {round} of {budget}.\n\n\
5099         Pull request: {}\n\n\
5100         What is holding it: {reason}\n\n\
5101         # The task\n\n{}\n",
5102        pr.url, state.instruction
5103    );
5104
5105    if pr.failing.is_empty() {
5106        s.push_str("\n# Failing checks\n\n(none)\n");
5107    } else {
5108        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5109        if logs.trim().is_empty() {
5110            s.push_str(
5111                "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5112            );
5113        } else {
5114            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5115        }
5116    }
5117
5118    if pr.review_comments.is_empty() {
5119        s.push_str("\n# Review comments\n\n(none)\n");
5120    } else {
5121        s.push_str("\n# Review comments\n");
5122        for c in &pr.review_comments {
5123            let where_ = match (&c.path, c.line) {
5124                (Some(p), Some(l)) => format!(" ({p}:{l})"),
5125                (Some(p), None) => format!(" ({p})"),
5126                _ => String::new(),
5127            };
5128            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5129        }
5130    }
5131
5132    s.push_str(
5133        "\n# Rules\n\n\
5134         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5135            failing test; do not silence a lint with an allow attribute; do not \
5136            stretch a timeout to hide a race. If the check is right, the code is \
5137            wrong.\n\
5138         2. Change nothing the checks and the comments did not raise. A \
5139            drive-by refactor turns a one-line fix into a pull request that \
5140            needs reviewing again.\n\
5141         3. If a comment is wrong, say so with a checkable argument and change \
5142            nothing for it. A declined comment with a reason is a correct \
5143            outcome; a change made to appease a reviewer is not.\n\
5144         4. Commit in this worktree. magi pushes to the pull request's branch \
5145            for you; do not push, merge, or close anything yourself.\n\
5146         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5147         # Output\n\n\
5148         Say what you changed and why, and what you declined and why.",
5149    );
5150
5151    let language = &state.config.graph.language;
5152    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5153        let _ = write!(s, "\n\nWrite all prose in {language}.");
5154    }
5155    // After the language line, so the exception is the last word on it.
5156    s.push_str(&crate::prompt::github_rule(
5157        language,
5158        &state.config.graph.github_language,
5159    ));
5160    if let Some(overlay) = state.config.prompts.overlay("fix") {
5161        let _ = write!(s, "\n\n{overlay}");
5162    }
5163    s.push('\n');
5164    s.push_str(crate::prompt::child_run_ownership());
5165    s
5166}
5167
5168/// Failing log tails, the way the operator collects them by hand:
5169/// `gh run view --log-failed`.
5170async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5171    let mut out = String::new();
5172    for (name, url) in failing.iter().take(MAX_LOGS) {
5173        let args = match (job_of(url), run_of(url)) {
5174            (Some(job), _) => vec![
5175                "run".to_owned(),
5176                "view".to_owned(),
5177                "--log-failed".to_owned(),
5178                "--job".to_owned(),
5179                job,
5180            ],
5181            (None, Some(run)) => vec![
5182                "run".to_owned(),
5183                "view".to_owned(),
5184                run,
5185                "--log-failed".to_owned(),
5186            ],
5187            // Not a GitHub Actions check - an external status has no log here.
5188            (None, None) => continue,
5189        };
5190        let (ok, body) = match gh(repo, &args).await {
5191            Ok(v) => v,
5192            Err(e) => (false, format!("{e:#}")),
5193        };
5194        if !ok && body.trim().is_empty() {
5195            continue;
5196        }
5197        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5198    }
5199    out
5200}
5201
5202/// Job id out of a check's `detailsUrl`
5203/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
5204fn job_of(details_url: &str) -> Option<String> {
5205    let after = details_url.split("/job/").nth(1)?;
5206    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5207    (!id.is_empty()).then_some(id)
5208}
5209
5210/// Workflow run id out of a check's `detailsUrl`.
5211pub(crate) fn run_of(details_url: &str) -> Option<String> {
5212    let after = details_url.split("/actions/runs/").nth(1)?;
5213    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5214    (!id.is_empty()).then_some(id)
5215}
5216
5217/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
5218/// it lands on GitHub, not in front of the operator. Pure so a test can hold
5219/// it to that.
5220fn stop_comment(run_id: &str, why: &str) -> String {
5221    format!(
5222        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5223         The branch is untouched and the run is `{run_id}`. Nothing was merged."
5224    )
5225}
5226
5227/// Leave the pull request open, say why on it, and mark the run blocked.
5228///
5229/// The comment is what makes an unattended stop actionable: the operator wakes
5230/// up to a pull request that explains itself rather than to a silent queue.
5231async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5232    let body = stop_comment(&state.id, why);
5233    let (_, mut body) = crate::github_text::prepare(state, "", &body);
5234    if !body.contains(MARKER) {
5235        body = format!("{MARKER}\n{body}");
5236    }
5237    let posted = gh(
5238        repo,
5239        &[
5240            "pr".to_owned(),
5241            "comment".to_owned(),
5242            pr.number.to_string(),
5243            "--body".to_owned(),
5244            body,
5245        ],
5246    )
5247    .await;
5248    match posted {
5249        Ok((true, _)) => {}
5250        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5251        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5252    }
5253    state.status = RunStatus::Blocked;
5254    state.merge = Some(MergeOutcome {
5255        mode: MergeMode::Pr,
5256        ok: false,
5257        detail: why.to_owned(),
5258        empty: false,
5259    });
5260    state.event("land", format!("stopped: {why}"));
5261    state.save()?;
5262    Ok(())
5263}
5264
5265/// Run `gh` in `repo`, returning success and the combined output.
5266///
5267/// Combined because `gh` reports a refused merge on stderr and the pull request
5268/// json on stdout, and both are evidence.
5269///
5270/// `GH_REPO` is stripped from the child's environment: every call site here
5271/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
5272/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
5273/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
5274/// `GH_REPO` the operator happens to have exported for an unrelated script
5275/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
5276/// below) to a different repository than the one actually on disk - which
5277/// for the same-repo guard in [`correct_manual_merge`] would mean the check
5278/// could be made to agree with whatever repository a forged `--merged` URL
5279/// claims, defeating it entirely.
5280pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5281    let out = tokio::process::Command::new("gh")
5282        .args(args)
5283        .current_dir(cwd)
5284        .env_remove("GH_REPO")
5285        .quiet()
5286        .stdin(std::process::Stdio::null())
5287        .output()
5288        .await
5289        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5290    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5291    let err = String::from_utf8_lossy(&out.stderr);
5292    if body.trim().is_empty() {
5293        body = err.into_owned();
5294    } else if !err.trim().is_empty() {
5295        body.push_str(&err);
5296    }
5297    Ok((out.status.success(), body.trim().to_owned()))
5298}
5299
5300/// Verdict of one entry in the status rollup.
5301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5302pub(crate) enum Verdict {
5303    Pass,
5304    Fail,
5305    Pending,
5306    Unknown,
5307}
5308
5309impl Verdict {
5310    /// Short operator-facing check result.
5311    pub(crate) fn label(self) -> &'static str {
5312        match self {
5313            Self::Pass => "passed",
5314            Self::Fail => "failed",
5315            Self::Pending => "pending",
5316            Self::Unknown => "unknown",
5317        }
5318    }
5319}
5320
5321#[derive(Debug, Deserialize)]
5322#[serde(rename_all = "camelCase")]
5323struct GhPr {
5324    #[serde(default)]
5325    url: String,
5326    #[serde(default)]
5327    number: u64,
5328    #[serde(default)]
5329    state: String,
5330    #[serde(default)]
5331    title: String,
5332    #[serde(default)]
5333    status_check_rollup: Vec<GhCheck>,
5334    /// GitHub's own verdict on whether the pull request can be merged.
5335    ///
5336    /// Worth asking for because it is the only place the *required* check set
5337    /// is applied: the rollup lists every check equally, so a repository that
5338    /// deliberately does not require `coverage` still looks red here. See
5339    /// [`Blocking`].
5340    #[serde(default)]
5341    merge_state_status: String,
5342    /// The commit the pull request currently points at. Compared with the
5343    /// commit a fix round pushed, it is how the loop knows the forge has moved
5344    /// on and the rollup belongs to the new head.
5345    #[serde(default)]
5346    head_ref_oid: String,
5347    #[serde(default)]
5348    base_ref_name: String,
5349    #[serde(default)]
5350    reviews: Vec<GhReview>,
5351    #[serde(default)]
5352    comments: Vec<GhComment>,
5353}
5354
5355/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
5356/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
5357/// status API, which is how CodeRabbit reports - has `context`/`state` and no
5358/// conclusion at all.
5359#[derive(Debug, Deserialize)]
5360#[serde(rename_all = "camelCase")]
5361struct GhCheck {
5362    #[serde(default)]
5363    name: Option<String>,
5364    #[serde(default)]
5365    context: Option<String>,
5366    #[serde(default)]
5367    status: Option<String>,
5368    #[serde(default)]
5369    conclusion: Option<String>,
5370    #[serde(default)]
5371    state: Option<String>,
5372    #[serde(default)]
5373    details_url: Option<String>,
5374    #[serde(default)]
5375    target_url: Option<String>,
5376    /// Whether the base branch requires this check. Only the GraphQL node
5377    /// carries it; `None` is "not read", never "not required".
5378    #[serde(default)]
5379    is_required: Option<bool>,
5380}
5381
5382impl GhCheck {
5383    /// Name to show a human and hand to the fixer.
5384    fn label(&self) -> String {
5385        self.name
5386            .clone()
5387            .or_else(|| self.context.clone())
5388            .unwrap_or_else(|| "(unnamed check)".to_owned())
5389    }
5390
5391    /// Where this check's logs live, when it has any.
5392    fn url(&self) -> Option<&str> {
5393        self.details_url
5394            .as_deref()
5395            .or(self.target_url.as_deref())
5396            .filter(|u| !u.is_empty())
5397    }
5398
5399    /// Did it pass?
5400    ///
5401    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
5402    /// skips release and bot pull requests by design, and a skip that blocked
5403    /// landing would block exactly the pull requests that need no review.
5404    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
5405    /// merging over one is merging over a check that never ran.
5406    fn verdict(&self) -> Verdict {
5407        if let Some(status) = self.status.as_deref() {
5408            if !status.eq_ignore_ascii_case("COMPLETED") {
5409                return Verdict::Pending;
5410            }
5411        }
5412        let outcome = self
5413            .conclusion
5414            .as_deref()
5415            .or(self.state.as_deref())
5416            .unwrap_or("");
5417        match outcome.to_ascii_uppercase().as_str() {
5418            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5419            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5420            | "ACTION_REQUIRED" => Verdict::Fail,
5421            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5422                Verdict::Pending
5423            }
5424            _ => Verdict::Unknown,
5425        }
5426    }
5427}
5428
5429#[derive(Debug, Deserialize)]
5430struct GhAuthor {
5431    #[serde(default)]
5432    login: String,
5433}
5434
5435#[derive(Debug, Deserialize)]
5436struct GhReview {
5437    #[serde(default)]
5438    author: GhAuthor,
5439    #[serde(default)]
5440    body: String,
5441}
5442
5443#[derive(Debug, Deserialize)]
5444struct GhComment {
5445    #[serde(default)]
5446    author: GhAuthor,
5447    #[serde(default)]
5448    body: String,
5449}
5450
5451#[derive(Debug, Deserialize)]
5452struct GhUser {
5453    #[serde(default)]
5454    login: String,
5455}
5456
5457#[derive(Debug, Deserialize)]
5458struct GhInline {
5459    #[serde(default)]
5460    user: GhUser,
5461    #[serde(default)]
5462    path: Option<String>,
5463    #[serde(default)]
5464    line: Option<u64>,
5465    #[serde(default)]
5466    body: String,
5467}
5468
5469impl Default for GhAuthor {
5470    fn default() -> Self {
5471        Self {
5472            login: "(unknown)".to_owned(),
5473        }
5474    }
5475}
5476
5477impl Default for GhUser {
5478    fn default() -> Self {
5479        Self {
5480            login: "(unknown)".to_owned(),
5481        }
5482    }
5483}
5484
5485#[cfg(test)]
5486pub(crate) mod tests {
5487    use super::*;
5488    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5489
5490    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5491        format!(
5492            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5493        )
5494    }
5495
5496    #[test]
5497    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5498        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5499        assert_eq!(
5500            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5501            Ok(())
5502        );
5503        for (json, why) in [
5504            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5505            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5506            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5507            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5508            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5509            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5510        ] {
5511            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5512                .unwrap_err()
5513                .why;
5514            assert!(err.contains(why), "{json}: {err}");
5515        }
5516        // A head that moved on past what was verified is left alone.
5517        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5518        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5519        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5520        assert!(
5521            !closable(
5522                &head_json("x", "main", "OPEN", false),
5523                "magi/27b2/A",
5524                "main",
5525                &[]
5526            )
5527            .unwrap_err()
5528            .retry
5529        );
5530        assert!(is_forge_url("https://github.com/o/r.git"));
5531        assert!(is_forge_url("git@github.com:o/r.git"));
5532        assert!(!is_forge_url("/tmp/origin.git"));
5533        assert!(!is_forge_url("C:\\work\\origin.git"));
5534        assert!(!is_forge_url("file:///tmp/origin.git"));
5535        assert!(forge_unavailable(
5536            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5537        ));
5538        assert!(!forge_unavailable(
5539            "gh pr list failed: error connecting to api.github.com"
5540        ));
5541        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5542        // A record that does not say whether it is a fork is not trusted.
5543        assert!(
5544            closable(
5545                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5546                "b",
5547                "main",
5548                &["aaa".to_owned()]
5549            )
5550            .is_err()
5551        );
5552    }
5553
5554    #[test]
5555    fn the_close_comment_names_the_commit_on_the_base() {
5556        let e = crate::already::Evidence {
5557            proof: crate::already::Proof::PatchId,
5558            tip: "1234567890".to_owned(),
5559            commits: vec!["0e368de0000".to_owned()],
5560        };
5561        let c = superseded_comment("main", &e);
5562        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5563    }
5564
5565    /// 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.
5566    const GREEN_OPEN: &str = r####"{
5567  "url": "https://github.com/yukimemi/magi/pull/10",
5568  "number": 10,
5569  "state": "OPEN",
5570  "mergeStateStatus": "CLEAN",
5571  "statusCheckRollup": [
5572    {
5573      "__typename": "CheckRun",
5574      "conclusion": "SKIPPED",
5575      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5576      "name": "review",
5577      "status": "COMPLETED",
5578      "workflowName": "claude-review"
5579    },
5580    {
5581      "__typename": "CheckRun",
5582      "conclusion": "SUCCESS",
5583      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5584      "name": "check (ubuntu-latest)",
5585      "status": "COMPLETED",
5586      "workflowName": "CI"
5587    },
5588    {
5589      "__typename": "CheckRun",
5590      "conclusion": "SUCCESS",
5591      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5592      "name": "rustfmt",
5593      "status": "COMPLETED",
5594      "workflowName": "CI"
5595    },
5596    {
5597      "__typename": "StatusContext",
5598      "context": "CodeRabbit",
5599      "state": "SUCCESS",
5600      "targetUrl": ""
5601    }
5602  ],
5603  "reviews": [],
5604  "comments": [
5605    {
5606      "author": {
5607        "login": "coderabbitai"
5608      },
5609      "authorAssociation": "NONE",
5610      "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"
5611    }
5612  ]
5613}"####;
5614
5615    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
5616    const RED_OPEN: &str = r####"{
5617  "url": "https://github.com/yukimemi/magi/pull/9",
5618  "number": 9,
5619  "state": "OPEN",
5620  "mergeStateStatus": "UNSTABLE",
5621  "statusCheckRollup": [
5622    {
5623      "__typename": "CheckRun",
5624      "conclusion": "SUCCESS",
5625      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5626      "name": "check (ubuntu-latest)",
5627      "status": "COMPLETED",
5628      "workflowName": "CI"
5629    },
5630    {
5631      "__typename": "CheckRun",
5632      "conclusion": "SUCCESS",
5633      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5634      "name": "rustfmt",
5635      "status": "COMPLETED",
5636      "workflowName": "CI"
5637    },
5638    {
5639      "__typename": "CheckRun",
5640      "conclusion": "FAILURE",
5641      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5642      "name": "editorconfig",
5643      "status": "COMPLETED",
5644      "workflowName": "CI"
5645    },
5646    {
5647      "__typename": "StatusContext",
5648      "context": "CodeRabbit",
5649      "state": "SUCCESS",
5650      "targetUrl": ""
5651    }
5652  ],
5653  "reviews": [],
5654  "comments": [
5655    {
5656      "author": {
5657        "login": "coderabbitai"
5658      },
5659      "authorAssociation": "NONE",
5660      "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"
5661    }
5662  ]
5663}"####;
5664
5665    /// 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.
5666    const PENDING_OPEN: &str = r####"{
5667  "url": "https://github.com/yukimemi/magi/pull/9",
5668  "number": 9,
5669  "state": "OPEN",
5670  "statusCheckRollup": [
5671    {
5672      "__typename": "CheckRun",
5673      "conclusion": "SUCCESS",
5674      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5675      "name": "check (ubuntu-latest)",
5676      "status": "COMPLETED",
5677      "workflowName": "CI"
5678    },
5679    {
5680      "__typename": "CheckRun",
5681      "conclusion": "SUCCESS",
5682      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5683      "name": "rustfmt",
5684      "status": "COMPLETED",
5685      "workflowName": "CI"
5686    },
5687    {
5688      "__typename": "CheckRun",
5689      "conclusion": null,
5690      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5691      "name": "editorconfig",
5692      "status": "IN_PROGRESS",
5693      "workflowName": "CI"
5694    },
5695    {
5696      "__typename": "StatusContext",
5697      "context": "CodeRabbit",
5698      "state": "SUCCESS",
5699      "targetUrl": ""
5700    }
5701  ],
5702  "reviews": [],
5703  "comments": []
5704}"####;
5705
5706    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
5707    const MERGED: &str = r####"{
5708  "url": "https://github.com/yukimemi/magi/pull/16",
5709  "number": 16,
5710  "state": "MERGED",
5711  "statusCheckRollup": [
5712    {
5713      "__typename": "CheckRun",
5714      "conclusion": "SUCCESS",
5715      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
5716      "name": "check (ubuntu-latest)",
5717      "status": "COMPLETED",
5718      "workflowName": "CI"
5719    },
5720    {
5721      "__typename": "CheckRun",
5722      "conclusion": "SUCCESS",
5723      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
5724      "name": "review",
5725      "status": "COMPLETED",
5726      "workflowName": "claude-review"
5727    }
5728  ],
5729  "reviews": [],
5730  "comments": []
5731}"####;
5732
5733    /// 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.
5734    const REVIEWED_OPEN: &str = r####"{
5735  "url": "https://github.com/yukimemi/magi/pull/12",
5736  "number": 12,
5737  "state": "OPEN",
5738  "statusCheckRollup": [
5739    {
5740      "__typename": "CheckRun",
5741      "conclusion": "SUCCESS",
5742      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
5743      "name": "check (ubuntu-latest)",
5744      "status": "COMPLETED",
5745      "workflowName": "CI"
5746    },
5747    {
5748      "__typename": "CheckRun",
5749      "conclusion": "SUCCESS",
5750      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
5751      "name": "review",
5752      "status": "COMPLETED",
5753      "workflowName": "claude-review"
5754    }
5755  ],
5756  "reviews": [
5757    {
5758      "author": {
5759        "login": "claude"
5760      },
5761      "state": "COMMENTED",
5762      "body": ""
5763    }
5764  ],
5765  "comments": [
5766    {
5767      "author": {
5768        "login": "coderabbitai"
5769      },
5770      "authorAssociation": "NONE",
5771      "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"
5772    },
5773    {
5774      "author": {
5775        "login": "claude"
5776      },
5777      "authorAssociation": "NONE",
5778      "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"
5779    }
5780  ]
5781}"####;
5782
5783    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
5784    const INLINE: &str = r####"[
5785  {
5786    "user": {
5787      "login": "claude[bot]"
5788    },
5789    "path": "src/graph.rs",
5790    "line": 231,
5791    "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"
5792  }
5793]"####;
5794
5795    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
5796    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
5797<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
5798
5799> [!IMPORTANT]
5800> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
5801> 
5802> This repository does not receive automatic reviews because it has fewer than 10 stars.
5803> 
5804> <details>
5805> <summary>⚙️ Run configuration</summary>
5806> 
5807> **Configuration used**: defaults
5808> 
5809> **Review profile**: CHILL
5810> 
5811> **Plan**: Team
5812> 
5813> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
5814> 
5815> </details>
5816
5817<!-- end of auto-generated comment: skip review by coderabbit.ai -->
5818
5819<!-- tips_start -->
5820
5821---
5822
5823Thanks 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.
5824
5825<details>
5826<summary>❤️ Share</summary>
5827
5828- [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"####;
5829
5830    /// 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.
5831    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5832
5833---
5834### Reviewing PR #16
5835
5836- [x] Read AGENTS.md conventions
5837- [x] Review `src/daemon.rs` changes
5838- [x] Review `src/main.rs` changes (new `doctor` reporting)
5839- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5840- [x] Check test coverage for new behavior
5841- [x] Run verification commands (blocked — see note)
5842- [x] Post findings"####;
5843
5844    /// The same job's real comment on pull request #12 once it had something to say.
5845    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5846
5847---
5848### Review: `magi review <branch>` — cheap-half-only graph
5849
5850Read 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.
5851
5852**Correctness**
5853
5854- 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"####;
5855
5856    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5857        PrState {
5858            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5859            number: 16,
5860            state: PrLifecycle::Open,
5861            checks,
5862            // These tests are about red-means-fix, so a red here is one the
5863            // forge gates on. Without saying so they would assert the new
5864            // "merge past a check nobody requires" path by accident.
5865            blocking: if matches!(checks, Checks::Red) {
5866                Blocking::Yes
5867            } else {
5868                Blocking::No
5869            },
5870            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5871            review_comments: (0..comments)
5872                .map(|i| ReviewComment {
5873                    author: "coderabbitai".to_owned(),
5874                    path: Some("src/graph.rs".to_owned()),
5875                    line: Some(231),
5876                    body: format!("finding {i}"),
5877                })
5878                .collect(),
5879        }
5880    }
5881
5882    #[test]
5883    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5884        use CiExpectation::{Absent, Expected};
5885        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5886            let p = pr(checks, &["x"], 0);
5887            for waited in [Duration::ZERO, CHECKS_GRACE] {
5888                assert_eq!(
5889                    decide_with(&p, 0, 4, waited, Expected),
5890                    decide(&p, 0, 4, waited)
5891                );
5892            }
5893        }
5894        // Nothing will ever report: no wait, no give-up, no fix round.
5895        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5896            let p = pr(checks, &["x"], 0);
5897            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5898            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5899        }
5900        // A conflict is not a check: it still wants the rebase.
5901        let mut p = pr(Checks::Unknown, &[], 0);
5902        p.blocking = Blocking::Conflict;
5903        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5904        // And a pull request that left our hands is still done.
5905        p.state = PrLifecycle::Merged;
5906        assert_eq!(
5907            decide_with(&p, 0, 4, Duration::ZERO, Absent),
5908            Step::Done { merged: true }
5909        );
5910    }
5911
5912    #[test]
5913    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5914        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5915        assert_eq!(state.number, 10);
5916        assert_eq!(state.state, PrLifecycle::Open);
5917        assert_eq!(state.checks, Checks::Green);
5918        assert!(state.failing.is_empty());
5919        assert!(
5920            state.review_comments.is_empty(),
5921            "the only comment is CodeRabbit's trigger notice: {:?}",
5922            state.review_comments
5923        );
5924        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5925    }
5926
5927    #[test]
5928    fn a_failing_check_parses_as_red_and_is_named() {
5929        let state = parse_pr(RED_OPEN).expect("red fixture parses");
5930        assert_eq!(state.checks, Checks::Red);
5931        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5932        // The captured payload says `UNSTABLE` - mergeable, with a check
5933        // nobody requires red - which is exactly the shape that had to be
5934        // merged by hand. Asserted separately, in
5935        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
5936        // test is about is that a red check is *named*, so the reason a fixer
5937        // is handed says which one; so it asks the blocking question here.
5938        let mut blocking = state.clone();
5939        blocking.blocking = Blocking::Yes;
5940        match decide(&blocking, 0, 4, Duration::ZERO) {
5941            Step::Fix { reason } => {
5942                assert!(reason.contains("editorconfig"), "reason: {reason}");
5943                assert!(reason.contains("failing"), "reason: {reason}");
5944            }
5945            other => panic!("expected a fix round, got {other:?}"),
5946        }
5947    }
5948
5949    #[test]
5950    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5951        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5952        assert_eq!(state.checks, Checks::Pending);
5953        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5954    }
5955
5956    #[test]
5957    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5958        let state = parse_pr(MERGED).expect("merged fixture parses");
5959        assert_eq!(state.state, PrLifecycle::Merged);
5960        assert_eq!(
5961            decide(&state, 0, 4, Duration::ZERO),
5962            Step::Done { merged: true }
5963        );
5964    }
5965
5966    #[test]
5967    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5968        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5969        assert_eq!(state.checks, Checks::Green);
5970        let authors: Vec<&str> = state
5971            .review_comments
5972            .iter()
5973            .map(|c| c.author.as_str())
5974            .collect();
5975        assert_eq!(
5976            authors,
5977            vec!["claude"],
5978            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5979        );
5980        match decide(&state, 0, 4, Duration::ZERO) {
5981            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5982            other => panic!("expected a fix round, got {other:?}"),
5983        }
5984    }
5985
5986    #[test]
5987    fn inline_review_comments_keep_their_file_and_line() {
5988        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5989        assert_eq!(comments.len(), 1);
5990        assert_eq!(comments[0].author, "claude[bot]");
5991        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5992        assert_eq!(comments[0].line, Some(231));
5993        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5994    }
5995
5996    #[test]
5997    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5998        assert!(
5999            is_noise(CODERABBIT_TRIGGER),
6000            "CodeRabbit's trigger notice declares itself not a review"
6001        );
6002        assert!(
6003            is_noise(CLAUDE_CHECKLIST),
6004            "a progress checklist asks for nothing"
6005        );
6006        assert!(
6007            !is_noise(CLAUDE_FINDING),
6008            "a review that names a bug is input, not noise"
6009        );
6010
6011        let mut clean = pr(Checks::Green, &[], 0);
6012        clean.review_comments.push(ReviewComment {
6013            author: "coderabbitai".to_owned(),
6014            path: None,
6015            line: None,
6016            body: CODERABBIT_TRIGGER.to_owned(),
6017        });
6018        clean.review_comments.retain(|c| !is_noise(&c.body));
6019        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6020
6021        let mut found = pr(Checks::Green, &[], 0);
6022        found.review_comments.push(ReviewComment {
6023            author: "claude".to_owned(),
6024            path: None,
6025            line: None,
6026            body: CLAUDE_FINDING.to_owned(),
6027        });
6028        found.review_comments.retain(|c| !is_noise(&c.body));
6029        assert!(matches!(
6030            decide(&found, 0, 4, Duration::ZERO),
6031            Step::Fix { .. }
6032        ));
6033    }
6034
6035    #[test]
6036    fn the_policy_table_holds_for_every_combination_that_matters() {
6037        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6038            (
6039                "pending checks are waited for, even on the last round",
6040                pr(Checks::Pending, &[], 0),
6041                4,
6042                4,
6043                Duration::ZERO,
6044                Step::Wait,
6045            ),
6046            (
6047                "red checks are fixed",
6048                pr(Checks::Red, &["editorconfig"], 0),
6049                0,
6050                4,
6051                Duration::ZERO,
6052                Step::Fix {
6053                    reason: "1 check(s) failing: editorconfig".to_owned(),
6054                },
6055            ),
6056            (
6057                "green with comments is fixed, not merged",
6058                pr(Checks::Green, &[], 2),
6059                1,
6060                4,
6061                Duration::ZERO,
6062                Step::Fix {
6063                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6064                        .to_owned(),
6065                },
6066            ),
6067            (
6068                "green and clean merges",
6069                pr(Checks::Green, &[], 0),
6070                3,
6071                4,
6072                Duration::ZERO,
6073                Step::Merge,
6074            ),
6075            (
6076                "an unreadable rollup is waited on while the grace lasts",
6077                pr(Checks::Unknown, &[], 0),
6078                0,
6079                4,
6080                Duration::ZERO,
6081                Step::Wait,
6082            ),
6083            (
6084                "an unreadable rollup is never merged once the grace is spent",
6085                pr(Checks::Unknown, &[], 0),
6086                0,
6087                4,
6088                CHECKS_GRACE,
6089                Step::GiveUp {
6090                    reason: "no check status is readable on the pull request after 3 minute(s); \
6091                             refusing to merge on a guess"
6092                        .to_owned(),
6093                },
6094            ),
6095        ];
6096        for (what, state, round, budget, waited, want) in cases {
6097            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6098        }
6099    }
6100
6101    #[test]
6102    fn the_forge_verdict_survives_the_round_trip_from_gh() {
6103        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
6104        // requested but never parsed is the kind of thing that looks wired up
6105        // and answers `Unsaid` forever.
6106        let green = parse_pr(GREEN_OPEN).expect("parse");
6107        assert_eq!(green.blocking, Blocking::No);
6108        let red = parse_pr(RED_OPEN).expect("parse");
6109        assert_eq!(
6110            red.blocking,
6111            Blocking::No,
6112            "`UNSTABLE` is mergeable: the red check is one nobody requires"
6113        );
6114        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6115        // A payload from an older `gh` has no such field at all.
6116        let quiet =
6117            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6118        assert_eq!(quiet.blocking, Blocking::Unsaid);
6119    }
6120
6121    #[test]
6122    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6123        // Pull request 37's only red check was `editorconfig`, failing
6124        // because the action could not fetch its own binary after
6125        // editorconfig-checker v4 renamed its release assets. The repository
6126        // does not require it. magi answered by asking a fixer to repair a
6127        // change that was fine, and the pull request had to be merged by hand.
6128        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6129        nonblocking.blocking = Blocking::No;
6130        assert_eq!(
6131            decide(&nonblocking, 0, 4, Duration::ZERO),
6132            Step::Merge,
6133            "the forge says nothing is in the way, so nothing is"
6134        );
6135
6136        // The same red, gated on: that is a fix round, as before.
6137        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6138        blocking.blocking = Blocking::Yes;
6139        assert!(matches!(
6140            decide(&blocking, 0, 4, Duration::ZERO),
6141            Step::Fix { .. }
6142        ));
6143
6144        // A review comment still outranks green-enough: a non-required red
6145        // must not become a way to merge past an unanswered reviewer.
6146        let mut commented = pr(Checks::Red, &["coverage"], 1);
6147        commented.blocking = Blocking::No;
6148        assert!(matches!(
6149            decide(&commented, 0, 4, Duration::ZERO),
6150            Step::Fix { .. }
6151        ));
6152
6153        // And silence from the forge is not consent.
6154        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6155        unsaid.blocking = Blocking::Unsaid;
6156        assert!(matches!(
6157            decide(&unsaid, 0, 4, Duration::ZERO),
6158            Step::Fix { .. }
6159        ));
6160    }
6161
6162    #[test]
6163    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6164        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6165        red.blocking = Blocking::No;
6166        assert_eq!(
6167            decide(&red, 0, 4, Duration::ZERO),
6168            Step::Merge,
6169            "announcing must not change the decision"
6170        );
6171        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6172        assert!(said.contains("yukimemi/magi"), "{said}");
6173        assert!(said.contains("#16"), "{said}");
6174        assert!(
6175            said.contains("https://github.com/yukimemi/magi/pull/16"),
6176            "{said}"
6177        );
6178        assert!(
6179            said.contains("test (windows-latest)") && said.contains("coverage"),
6180            "{said}"
6181        );
6182
6183        // `failing` can be left over on a green observation; only `checks` counts.
6184        let green = pr(Checks::Green, &["stale"], 0);
6185        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6186    }
6187
6188    #[test]
6189    fn red_merge_notices_omit_empty_check_details() {
6190        for checks in [vec![], vec!["", "  "]] {
6191            let red = pr(Checks::Red, &checks, 0);
6192            let summary = red_merge_summary("owner/repo", &red).unwrap();
6193            let notice = crate::notices::merged_red("run-1", &summary);
6194            assert_eq!(
6195                notice.message,
6196                format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6197            );
6198            assert_eq!(notice.severity, crate::notices::Severity::Warn);
6199        }
6200        let red = pr(Checks::Red, &["", " test ", " "], 0);
6201        let summary = red_merge_summary("owner/repo", &red).unwrap();
6202        assert!(summary.contains("with red checks: test ("), "{summary}");
6203    }
6204
6205    #[test]
6206    fn the_repo_label_comes_from_the_pull_request_url() {
6207        let p = Path::new("/tmp/checkout");
6208        assert_eq!(
6209            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6210            "yukimemi/magi"
6211        );
6212        assert_eq!(repo_label(p, "not a url"), "checkout");
6213    }
6214
6215    #[test]
6216    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6217        // Pull requests 35 and 37 were both rebased by hand: a competition
6218        // that runs for two hours against a repository merging pull requests
6219        // all day conflicts on the way in, and that is arithmetic rather
6220        // than a defect in the change.
6221        let mut conflicted = pr(Checks::Green, &[], 0);
6222        conflicted.blocking = Blocking::Conflict;
6223        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6224
6225        // Decided before the checks, and even with the rounds spent: every
6226        // check on a branch that cannot land is an answer about a state that
6227        // cannot land, and a conflict is not the change's fault.
6228        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6229        red.blocking = Blocking::Conflict;
6230        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6231
6232        // The lifecycle still wins over everything, conflict included.
6233        let mut merged = pr(Checks::Red, &[], 0);
6234        merged.blocking = Blocking::Conflict;
6235        merged.state = PrLifecycle::Merged;
6236        assert_eq!(
6237            decide(&merged, 0, 4, Duration::ZERO),
6238            Step::Done { merged: true }
6239        );
6240    }
6241
6242    #[test]
6243    fn the_forge_verdict_is_read_off_merge_state_status() {
6244        // The spellings that mean "mergeable". `UNSTABLE` is the one that
6245        // matters: mergeable, with a non-required check red or still running.
6246        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6247            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6248            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6249        }
6250        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6251        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6252        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6253        // An older `gh`, or a token without the scope, says nothing - and
6254        // refusing to guess is the rule everywhere else in this module.
6255        for quiet in ["", "UNKNOWN"] {
6256            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6257            assert!(Blocking::of(quiet).stops_a_merge());
6258        }
6259    }
6260
6261    #[test]
6262    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6263        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6264        // The exact stderr from run ec12, in a jj-colocated repository.
6265        let jj = "could not determine current branch: failed to run git: not on any branch";
6266
6267        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6268            .expect("the forge says merged, so it merged");
6269        assert!(landed.ok);
6270        assert!(
6271            landed.detail.contains("but the pull request is merged"),
6272            "the record must not read as a clean success: {}",
6273            landed.detail
6274        );
6275        assert!(
6276            landed.detail.contains("not on any branch"),
6277            "and it must keep what the command actually said: {}",
6278            landed.detail
6279        );
6280
6281        // A pull request still open means the merge really failed.
6282        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6283        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6284        // And an unreadable answer is not evidence of success.
6285        assert!(merged_after_all(&argv, jj, None).is_none());
6286    }
6287
6288    #[test]
6289    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6290        let mut state = pr(Checks::Red, &["editorconfig"], 3);
6291        state.state = PrLifecycle::Closed;
6292        assert_eq!(
6293            decide(&state, 0, 4, Duration::ZERO),
6294            Step::Done { merged: false },
6295            "a human closing the pull request ends the loop, whatever CI says"
6296        );
6297    }
6298
6299    #[test]
6300    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6301        let red = decide(
6302            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6303            4,
6304            4,
6305            Duration::ZERO,
6306        );
6307        match red {
6308            Step::GiveUp { reason } => {
6309                assert!(reason.contains("editorconfig"), "reason: {reason}");
6310                assert!(reason.contains("test (macos)"), "reason: {reason}");
6311                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6312            }
6313            other => panic!("expected a give-up, got {other:?}"),
6314        }
6315
6316        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6317        match commented {
6318            Step::GiveUp { reason } => {
6319                assert!(reason.contains("unresolved"), "reason: {reason}");
6320                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6321            }
6322            other => panic!("expected a give-up, got {other:?}"),
6323        }
6324    }
6325
6326    #[test]
6327    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6328        let candidate_commit = "magi: candidate A (uncommitted work)";
6329        let subject = merge_subject(candidate_commit, "add retries to the uploader");
6330        let argv = merge_argv(16, &subject);
6331
6332        assert!(argv.contains(&"--squash".to_owned()));
6333        assert!(argv.contains(&"--delete-branch".to_owned()));
6334        assert!(argv.contains(&"--subject".to_owned()));
6335        assert_eq!(
6336            argv.last().map(String::as_str),
6337            Some("add retries to the uploader"),
6338            "the subject must not be the candidate commit message"
6339        );
6340        assert_ne!(subject, candidate_commit);
6341    }
6342
6343    #[test]
6344    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6345        assert_eq!(
6346            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6347            "feat: a queue, an unattended loop, and a phone UI"
6348        );
6349        assert_eq!(
6350            merge_subject("", "# port the retry logic\n\ndetails"),
6351            "port the retry logic",
6352            "an empty title falls back to the task's first line, heading marks stripped"
6353        );
6354    }
6355
6356    #[test]
6357    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6358        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6359        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6360        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6361        assert_eq!(job_of("https://coderabbit.ai/status"), None);
6362        assert_eq!(run_of(""), None);
6363    }
6364
6365    #[test]
6366    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6367        let mut out = Vec::new();
6368        push_if_outstanding(
6369            &mut out,
6370            ReviewComment {
6371                author: "yukimemi".to_owned(),
6372                path: None,
6373                line: None,
6374                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6375            },
6376        );
6377        assert!(out.is_empty());
6378    }
6379
6380    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
6381    /// falls back to the repository - which keeps these tests free of a
6382    /// worktree, a `git` invocation and a network.
6383    fn run_state() -> RunState {
6384        let mut state = RunState::new(
6385            std::path::PathBuf::from("/repo/magi"),
6386            "main".to_owned(),
6387            "abcdef1234".to_owned(),
6388            "add retries to the uploader".to_owned(),
6389            crate::config::Config::default(),
6390        );
6391        // Tests run in parallel against one persistent home and the ids
6392        // `RunState::new` draws from the clock can repeat, so two tests would
6393        // share a run's questions. The home also outlives the process, so the
6394        // counter alone would reuse an earlier run's ids.
6395        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6396        let nanos = std::time::SystemTime::now()
6397            .duration_since(std::time::UNIX_EPOCH)
6398            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6399        state.id = format!(
6400            "20261004-{nanos:06}-{:04x}",
6401            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6402        );
6403        state
6404    }
6405
6406    fn green_pr() -> PrState {
6407        PrState {
6408            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6409            number: 42,
6410            state: PrLifecycle::Open,
6411            checks: Checks::Green,
6412            // The forge sees nothing in the way unless a test says otherwise.
6413            blocking: Blocking::No,
6414            failing: Vec::new(),
6415            review_comments: vec![ReviewComment {
6416                author: "coderabbitai".to_owned(),
6417                path: Some("src/land.rs".to_owned()),
6418                line: Some(212),
6419                body: "this branch never checks the exit code".to_owned(),
6420            }],
6421        }
6422    }
6423
6424    #[test]
6425    fn land_fixer_defers_review_and_verification_without_a_cache() {
6426        let state = run_state();
6427        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6428        assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
6429        assert!(p.contains("Commit in this worktree"), "{p}");
6430    }
6431
6432    #[test]
6433    fn github_facing_land_text_is_english_whatever_the_language() {
6434        let mut state = run_state();
6435        state.config.graph.language = "ja".to_owned();
6436        let comment = stop_comment(&state.id, "checks are still red");
6437        assert!(crate::github_text::check("", &comment).is_empty());
6438        assert!(comment.is_ascii(), "{comment}");
6439        assert!(comment.starts_with(MARKER));
6440
6441        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6442        let ja_at = p.find("Write all prose in ja").unwrap();
6443        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6444        assert!(ja_at < rule_at, "{p}");
6445        assert!(p.contains("stays in Japanese"), "{p}");
6446
6447        state.config.graph.language = "en".to_owned();
6448        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6449        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6450        assert!(!p.contains("does not apply"), "{p}");
6451    }
6452
6453    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6454
6455    fn panel() -> String {
6456        approval_panel(
6457            &run_state(),
6458            &green_pr(),
6459            NUMSTAT,
6460            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6461            &[
6462                "land: ask before merging".to_owned(),
6463                "land: colour the diff".to_owned(),
6464            ],
6465            "feat: merge approval from the phone",
6466        )
6467    }
6468
6469    #[test]
6470    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6471        let html = panel();
6472        for needle in [
6473            "42",
6474            "main",
6475            "src/land.rs",
6476            "src/web.rs",
6477            "assets/logo.png",
6478            "feat: merge approval from the phone",
6479            "land: ask before merging",
6480            "land: colour the diff",
6481            "coderabbitai",
6482            "this branch never checks the exit code",
6483            "green",
6484        ] {
6485            assert!(html.contains(needle), "the panel must state `{needle}`");
6486        }
6487    }
6488
6489    /// A candidate whose label is `A` and has won, so [`RunState::winner`]
6490    /// resolves to it.
6491    fn winning_candidate(summary: &str) -> Candidate {
6492        Candidate {
6493            index: 0,
6494            label: 'A',
6495            agent: "opus".to_owned(),
6496            branch: "magi/x/A".to_owned(),
6497            worktree: PathBuf::from("/wt/A"),
6498            summary: summary.to_owned(),
6499            stat: String::new(),
6500            files: 1,
6501            commits: 1,
6502            empty: false,
6503            failed: None,
6504            verified_noop: None,
6505            duration_ms: 0,
6506            folded: false,
6507        }
6508    }
6509
6510    fn uncontested_tally() -> Tally {
6511        Tally {
6512            first_choice: BTreeMap::from([('A', 1)]),
6513            borda: BTreeMap::new(),
6514            winner: 'A',
6515            rankings: 1,
6516            unanimous_initial: true,
6517            deliberated: false,
6518            changed_votes: 0,
6519            unanimous_final: true,
6520            tie_break: None,
6521            judges: 1,
6522            present: 1,
6523            quorum: 1,
6524            met_quorum: true,
6525            uncontested: None,
6526        }
6527    }
6528
6529    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
6530        ReviewRecord {
6531            attempts: 0,
6532            reviewer,
6533            agent: agent.to_owned(),
6534            summary: summary.to_owned(),
6535            findings: Vec::new(),
6536            vote: None,
6537            failed: None,
6538            duration_ms: 0,
6539        }
6540    }
6541
6542    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
6543        let answered = reviews.len();
6544        ReviewRound {
6545            round,
6546            head: "abc1234".to_owned(),
6547            verified_head: None,
6548            verified_at: None,
6549            reviews,
6550            e2e: Vec::new(),
6551            verify_retried: false,
6552            e2e_deferred: false,
6553            e2e_defer_reason: None,
6554            fix: None,
6555            blocking: 0,
6556            answered,
6557            expected: answered,
6558            clean: true,
6559            progressed: false,
6560            vote_split: false,
6561            reconsideration: Vec::new(),
6562            verdict: None,
6563        }
6564    }
6565
6566    #[test]
6567    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
6568        let en = panel();
6569        assert!(en.contains("Task"), "{en}");
6570        assert!(en.contains("add retries to the uploader"), "{en}");
6571
6572        let mut state = run_state();
6573        state.config.graph.language = "ja".to_owned();
6574        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6575        assert!(ja.contains("タスク"), "{ja}");
6576        assert!(
6577            ja.contains("add retries to the uploader"),
6578            "the task itself is not translated: {ja}"
6579        );
6580    }
6581
6582    #[test]
6583    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
6584        // `run_state()` has no candidates, no tally and no reviews - exactly
6585        // the shape a run has before anything has judged or reviewed it, and
6586        // the panel must not print an empty box for either.
6587        let html = panel();
6588        assert!(!html.contains("What changed"), "{html}");
6589        assert!(!html.contains("Review verdict"), "{html}");
6590    }
6591
6592    #[test]
6593    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
6594        let mut state = run_state();
6595        state.candidates = vec![winning_candidate("")];
6596        state.tally = Some(uncontested_tally());
6597        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6598        assert!(
6599            !html.contains("What changed"),
6600            "an empty summary must not render an empty box: {html}"
6601        );
6602    }
6603
6604    #[test]
6605    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
6606        let mut state = run_state();
6607        state.candidates = vec![winning_candidate(
6608            "Added a retry loop around the uploader PUT call.",
6609        )];
6610        state.tally = Some(uncontested_tally());
6611        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6612        assert!(en.contains("What changed"), "{en}");
6613        assert!(
6614            en.contains("Added a retry loop around the uploader PUT call."),
6615            "{en}"
6616        );
6617
6618        state.config.graph.language = "ja".to_owned();
6619        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6620        assert!(ja.contains("変更内容"), "{ja}");
6621        assert!(
6622            ja.contains("Added a retry loop around the uploader PUT call."),
6623            "{ja}"
6624        );
6625    }
6626
6627    #[test]
6628    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
6629        let mut state = run_state();
6630        state.reviews = vec![
6631            review_round(
6632                1,
6633                vec![review_record(1, "alpha", "found a race, sent back")],
6634            ),
6635            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
6636        ];
6637        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6638        assert!(en.contains("Review verdict"), "{en}");
6639        assert!(en.contains("race is fixed, clean"), "{en}");
6640        assert!(
6641            !en.contains("found a race, sent back"),
6642            "only the round that actually cleared the merge should show: {en}"
6643        );
6644
6645        state.config.graph.language = "ja".to_owned();
6646        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6647        assert!(ja.contains("レビューの結論"), "{ja}");
6648        assert!(ja.contains("レビュアー"), "{ja}");
6649        assert!(ja.contains("race is fixed, clean"), "{ja}");
6650    }
6651
6652    /// The `incomplete_review = "warn"` policy (see
6653    /// `graph::Runner::review_loop`) can push a `clean` round to
6654    /// `state.reviews` while one seat's own record still has `failed: Some`
6655    /// and an empty `summary` - a seat that never answered, not one that
6656    /// answered with nothing to say.
6657    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
6658        ReviewRecord {
6659            attempts: 0,
6660            reviewer,
6661            agent: agent.to_owned(),
6662            summary: String::new(),
6663            findings: Vec::new(),
6664            vote: None,
6665            failed: Some(reason.to_owned()),
6666            duration_ms: 0,
6667        }
6668    }
6669
6670    #[test]
6671    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
6672        let mut state = run_state();
6673        state.reviews = vec![review_round(
6674            1,
6675            vec![
6676                review_record(1, "alpha", "clean, nothing to add"),
6677                unanswered_review_record(2, "beta", "timed out"),
6678            ],
6679        )];
6680        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6681        assert!(en.contains("clean, nothing to add"), "{en}");
6682        assert!(
6683            en.contains("produced no answer: timed out"),
6684            "a seat that never answered must say so, not render a blank box: {en}"
6685        );
6686        assert!(
6687            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
6688            "no reviewer box may be left empty: {en}"
6689        );
6690
6691        state.config.graph.language = "ja".to_owned();
6692        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6693        assert!(ja.contains("回答なし: timed out"), "{ja}");
6694    }
6695
6696    #[test]
6697    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
6698        let html = panel();
6699        assert!(!html.contains("<script"), "no script survives the csp");
6700        assert!(!html.contains("<form"), "form-action is 'none'");
6701        let pr = green_pr();
6702        assert_eq!(
6703            html.matches("http").count(),
6704            html.matches(pr.url.as_str()).count(),
6705            "the only http url in the panel is the pull request's own link"
6706        );
6707        assert!(
6708            html.contains("target=\"_blank\""),
6709            "the PR link must open in a new tab so it escapes the iframe"
6710        );
6711        assert!(
6712            html.contains("rel=\"noopener noreferrer\""),
6713            "external links opening in new tabs must carry noopener noreferrer"
6714        );
6715    }
6716
6717    #[test]
6718    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
6719        let html = panel();
6720        assert!(
6721            html.contains(">+</span>"),
6722            "an added line carries a `+` in the gutter, not only a background"
6723        );
6724        assert!(
6725            html.contains(">-</span>"),
6726            "a removed line carries a `-` in the gutter, not only a background"
6727        );
6728        assert!(
6729            html.contains(">new line</span>"),
6730            "the marker is moved to the gutter, so the body is printed once without it"
6731        );
6732    }
6733
6734    #[test]
6735    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
6736        let total = DIFF_MAX_LINES + 100;
6737        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
6738        let html = approval_panel(
6739            &run_state(),
6740            &green_pr(),
6741            NUMSTAT,
6742            &diff,
6743            &[],
6744            "feat: something long",
6745        );
6746        assert!(
6747            html.contains(&format!("100 of {total} diff lines omitted")),
6748            "the note must say exactly how much was cut"
6749        );
6750        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
6751        assert!(
6752            !html.contains(&format!("line {DIFF_MAX_LINES}")),
6753            "nothing past the threshold is rendered"
6754        );
6755        assert!(
6756            html.contains("/repo/magi"),
6757            "the note says where the rest is"
6758        );
6759    }
6760
6761    #[test]
6762    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
6763        let html = approval_panel(
6764            &run_state(),
6765            &green_pr(),
6766            "1\t2\tsrc/<b>&\"x\"'.rs",
6767            "",
6768            &[],
6769            "subject",
6770        );
6771        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
6772        assert!(
6773            !html.contains("<b>"),
6774            "an agent-influenced path must never become markup"
6775        );
6776    }
6777
6778    #[tokio::test]
6779    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
6780        let a = std::path::PathBuf::from("/repo/a");
6781        let b = std::path::PathBuf::from("/repo/b");
6782
6783        let held = repo_merge_lock(&a).lock_owned().await;
6784
6785        // A second, concurrent land run against the *same* repository must
6786        // wait - `try_lock` fails while `held` is alive.
6787        assert!(
6788            repo_merge_lock(&a).try_lock().is_err(),
6789            "a second merge into the same repository must not proceed concurrently"
6790        );
6791
6792        // A run against a *different* repository must not be blocked by it -
6793        // this is what keeps a slow rebase or `gh pr merge` in one
6794        // repository from also stalling a land-approval resume in another.
6795        assert!(
6796            repo_merge_lock(&b).try_lock().is_ok(),
6797            "a different repository's merge lock must be independent"
6798        );
6799
6800        drop(held);
6801        assert!(
6802            repo_merge_lock(&a).try_lock().is_ok(),
6803            "the lock is released once the holder is done"
6804        );
6805    }
6806
6807    pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
6808        let mut state = landing_state();
6809        let mut rec = review_record(1, "alpha", "precise rejection reason");
6810        rec.vote = Some(if reject {
6811            crate::verdict::ReviewVote::Reject
6812        } else {
6813            crate::verdict::ReviewVote::Approve
6814        });
6815        if major {
6816            rec.findings = contested().findings;
6817        }
6818        let mut round = review_round(1, vec![rec]);
6819        round.clean = false;
6820        round.blocking = usize::from(major);
6821        state.reviews.push(round);
6822        state.config.graph.review_rounds = 1;
6823        state
6824    }
6825
6826    #[test]
6827    fn approval_policy_controls_are_independent_and_keep_all_reasons() {
6828        for every in [false, true] {
6829            for contested in [false, true] {
6830                for major in [false, true] {
6831                    for reject in [false, true] {
6832                        let mut state = repair_state(major, reject);
6833                        state.config.graph.land_approval = every;
6834                        state.config.graph.hold_contested_merge = contested;
6835                        let reasons = approval_reasons(&state);
6836                        assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
6837                        assert_eq!(
6838                            reasons.contains(&ApprovalReason::OpenBlockingFindings),
6839                            contested && major
6840                        );
6841                        assert_eq!(
6842                            reasons.contains(&ApprovalReason::FinalReject),
6843                            contested && reject
6844                        );
6845                        assert_eq!(
6846                            reasons.len(),
6847                            usize::from(every)
6848                                + usize::from(contested && major)
6849                                + usize::from(contested && reject)
6850                        );
6851                    }
6852                }
6853            }
6854        }
6855    }
6856
6857    #[test]
6858    fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
6859        use crate::verdict::ReviewVote::{Approve, Reject};
6860        for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
6861            let mut state = repair_state(false, initial_reject);
6862            state.reviews[0]
6863                .reconsideration
6864                .push(crate::run::ReviewRevoteRecord {
6865                    reviewer: 1,
6866                    agent: "alpha".into(),
6867                    vote: Some(final_vote),
6868                    reason: "changed after reading the panel <evidence>".into(),
6869                    failed: None,
6870                });
6871            assert_eq!(
6872                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
6873                final_vote == Reject
6874            );
6875            for language in ["en", "ja"] {
6876                state.config.graph.language = language.into();
6877                let evidence = approval_evidence(&state);
6878                assert!(evidence.contains(&format!("→ {}", final_vote.label())));
6879                assert!(evidence.contains("changed after reading the panel <evidence>"));
6880                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
6881                assert!(html.contains("changed after reading the panel &lt;evidence&gt;"));
6882                assert!(!html.contains("<evidence>"));
6883            }
6884            state.reviews[0].reconsideration[0].vote = None;
6885            state.reviews[0].reconsideration[0].failed = Some("timed out".into());
6886            assert_eq!(
6887                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
6888                initial_reject
6889            );
6890            assert!(approval_evidence(&state).contains("timed out"));
6891        }
6892    }
6893
6894    #[tokio::test]
6895    async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
6896        for every in [false, true] {
6897            for contested in [false, true] {
6898                for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
6899                {
6900                    let mut state = repair_state(major, reject);
6901                    state.config.graph.land_approval = every;
6902                    state.config.graph.hold_contested_merge = contested;
6903                    let forge = Scripted::new(
6904                        vec![seen("abc", Checks::Green, "CLEAN", false)],
6905                        vec![(true, "")],
6906                    );
6907                    land_with(&mut state, URL, &forge).await.unwrap();
6908                    let expected = every || (contested && (major || reject));
6909                    assert_eq!(state.land_approval.is_some(), expected);
6910                    assert_eq!(forge.calls().contains(&"merge"), !expected);
6911                    if expected {
6912                        assert!(state.parked);
6913                    }
6914                }
6915            }
6916        }
6917    }
6918
6919    #[tokio::test]
6920    async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
6921        let mut state = repair_state(false, true);
6922        state.reviews[0].clean = true;
6923        let mut finding = contested().findings.remove(0);
6924        finding.severity = crate::verdict::Severity::Minor;
6925        state.reviews[0].reviews[0].findings.push(finding);
6926        assert!(state.open_findings().is_empty());
6927        let mut pr = green_pr();
6928        pr.review_comments.clear();
6929        for language in ["en", "ja"] {
6930            state.config.graph.language = language.into();
6931            let html = approval_panel(&state, &pr, "", "", &[], "subject");
6932            assert!(html.contains("[R3-1-1] minor"));
6933            assert!(html.contains(if language == "ja" {
6934                "内部指摘の解決を意味するものではありません"
6935            } else {
6936                "This does not mean internal findings are resolved"
6937            }));
6938            assert!(
6939                html.find(if language == "ja" {
6940                    "承認が必要な理由:"
6941                } else {
6942                    "Approval required because:"
6943                })
6944                .unwrap()
6945                    < html.find("[R3-1-1]").unwrap()
6946            );
6947        }
6948        state.config.graph.language = "en".into();
6949        let observed = seen("abc", Checks::Green, "CLEAN", false);
6950        assert_eq!(
6951            approval_gate(&mut state, &observed.pr, "subject", None, "abc")
6952                .await
6953                .unwrap(),
6954            ApprovalGate::Pending
6955        );
6956        let id = state.land_approval.as_ref().unwrap().question.clone();
6957        let q = ask::Questions::open().get(&id).unwrap();
6958        assert!(q.detail.starts_with("Approval required because:"));
6959        assert!(q.detail.contains("final reviewer reject"));
6960        assert!(q.detail.contains("[R3-1-1] minor"));
6961        assert!(
6962            q.detail
6963                .contains("No adoption or resolution evidence recorded")
6964        );
6965        state.config.graph.land_approval = false;
6966        state.config.graph.hold_contested_merge = false;
6967        state.reviews.clear();
6968        assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
6969        assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
6970        assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
6971        let forge = Scripted::new(vec![observed], vec![]);
6972        land_with(&mut state, URL, &forge).await.unwrap();
6973        assert!(!forge.calls().contains(&"merge"));
6974        assert_eq!(state.land_approval.as_ref().unwrap().question, id);
6975        assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
6976    }
6977
6978    #[tokio::test]
6979    async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
6980        let mut state = repair_state(false, true);
6981        state.config.graph.land_approval = false;
6982        let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
6983        let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
6984        let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
6985        state.reviews[0].reviews[0].summary = long_reason.clone();
6986        for i in 1..=10 {
6987            let mut finding = contested().findings.remove(0);
6988            finding.id = format!("R1-1-{i}");
6989            finding.severity = if i == 9 {
6990                crate::verdict::Severity::Major
6991            } else {
6992                crate::verdict::Severity::Minor
6993            };
6994            finding.title = long_title.clone();
6995            finding.detail = long_detail.clone();
6996            state.reviews[0].reviews[0].findings.push(finding);
6997        }
6998        state.reviews[0].fix = Some(crate::run::FixRecord {
6999            agent: "fixer".into(),
7000            addressed: vec!["R1-1-9".into()],
7001            rejected: vec![crate::verdict::Rejection {
7002                id: "R1-1-10".into(),
7003                why: long_reason.clone(),
7004            }],
7005            notes: long_detail.clone(),
7006            committed: false,
7007            failed: None,
7008            duration_ms: 0,
7009            continuation: None,
7010        });
7011        assert_eq!(
7012            approval_reasons(&state),
7013            [
7014                ApprovalReason::OpenBlockingFindings,
7015                ApprovalReason::FinalReject
7016            ]
7017        );
7018        let excerpt = approval_excerpt(&long_reason, 300);
7019        assert_eq!(excerpt.chars().count(), 300);
7020        assert!(excerpt.ends_with('…'));
7021        let boundary = "拒".repeat(300);
7022        assert_eq!(approval_excerpt(&boundary, 300), boundary);
7023        for language in ["en", "ja"] {
7024            state.config.graph.language = language.into();
7025            for revote in [
7026                None,
7027                Some(crate::verdict::ReviewVote::Reject),
7028                Some(crate::verdict::ReviewVote::Approve),
7029            ] {
7030                state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
7031                state.reviews[0].reconsideration.clear();
7032                if let Some(vote) = revote {
7033                    state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
7034                    state.reviews[0]
7035                        .reconsideration
7036                        .push(crate::run::ReviewRevoteRecord {
7037                            reviewer: 1,
7038                            agent: "alpha".into(),
7039                            vote: Some(vote),
7040                            reason: long_reason.clone(),
7041                            failed: None,
7042                        });
7043                }
7044                let evidence = approval_evidence(&state);
7045                let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
7046                assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
7047                assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
7048                assert_eq!(
7049                    approval_reasons(&state).contains(&ApprovalReason::FinalReject),
7050                    final_vote == crate::verdict::ReviewVote::Reject
7051                );
7052                assert!(evidence.contains(&excerpt));
7053                assert!(evidence.contains("[R1-1-8] minor"));
7054                assert!(evidence.contains("[R1-1-9] major"));
7055                assert!(evidence.contains("[R1-1-10] minor"));
7056                for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
7057                    assert!(!evidence.contains(tail));
7058                }
7059                assert!(evidence.contains(if language == "ja" {
7060                    "採用申告(解決未確認)"
7061                } else {
7062                    "claimed addressed (resolution unconfirmed)"
7063                }));
7064                assert!(evidence.contains(if language == "ja" {
7065                    "不採用理由"
7066                } else {
7067                    "declined"
7068                }));
7069                let major_evidence = evidence
7070                    .split("[R1-1-9] major")
7071                    .nth(1)
7072                    .unwrap()
7073                    .split("[R1-1-10]")
7074                    .next()
7075                    .unwrap();
7076                assert!(major_evidence.contains(if language == "ja" {
7077                    "採用申告(解決未確認)"
7078                } else {
7079                    "claimed addressed (resolution unconfirmed)"
7080                }));
7081                let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
7082                assert!(last_evidence.contains(if language == "ja" {
7083                    "不採用理由"
7084                } else {
7085                    "declined"
7086                }));
7087                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
7088                assert!(html.contains(&esc(&evidence)));
7089                assert_eq!(
7090                    approval_gate(&mut state, &green_pr(), "subject", None, "abc")
7091                        .await
7092                        .unwrap(),
7093                    ApprovalGate::Pending
7094                );
7095                let id = &state.land_approval.take().unwrap().question;
7096                let q = ask::Questions::open().get(id).unwrap();
7097                assert!(q.detail.starts_with(&evidence));
7098            }
7099            state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
7100        }
7101    }
7102
7103    #[test]
7104    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
7105        for (major, reject) in [(true, false), (false, true), (true, true)] {
7106            let state = repair_state(major, reject);
7107            assert!(repair_available(&state));
7108            assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
7109        }
7110        assert!(!repair_available(&repair_state(false, false)));
7111        let mut clean = repair_state(true, false);
7112        clean.reviews[0].clean = true;
7113        assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
7114        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
7115    }
7116
7117    #[tokio::test]
7118    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
7119        let mut state = repair_state(true, false);
7120        let pr = green_pr();
7121        approval_gate(&mut state, &pr, "feat: x", None, "abc")
7122            .await
7123            .unwrap();
7124        let store = ask::Questions::open();
7125        let id = state.land_approval.as_ref().unwrap().question.clone();
7126        // Simulate an unanswered two-choice question created by an older build.
7127        store
7128            .update(&id, |q| {
7129                q.choices = vec![APPROVE.into(), HOLD.into()];
7130                Ok(())
7131            })
7132            .unwrap();
7133        let upgraded = store.get(&id).unwrap();
7134        assert_eq!(upgraded.choices, approval_choices(&state));
7135        assert!(!upgraded.actions.contains_key(HOLD));
7136        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
7137        assert!(
7138            store
7139                .list()
7140                .iter()
7141                .any(|q| q.id == id && q.choices == upgraded.choices)
7142        );
7143        assert!(
7144            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
7145        );
7146        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
7147        store
7148            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
7149            .unwrap();
7150        // An earlier build may have recorded the repair answer and stopped
7151        // before any driver consumed it. Reconcile that durable decision too.
7152        store
7153            .update(&id, |q| {
7154                q.actions.clear();
7155                Ok(())
7156            })
7157            .unwrap();
7158        assert_eq!(
7159            store.get(&id).unwrap().chosen_action(),
7160            Some(&ask::ChoiceAction::Resume {
7161                run: state.id.clone()
7162            })
7163        );
7164        let binding = state.land_approval.clone();
7165        assert_eq!(
7166            approval_gate(&mut state, &pr, "feat: x", None, "abc")
7167                .await
7168                .unwrap(),
7169            ApprovalGate::Repair
7170        );
7171        assert!(state.land_approval.is_none());
7172        assert_eq!(state.merge_repairs.len(), 1);
7173        let request = &state.merge_repairs[0];
7174        assert_eq!(request.question, id);
7175        assert_eq!(request.head, "abc");
7176        assert_eq!(request.pr, pr.url);
7177        assert_eq!(
7178            request.reviews[0].reviews[0].summary,
7179            "precise rejection reason"
7180        );
7181        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
7182        assert_eq!(
7183            request.reviews[0].reviews[0].findings[0].detail,
7184            state.reviews[0].reviews[0].findings[0].detail
7185        );
7186        // A repeated consumption of the same answer never dispatches another repair.
7187        state.land_approval = binding;
7188        assert_eq!(
7189            approval_gate(&mut state, &pr, "feat: x", None, "abc")
7190                .await
7191                .unwrap(),
7192            ApprovalGate::Repair
7193        );
7194        assert_eq!(state.merge_repairs.len(), 1);
7195        state.land_approval = None;
7196        state.config.graph.land_approval = false;
7197        state.config.graph.hold_contested_merge = false;
7198        state.reviews.clear();
7199        assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
7200        assert_eq!(
7201            approval_gate(&mut state, &pr, "feat: x", None, "abc")
7202                .await
7203                .unwrap(),
7204            ApprovalGate::Pending
7205        );
7206        let fresh_id = &state.land_approval.as_ref().unwrap().question;
7207        assert_ne!(fresh_id, &id);
7208        let fresh = store.get(fresh_id).unwrap();
7209        assert!(
7210            fresh
7211                .detail
7212                .contains("fresh owner approval after merge repair")
7213        );
7214        assert!(fresh.detail.contains("No review recorded"));
7215        assert!(!fresh.detail.contains("precise rejection reason"));
7216    }
7217
7218    #[tokio::test]
7219    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
7220        for choice in [REPAIR, HOLD] {
7221            let mut state = repair_state(false, true);
7222            let observed = seen("abc", Checks::Green, "CLEAN", false);
7223            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
7224                .await
7225                .unwrap();
7226            let id = state.land_approval.as_ref().unwrap().question.clone();
7227            ask::Questions::open()
7228                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
7229                .unwrap();
7230            let forge = Scripted::new(vec![observed], vec![(true, "")]);
7231            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7232                .await
7233                .unwrap();
7234            assert!(
7235                forge
7236                    .argvs()
7237                    .iter()
7238                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
7239            );
7240            assert!(!forge.calls().contains(&"fix"));
7241            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
7242            assert_ne!(state.status, RunStatus::Merged);
7243        }
7244    }
7245
7246    #[test]
7247    fn only_the_merge_choice_merges_and_silence_holds() {
7248        let table = [
7249            (None, Approval::Hold),
7250            (Some("merge"), Approval::Merge),
7251            (Some(" merge\n"), Approval::Merge),
7252            (Some("hold"), Approval::Hold),
7253            (Some(""), Approval::Hold),
7254            (Some("yes"), Approval::Hold),
7255        ];
7256        for (answer, want) in table {
7257            assert_eq!(
7258                approval(answer),
7259                want,
7260                "answer {answer:?} must resolve to {want:?}"
7261            );
7262        }
7263    }
7264
7265    #[tokio::test]
7266    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
7267        let mut state = landing_state();
7268        state.config.graph.land_approval = true;
7269        let pr = green_pr();
7270
7271        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
7272            .await
7273            .unwrap();
7274        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
7275        assert!(
7276            !state.parked,
7277            "approval_gate itself never sets `parked`; only its caller does"
7278        );
7279
7280        let store = ask::Questions::open();
7281        let filed: Vec<_> = store
7282            .list()
7283            .into_iter()
7284            .filter(|q| q.run == state.id)
7285            .collect();
7286        assert_eq!(filed.len(), 1, "exactly one question is filed");
7287        assert_eq!(filed[0].node, APPROVAL_NODE);
7288        assert_eq!(filed[0].choices, approval_choices(&state));
7289        assert!(filed[0].status.open());
7290
7291        // A second visit - standing in for a resumed run whose slot the
7292        // daemon handed to something else while nobody had answered - must
7293        // find the same question rather than filing a second one.
7294        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
7295            .await
7296            .unwrap();
7297        assert_eq!(again, ApprovalGate::Pending);
7298        let still_one = store
7299            .list()
7300            .into_iter()
7301            .filter(|q| q.run == state.id)
7302            .count();
7303        assert_eq!(
7304            still_one, 1,
7305            "asking twice must not double-file the question"
7306        );
7307    }
7308
7309    #[tokio::test]
7310    async fn approving_the_existing_question_is_read_back_as_approved() {
7311        crate::run::pin_test_home();
7312        let mut state = run_state();
7313        state.config.graph.land_approval = true;
7314        let pr = green_pr();
7315        assert_eq!(
7316            approval_gate(&mut state, &pr, "feat: x", None, "abc")
7317                .await
7318                .unwrap(),
7319            ApprovalGate::Pending
7320        );
7321
7322        let store = ask::Questions::open();
7323        let mut q = store
7324            .list()
7325            .into_iter()
7326            .find(|q| q.run == state.id)
7327            .expect("filed above");
7328        let talks = crate::talk::Talks::open();
7329        let cfg = crate::config::Config {
7330            agents: vec![crate::config::AgentSpec {
7331                id: "mock".into(),
7332                kind: crate::config::AgentKind::Command,
7333                model: None,
7334                command: vec!["true".into()],
7335                extra_args: Vec::new(),
7336                env: Default::default(),
7337                prompt_delivery: None,
7338            }],
7339            ..crate::config::Config::default()
7340        };
7341        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
7342        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
7343        crate::talk::drain(&mut talk, &talks).unwrap();
7344        assert_eq!(
7345            approval_gate(&mut state, &pr, "feat: x", None, "abc")
7346                .await
7347                .unwrap(),
7348            ApprovalGate::Pending,
7349            "consultation cannot approve or hold the merge"
7350        );
7351        q = store.get(&q.id).unwrap();
7352        assert!(q.answer.is_none());
7353        talk.turns.push(crate::talk::Turn {
7354            sender: None,
7355            breaks: Some(Vec::new()),
7356            messages: Vec::new(),
7357            posted_by: None,
7358            who: crate::talk::Who::Operator,
7359            body: "merge abc".into(),
7360            at: Timestamp::now(),
7361            attachments: Vec::new(),
7362            usage: None,
7363        });
7364        talks.put(&mut talk).unwrap();
7365        crate::consult::validate_answer(
7366            &q,
7367            &talks,
7368            &talk.id,
7369            crate::queue::CHAT_NODE,
7370            APPROVE,
7371            Some("merge abc"),
7372        )
7373        .unwrap();
7374        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7375        store.put(&mut q).unwrap();
7376
7377        assert_eq!(
7378            approval_gate(&mut state, &pr, "feat: x", None, "abc")
7379                .await
7380                .unwrap(),
7381            ApprovalGate::Approved
7382        );
7383    }
7384
7385    #[tokio::test]
7386    async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
7387        crate::run::pin_test_home();
7388        let store = ask::Questions::open();
7389        for via_list in [false, true] {
7390            let mut state = repair_state(false, true);
7391            let pr = green_pr();
7392            approval_gate(&mut state, &pr, "feat: x", None, "abc")
7393                .await
7394                .unwrap();
7395            let id = state.land_approval.as_ref().unwrap().question.clone();
7396            store
7397                .update(&id, |q| {
7398                    q.actions.insert(
7399                        HOLD.to_owned(),
7400                        ask::ChoiceAction::Resume {
7401                            run: state.id.clone(),
7402                        },
7403                    );
7404                    Ok(())
7405                })
7406                .unwrap();
7407            let q = if via_list {
7408                store.list().into_iter().find(|q| q.id == id).unwrap()
7409            } else {
7410                store.get(&id).unwrap()
7411            };
7412            assert!(!q.actions.contains_key(HOLD));
7413            store
7414                .update(&id, |q| {
7415                    // Check the persisted migration, without another read upgrade.
7416                    assert!(!q.actions.contains_key(HOLD));
7417                    assert!(!upgrade_repair_choice(q, &state));
7418                    q.answer(ask::Answer::Choice(HOLD.to_owned()))
7419                })
7420                .unwrap();
7421            assert!(store.get(&id).unwrap().chosen_action().is_none());
7422            assert_eq!(
7423                approval_gate(&mut state, &pr, "feat: x", None, "abc")
7424                    .await
7425                    .unwrap(),
7426                ApprovalGate::Held
7427            );
7428        }
7429    }
7430
7431    #[tokio::test]
7432    async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
7433        crate::run::pin_test_home();
7434        let store = ask::Questions::open();
7435
7436        let mut held_state = run_state();
7437        held_state.config.graph.land_approval = true;
7438        let pr = green_pr();
7439        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
7440            .await
7441            .unwrap();
7442        let mut q = store
7443            .list()
7444            .into_iter()
7445            .find(|q| q.run == held_state.id)
7446            .expect("filed above");
7447        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
7448        store.put(&mut q).unwrap();
7449        assert_eq!(
7450            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
7451                .await
7452                .unwrap(),
7453            ApprovalGate::Held
7454        );
7455
7456        let mut abandoned_state = run_state();
7457        abandoned_state.config.graph.land_approval = true;
7458        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
7459            .await
7460            .unwrap();
7461        let mut q = store
7462            .list()
7463            .into_iter()
7464            .find(|q| q.run == abandoned_state.id)
7465            .expect("filed above");
7466        q.abandon("no answer within the timeout");
7467        store.put(&mut q).unwrap();
7468        assert_eq!(
7469            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
7470                .await
7471                .unwrap(),
7472            ApprovalGate::Held,
7473            "silence must never merge"
7474        );
7475    }
7476
7477    fn contested() -> ContestedHandoff {
7478        let finding = |id: &str, n: u32| crate::verdict::Finding {
7479            id: id.to_owned(),
7480            severity: crate::verdict::Severity::Major,
7481            file: Some("src/a.rs".to_owned()),
7482            line: Some(n),
7483            title: format!("problem {id}"),
7484            detail: String::new(),
7485        };
7486        ContestedHandoff {
7487            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
7488            rejecters: vec![(1, "alpha".to_owned())],
7489        }
7490    }
7491
7492    #[test]
7493    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
7494        let mut state = run_state();
7495        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
7496        state.contested_handoff = Some(contested());
7497        assert!(contested_to_ask(&state).is_some());
7498        state.config.graph.hold_contested_merge = false;
7499        assert!(
7500            contested_to_ask(&state).is_none(),
7501            "the switch restores today"
7502        );
7503    }
7504
7505    #[test]
7506    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
7507        let q = ask::Question::new(
7508            "run-1".to_owned(),
7509            APPROVAL_NODE.to_owned(),
7510            "land".to_owned(),
7511            "Merge?".to_owned(),
7512            String::new(),
7513            vec![APPROVE.to_owned(), HOLD.to_owned()],
7514        );
7515        let none = deputy_brief(&q, None);
7516        assert!(none.contains("could not be read"), "{none}");
7517        assert!(none.contains("Silence is a hold"), "{none}");
7518
7519        let mut state = run_state();
7520        state.pr = Some(crate::run::PrRecord {
7521            url: "https://example.test/pull/7".to_owned(),
7522            number: 7,
7523            state: "open".to_owned(),
7524            checks: "green".to_owned(),
7525            round: 0,
7526            rounds: 3,
7527            red_at_merge: Vec::new(),
7528        });
7529        state.contested_handoff = Some(contested());
7530        let b = deputy_brief(&q, Some(&state));
7531        assert!(b.contains("https://example.test/pull/7"), "{b}");
7532        assert!(b.contains("not recorded in this legacy question"), "{b}");
7533        assert!(!b.contains("This question was filed although"));
7534        state.contested_handoff = None;
7535        assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
7536    }
7537
7538    #[test]
7539    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
7540        for lang in ["en", "ja"] {
7541            let mut cfg = crate::config::Config::default();
7542            cfg.graph.language = lang.to_owned();
7543            let w = words(&cfg.graph.language);
7544            let text = w.approval_detail(
7545                "https://github.com/yukimemi/magi/pull/42",
7546                "main",
7547                "feat: x",
7548                Some(&contested()),
7549            );
7550            assert!(text.contains("pull/42"), "{text}");
7551            assert!(
7552                text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
7553                "{text}"
7554            );
7555            assert!(text.contains("R3-1-5"), "{text}");
7556            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
7557            assert!(text.contains("2"), "the rest are counted: {text}");
7558            assert!(text.contains("#1 (alpha)"), "{text}");
7559        }
7560        let plain = words("en").approval_detail("u", "main", "s", None);
7561        assert!(!plain.contains("reject"), "{plain}");
7562    }
7563
7564    #[tokio::test]
7565    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
7566        crate::run::pin_test_home();
7567        let mut state = run_state();
7568        state.config.graph.land_approval = false;
7569        state.contested_handoff = Some(contested());
7570        let pr = green_pr();
7571        let c = contested_to_ask(&state);
7572        assert_eq!(
7573            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7574                .await
7575                .unwrap(),
7576            ApprovalGate::Pending,
7577            "silence is a hold"
7578        );
7579        let store = ask::Questions::open();
7580        let filed: Vec<_> = store
7581            .list()
7582            .into_iter()
7583            .filter(|q| q.run == state.id)
7584            .collect();
7585        assert_eq!(filed.len(), 1);
7586        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
7587
7588        assert_eq!(
7589            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7590                .await
7591                .unwrap(),
7592            ApprovalGate::Pending
7593        );
7594        let mut q = store
7595            .list()
7596            .into_iter()
7597            .find(|q| q.run == state.id)
7598            .unwrap();
7599        assert_eq!(q.id, filed[0].id, "the same question after a resume");
7600        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7601        store.put(&mut q).unwrap();
7602        assert_eq!(
7603            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7604                .await
7605                .unwrap(),
7606            ApprovalGate::Approved
7607        );
7608    }
7609
7610    #[test]
7611    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
7612        let rows = parse_numstat(NUMSTAT);
7613        assert_eq!(
7614            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
7615            ["src/web.rs", "src/land.rs", "assets/logo.png"]
7616        );
7617        assert_eq!(rows[2].added, None, "a binary file has no line counts");
7618    }
7619    #[test]
7620    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
7621        // Reported from a real run: the merge question arrived in English on a
7622        // repository with `language = "ja"`. magi's own strings have to follow
7623        // that setting too - "it is a literal in Rust" is not an answer.
7624        let mut state = run_state();
7625        state.config.graph.language = "ja".to_owned();
7626        let pr = green_pr();
7627        let commits = ["c1".to_owned()];
7628
7629        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7630        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
7631        assert!(ja.contains("squash されるコミット"), "{ja}");
7632        assert!(ja.contains("レビューコメント"), "{ja}");
7633        assert!(ja.contains("差分"), "{ja}");
7634        assert!(
7635            !ja.contains("Commits being squashed"),
7636            "no English left over"
7637        );
7638
7639        let w = words("ja");
7640        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
7641        assert!(
7642            w.approval_detail("http://x/1", "main", "feat: x", None)
7643                .contains("パネル")
7644        );
7645
7646        // The evidence itself is language-neutral and must survive either way.
7647        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
7648        assert!(ja.contains("feat: x"), "nor is the merge subject");
7649
7650        // English stays the default, and a language magi cannot check falls
7651        // back to it rather than shipping a guess.
7652        state.config.graph.language = "en".to_owned();
7653        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7654        assert!(en.contains("Commits being squashed"), "{en}");
7655        assert_eq!(words("Klingon").html_lang, "en");
7656    }
7657
7658    /// A `gh pr list` result naming exactly one pull request whose base and
7659    /// merge time both fit the run is exactly the case
7660    /// [`find_external_merge`] exists to act on.
7661    #[test]
7662    fn pick_open_pr_classifies_by_count_and_base() {
7663        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
7664        assert_eq!(
7665            pick_open_pr(one, "main").unwrap(),
7666            OpenPr::One {
7667                url: "https://x/pull/58".into(),
7668                title: "t".into()
7669            }
7670        );
7671        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
7672        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
7673        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
7674                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
7675        assert_eq!(
7676            pick_open_pr(two, "main").unwrap(),
7677            OpenPr::Many(vec!["u1".into(), "u2".into()])
7678        );
7679        // A fork's same-named branch is not a pull request for this head.
7680        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
7681        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
7682        assert!(pick_open_pr("not json", "main").is_err());
7683        // An incomplete record is an error, never "nothing open".
7684        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
7685        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
7686    }
7687
7688    #[test]
7689    fn pick_merged_pr_picks_the_unique_match() {
7690        let json = r#"[
7691            {"url": "https://github.com/o/r/pull/42", "number": 42,
7692             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
7693        ]"#;
7694        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7695        let found = pick_merged_pr(json, "main", created_at)
7696            .expect("valid json")
7697            .expect("one unambiguous match");
7698        assert_eq!(found.url, "https://github.com/o/r/pull/42");
7699        assert_eq!(found.number, 42);
7700    }
7701
7702    /// Two candidates surviving the filter is exactly as uninformative as
7703    /// zero — a branch name can be reused across runs — so neither is
7704    /// preferred over the other and nothing is recorded automatically.
7705    #[test]
7706    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
7707        let json = r#"[
7708            {"url": "https://github.com/o/r/pull/42", "number": 42,
7709             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
7710            {"url": "https://github.com/o/r/pull/43", "number": 43,
7711             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
7712        ]"#;
7713        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7714        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7715    }
7716
7717    /// A pull request that targets a different base branch cannot be this
7718    /// run's, whatever its head branch is named — a reused branch name from
7719    /// an unrelated task must not be recorded as this run's merge.
7720    #[test]
7721    fn pick_merged_pr_ignores_a_different_base_branch() {
7722        let json = r#"[
7723            {"url": "https://github.com/o/r/pull/42", "number": 42,
7724             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
7725        ]"#;
7726        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7727        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7728    }
7729
7730    /// A pull request merged before this run was even created cannot be this
7731    /// run's winner, no matter how its head branch is spelled.
7732    #[test]
7733    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
7734        let json = r#"[
7735            {"url": "https://github.com/o/r/pull/42", "number": 42,
7736             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
7737        ]"#;
7738        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7739        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7740    }
7741
7742    #[test]
7743    fn slug_of_pr_url_reads_host_owner_and_repo() {
7744        assert_eq!(
7745            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
7746            Some("github.com/yukimemi/shun")
7747        );
7748    }
7749
7750    #[test]
7751    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
7752        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
7753        assert_eq!(slug_of_pr_url("not a url at all"), None);
7754        assert_eq!(slug_of_pr_url("https://github.com"), None);
7755    }
7756
7757    #[test]
7758    fn slug_of_repo_url_reads_host_owner_and_repo() {
7759        assert_eq!(
7760            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
7761            Some("github.com/yukimemi/magi")
7762        );
7763        assert_eq!(slug_of_repo_url("https://github.com"), None);
7764    }
7765
7766    #[test]
7767    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
7768        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
7769            .expect("same repo, different case");
7770    }
7771
7772    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
7773    /// own in-progress run and rewrote its status from a pull request in a
7774    /// completely different repository. This is the guard that must catch
7775    /// that even when an explicit (but wrong) id is given.
7776    #[test]
7777    fn ensure_same_repo_refuses_a_different_repo() {
7778        let err =
7779            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
7780        let msg = format!("{err:#}");
7781        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7782        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
7783    }
7784
7785    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
7786    /// instance mirroring a `github.com` repository's name, say) must not be
7787    /// treated as the same repository just because the trailing path
7788    /// matches.
7789    #[test]
7790    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
7791        let err = ensure_same_repo(
7792            "github.com/yukimemi/magi",
7793            "github.example.com/yukimemi/magi",
7794        )
7795        .unwrap_err();
7796        let msg = format!("{err:#}");
7797        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7798        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
7799    }
7800
7801    /// No winner decided yet means there is no branch to ask GitHub about at
7802    /// all — `find_external_merge` must return `None` without ever spawning
7803    /// `gh`, which this proves by never providing a real repository to spawn
7804    /// it in.
7805    #[tokio::test]
7806    async fn find_external_merge_returns_none_without_a_winner() {
7807        let state = RunState::new(
7808            PathBuf::from("/no/such/repo"),
7809            "main".to_owned(),
7810            "0000000000000000000000000000000000000000".to_owned(),
7811            "irrelevant".to_owned(),
7812            crate::config::Config::default(),
7813        );
7814        assert_eq!(find_external_merge(&state).await.unwrap(), None);
7815    }
7816
7817    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
7818        let mut run = RunState::new(
7819            PathBuf::from(repo),
7820            "main".to_owned(),
7821            "abcdef1234".to_owned(),
7822            "x".to_owned(),
7823            crate::config::Config::default(),
7824        );
7825        run.id = id.to_owned();
7826        run.status = status;
7827        run.driver_exited = true;
7828        run.pr = Some(crate::run::PrRecord {
7829            number: url.rsplit('/').next().unwrap().parse().unwrap(),
7830            url: url.to_owned(),
7831            state: state.to_owned(),
7832            checks: "red".to_owned(),
7833            round: 0,
7834            rounds: 2,
7835            red_at_merge: Vec::new(),
7836        });
7837        run.save_under(home).unwrap();
7838    }
7839
7840    fn recorded(home: &Path, id: &str) -> String {
7841        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
7842        serde_json::from_str::<RunState>(&body)
7843            .unwrap()
7844            .pr
7845            .unwrap()
7846            .state
7847    }
7848
7849    const PR: &str = "https://github.com/o/r/pull/7";
7850
7851    #[test]
7852    fn write_through_updates_predecessors_and_siblings_only() {
7853        let tmp = tempfile::tempdir().unwrap();
7854        let h = tmp.path();
7855        pr_run(
7856            h,
7857            "20261004-100000-aaaa",
7858            "/repo/r",
7859            RunStatus::Superseded,
7860            PR,
7861            "open",
7862        );
7863        pr_run(
7864            h,
7865            "20261004-100100-bbbb",
7866            "/repo/r",
7867            RunStatus::Blocked,
7868            PR,
7869            "open",
7870        );
7871        // Not terminal: a driver may be writing it.
7872        pr_run(
7873            h,
7874            "20261004-100200-cccc",
7875            "/repo/r",
7876            RunStatus::Landing,
7877            PR,
7878            "open",
7879        );
7880        // Another repository's pull request with the same number.
7881        pr_run(
7882            h,
7883            "20261004-100300-dddd",
7884            "/repo/other",
7885            RunStatus::Blocked,
7886            "https://github.com/o/other/pull/7",
7887            "open",
7888        );
7889        // A different pull request of the same repository.
7890        pr_run(
7891            h,
7892            "20261004-100400-eeee",
7893            "/repo/r",
7894            RunStatus::Blocked,
7895            "https://github.com/o/r/pull/8",
7896            "open",
7897        );
7898        pr_run(
7899            h,
7900            "20261004-100500-ffff",
7901            "/repo/r",
7902            RunStatus::Merged,
7903            PR,
7904            "open",
7905        );
7906        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
7907
7908        assert_eq!(
7909            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7910            2
7911        );
7912        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
7913        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
7914        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
7915        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
7916        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
7917        // The source's own record is the caller's to write.
7918        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
7919        // Idempotent.
7920        assert_eq!(
7921            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7922            0
7923        );
7924        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
7925        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
7926    }
7927
7928    #[test]
7929    fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
7930        let home = tempfile::tempdir().unwrap();
7931        let live_id = "20261004-100000-aaaa";
7932        let unknown_id = "20261004-100100-bbbb";
7933        for id in [live_id, unknown_id] {
7934            pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
7935            let mut s = RunState::load_under(id, home.path()).unwrap();
7936            s.driver_exited = false;
7937            if id == live_id {
7938                s.driver_pid = Some(std::process::id());
7939                s.driver_started_at = crate::proc::process_started_at(std::process::id());
7940            }
7941            s.save_under(home.path()).unwrap();
7942        }
7943        let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
7944        assert_eq!(apply_pr_states(home.path(), &known), 0);
7945        for id in [live_id, unknown_id] {
7946            assert_eq!(recorded(home.path(), id), "open");
7947        }
7948    }
7949
7950    #[test]
7951    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
7952        let tmp = tempfile::tempdir().unwrap();
7953        let h = tmp.path();
7954        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
7955        pr_run(
7956            h,
7957            "20261004-100000-aaaa",
7958            "/repo/r",
7959            RunStatus::Superseded,
7960            &url(1),
7961            "open",
7962        );
7963        pr_run(
7964            h,
7965            "20261004-100100-bbbb",
7966            "/repo/r",
7967            RunStatus::Blocked,
7968            &url(2),
7969            "open",
7970        );
7971        pr_run(
7972            h,
7973            "20261004-100200-cccc",
7974            "/repo/r",
7975            RunStatus::Ready,
7976            &url(3),
7977            "open",
7978        );
7979        pr_run(
7980            h,
7981            "20261004-100300-dddd",
7982            "/repo/r",
7983            RunStatus::Ready,
7984            &url(4),
7985            "open",
7986        );
7987        pr_run(
7988            h,
7989            "20261004-100400-eeee",
7990            "/repo/r",
7991            RunStatus::Implementing,
7992            &url(1),
7993            "open",
7994        );
7995        assert_eq!(stale_open_prs(h).len(), 4);
7996
7997        let mut known = BTreeMap::new();
7998        known.insert(url(1), PrLifecycle::Merged);
7999        known.insert(url(2), PrLifecycle::Closed);
8000        known.insert(url(3), PrLifecycle::Open);
8001        // #4: the forge could not be read, so it has no answer.
8002        assert_eq!(apply_pr_states(h, &known), 2);
8003        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
8004        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
8005        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
8006        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
8007        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
8008        assert_eq!(apply_pr_states(h, &known), 0);
8009    }
8010
8011    // --- the land loop against a scripted forge -------------------------
8012
8013    use std::collections::VecDeque;
8014    use std::sync::Mutex;
8015
8016    /// Answers views from a script (the last one repeats), merges from a
8017    /// queue, and records every call so a test can assert the order.
8018    struct Scripted {
8019        views: Mutex<VecDeque<Seen>>,
8020        merges: Mutex<VecDeque<(bool, String)>>,
8021        fix: Mutex<Option<Fixed>>,
8022        log: Mutex<Vec<&'static str>>,
8023        argvs: Mutex<Vec<Vec<String>>>,
8024        required: Mutex<Option<BTreeSet<String>>>,
8025        /// Set once a merge answered ok: the forge then reports `merged`,
8026        /// unless `queued` says the merge only entered a queue.
8027        merged: Mutex<bool>,
8028        queued: Mutex<bool>,
8029        /// Views fail once a merge answered ok.
8030        unreadable_after_merge: Mutex<bool>,
8031        /// Workflow runs asked to rerun, and whether that call fails.
8032        reruns: Mutex<Vec<String>>,
8033        rerun_fails: Mutex<bool>,
8034        /// Unix seconds; every poll moves it on by `POLL`.
8035        clock: Mutex<i64>,
8036    }
8037
8038    impl Scripted {
8039        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
8040            Self {
8041                views: Mutex::new(views.into()),
8042                merges: Mutex::new(
8043                    merges
8044                        .into_iter()
8045                        .map(|(ok, m)| (ok, m.to_owned()))
8046                        .collect(),
8047                ),
8048                fix: Mutex::new(None),
8049                log: Mutex::new(Vec::new()),
8050                argvs: Mutex::new(Vec::new()),
8051                required: Mutex::new(None),
8052                merged: Mutex::new(false),
8053                queued: Mutex::new(false),
8054                unreadable_after_merge: Mutex::new(false),
8055                reruns: Mutex::new(Vec::new()),
8056                rerun_fails: Mutex::new(false),
8057                clock: Mutex::new(1_000),
8058            }
8059        }
8060        fn argvs(&self) -> Vec<Vec<String>> {
8061            self.argvs.lock().unwrap().clone()
8062        }
8063        fn calls(&self) -> Vec<&'static str> {
8064            self.log.lock().unwrap().clone()
8065        }
8066    }
8067
8068    impl Forge for Scripted {
8069        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
8070            self.log.lock().unwrap().push("view");
8071            if *self.unreadable_after_merge.lock().unwrap()
8072                && !self.argvs.lock().unwrap().is_empty()
8073            {
8074                anyhow::bail!("forge unreachable");
8075            }
8076            let mut v = self.views.lock().unwrap();
8077            let mut seen = if v.len() > 1 {
8078                v.pop_front().unwrap()
8079            } else {
8080                v[0].clone()
8081            };
8082            if *self.merged.lock().unwrap() {
8083                seen.pr.state = PrLifecycle::Merged;
8084            }
8085            Ok(seen)
8086        }
8087        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
8088            self.log.lock().unwrap().push("merge");
8089            self.argvs.lock().unwrap().push(argv.to_vec());
8090            let out = self
8091                .merges
8092                .lock()
8093                .unwrap()
8094                .pop_front()
8095                .expect("unscripted merge");
8096            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
8097            {
8098                *self.merged.lock().unwrap() = true;
8099            }
8100            Ok(out)
8101        }
8102        async fn poll(&self) {
8103            self.log.lock().unwrap().push("poll");
8104            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
8105        }
8106        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
8107            self.log.lock().unwrap().push("rerun");
8108            self.reruns.lock().unwrap().push(run.to_owned());
8109            if *self.rerun_fails.lock().unwrap() {
8110                anyhow::bail!("gh run rerun: no");
8111            }
8112            Ok(())
8113        }
8114        fn now(&self) -> i64 {
8115            *self.clock.lock().unwrap()
8116        }
8117        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
8118            self.required.lock().unwrap().clone()
8119        }
8120        async fn fix(
8121            &self,
8122            _state: &mut RunState,
8123            _pr: &PrState,
8124            _round: usize,
8125            _budget: usize,
8126            _reason: &str,
8127            _logs: &str,
8128        ) -> Result<Fixed> {
8129            self.log.lock().unwrap().push("fix");
8130            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
8131        }
8132    }
8133
8134    const REFUSED: &str =
8135        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
8136
8137    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
8138        let mut pr = green_pr();
8139        pr.checks = checks;
8140        pr.blocking = Blocking::of(merge_state);
8141        if !comments {
8142            pr.review_comments.clear();
8143        }
8144        Seen {
8145            pr,
8146            title: "feat: x".to_owned(),
8147            failing_urls: Vec::new(),
8148            head: head.to_owned(),
8149            rollup_head: head.to_owned(),
8150            merge_state: merge_state.to_owned(),
8151            contexts: Vec::new(),
8152            base: "main".to_owned(),
8153        }
8154    }
8155
8156    fn landing_state() -> RunState {
8157        crate::run::pin_test_home();
8158        let mut state = run_state();
8159        state.config.graph.land_approval = false;
8160        state
8161    }
8162
8163    #[test]
8164    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
8165        assert!(!awaiting_new_head(None, "aaa"));
8166        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
8167        assert!(awaiting_new_head(Some("abc123"), "def456"));
8168        assert!(awaiting_new_head(Some("abc123"), ""));
8169    }
8170
8171    #[test]
8172    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
8173        let open = |c, m: &str| seen("a", c, m, false);
8174        let table = [
8175            (None, false, Refused::Pending),
8176            (
8177                Some(open(Checks::Pending, "BLOCKED")),
8178                false,
8179                Refused::Pending,
8180            ),
8181            (
8182                Some(open(Checks::Unknown, "BLOCKED")),
8183                false,
8184                Refused::Pending,
8185            ),
8186            (
8187                Some(open(Checks::Green, "UNKNOWN")),
8188                false,
8189                Refused::Pending,
8190            ),
8191            (Some(open(Checks::Green, "")), false, Refused::Pending),
8192            (
8193                Some(open(Checks::Green, "BLOCKED")),
8194                false,
8195                Refused::Recheck,
8196            ),
8197            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
8198        ];
8199        for (after, rechecked, want) in table {
8200            assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
8201        }
8202        let mut closed = open(Checks::Green, "CLEAN");
8203        closed.pr.state = PrLifecycle::Closed;
8204        assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
8205    }
8206
8207    #[tokio::test]
8208    async fn a_normal_landing_merges_on_the_first_look() {
8209        let mut state = landing_state();
8210        let forge = Scripted::new(
8211            vec![seen("a", Checks::Green, "CLEAN", false)],
8212            vec![(true, "")],
8213        );
8214        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8215            .await
8216            .unwrap();
8217        // One fresh read, then the head-bound merge.
8218        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
8219        assert_eq!(state.status, RunStatus::Merged);
8220    }
8221
8222    #[tokio::test]
8223    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
8224        let mut state = landing_state();
8225        let forge = Scripted::new(
8226            vec![seen("a", Checks::Green, "CLEAN", false)],
8227            std::iter::repeat_n((true, ""), 100).collect(),
8228        );
8229        *forge.queued.lock().unwrap() = true;
8230        let task = async {
8231            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8232                .await
8233                .unwrap();
8234        };
8235        // Still open after the command succeeded: it keeps watching and
8236        // never records a merge (it stops at the wait ceiling instead).
8237        task.await;
8238        assert_ne!(state.status, RunStatus::Merged);
8239    }
8240
8241    #[tokio::test]
8242    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
8243        let mut state = landing_state();
8244        let forge = Scripted::new(
8245            vec![seen("a", Checks::Green, "CLEAN", false)],
8246            std::iter::repeat_n((true, ""), 100).collect(),
8247        );
8248        *forge.unreadable_after_merge.lock().unwrap() = true;
8249        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8250            .await
8251            .ok();
8252        assert_ne!(state.status, RunStatus::Merged);
8253    }
8254
8255    #[tokio::test]
8256    async fn a_task_public_head_is_awaited_on_land_entry() {
8257        let mut state = landing_state();
8258        state.pr_head = Some("magi/task-abcd-1234".into());
8259        state.pr_pushed_head = Some("new".into());
8260        let forge = Scripted::new(
8261            vec![
8262                seen("old", Checks::Green, "CLEAN", false),
8263                seen("new", Checks::Green, "CLEAN", false),
8264            ],
8265            vec![(true, "")],
8266        );
8267        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8268            .await
8269            .unwrap();
8270        assert_eq!(
8271            forge.calls(),
8272            ["view", "poll", "view", "view", "merge", "view"]
8273        );
8274        assert_eq!(state.status, RunStatus::Merged);
8275    }
8276
8277    #[tokio::test]
8278    async fn a_superseded_public_head_owner_cannot_reenter_land() {
8279        let mut state = landing_state();
8280        state.pr_taken_over_by = Some("next".into());
8281        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
8282        assert!(
8283            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8284                .await
8285                .is_err()
8286        );
8287        assert!(forge.calls().is_empty());
8288    }
8289
8290    #[tokio::test]
8291    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
8292        let mut state = landing_state();
8293        let forge = Scripted::new(
8294            vec![
8295                seen("old", Checks::Green, "CLEAN", true),
8296                // The forge has not moved to the new head yet: still green.
8297                seen("old", Checks::Green, "CLEAN", true),
8298                seen("new", Checks::Pending, "BLOCKED", true),
8299                seen("new", Checks::Green, "CLEAN", true),
8300            ],
8301            vec![(true, "")],
8302        );
8303        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8304            head: "NEW".to_owned(),
8305        });
8306        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8307            .await
8308            .unwrap();
8309        assert_eq!(
8310            forge.calls(),
8311            [
8312                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
8313                "view"
8314            ]
8315        );
8316        assert_eq!(state.status, RunStatus::Merged);
8317    }
8318
8319    #[tokio::test]
8320    async fn a_head_that_never_arrives_stops_naming_both_commits() {
8321        let mut state = landing_state();
8322        let forge = Scripted::new(
8323            vec![
8324                seen("old", Checks::Green, "CLEAN", true),
8325                seen("someone-elses", Checks::Green, "CLEAN", true),
8326            ],
8327            vec![],
8328        );
8329        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8330            head: "mine".to_owned(),
8331        });
8332        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8333            .await
8334            .unwrap();
8335        assert!(!forge.calls().contains(&"merge"));
8336        let why = state.merge.as_ref().unwrap().detail.clone();
8337        assert!(
8338            why.contains("mine") && why.contains("someone-elses"),
8339            "{why}"
8340        );
8341        assert_eq!(state.status, RunStatus::Blocked);
8342    }
8343
8344    #[tokio::test]
8345    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
8346        let mut state = landing_state();
8347        let forge = Scripted::new(
8348            vec![
8349                seen("a", Checks::Green, "CLEAN", false),
8350                seen("a", Checks::Green, "CLEAN", false),
8351                seen("a", Checks::Pending, "BLOCKED", false),
8352                seen("a", Checks::Pending, "BLOCKED", false),
8353                seen("a", Checks::Green, "CLEAN", false),
8354            ],
8355            vec![(false, REFUSED), (true, "")],
8356        );
8357        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8358            .await
8359            .unwrap();
8360        assert_eq!(
8361            forge.calls(),
8362            [
8363                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
8364                "view"
8365            ]
8366        );
8367        assert_eq!(state.status, RunStatus::Merged);
8368    }
8369
8370    #[tokio::test]
8371    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
8372        let mut state = landing_state();
8373        let forge = Scripted::new(
8374            vec![
8375                seen("a", Checks::Green, "CLEAN", false),
8376                seen("a", Checks::Green, "BLOCKED", false),
8377            ],
8378            vec![(false, REFUSED), (false, REFUSED)],
8379        );
8380        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8381            .await
8382            .unwrap();
8383        // One re-look is allowed for the forge's own lag, then it is final.
8384        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
8385        let why = state.merge.as_ref().unwrap().detail.clone();
8386        assert!(
8387            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
8388            "{why}"
8389        );
8390        assert_eq!(state.status, RunStatus::Blocked);
8391    }
8392
8393    #[test]
8394    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
8395        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
8396        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
8397        // The pull request is still on the commit before the push.
8398        assert_eq!(bound_head("old", "old", Some("new")), None);
8399        // The checks are the previous commit's.
8400        assert_eq!(bound_head("new", "old", Some("new")), None);
8401        assert_eq!(bound_head("new", "old", None), None);
8402        // Nothing readable.
8403        assert_eq!(bound_head("", "", None), None);
8404        assert_eq!(bound_head("", "", Some("new")), None);
8405        assert_eq!(bound_head("abc", "", None), None);
8406    }
8407
8408    fn view_json(head: &str) -> String {
8409        format!(
8410            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
8411            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
8412            "reviews":[],"comments":[]}}"#
8413        )
8414    }
8415
8416    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
8417        format!(
8418            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
8419            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
8420            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
8421            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
8422        )
8423    }
8424
8425    #[test]
8426    fn rollup_is_bound_to_the_commit_in_the_same_node() {
8427        // The view already points at the new head, but the node still answers
8428        // for the old commit with its red check: the head stays unbound.
8429        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
8430        assert_eq!(s.rollup_head, "old");
8431        assert_eq!(s.pr.checks, Checks::Red);
8432        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
8433        assert_eq!(s.failing_urls.len(), 1);
8434    }
8435
8436    #[test]
8437    fn checks_come_from_the_node_not_the_view() {
8438        let view = view_json("new").replace(
8439            r#""reviews""#,
8440            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
8441        );
8442        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
8443        assert_eq!(s.pr.checks, Checks::Green);
8444        assert!(s.pr.failing.is_empty());
8445        assert_eq!(
8446            bound_head(&s.head, &s.rollup_head, Some("new")),
8447            Some("new")
8448        );
8449    }
8450
8451    #[test]
8452    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
8453        for node in [
8454            None,
8455            Some("not json".to_owned()),
8456            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
8457            Some(node_json("new", "SUCCESS", true)),
8458        ] {
8459            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
8460            assert!(s.rollup_head.is_empty());
8461            assert_eq!(s.pr.checks, Checks::Unknown);
8462            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
8463        }
8464    }
8465
8466    #[test]
8467    fn the_merge_command_is_pinned_to_the_observed_head() {
8468        let argv = merge_argv_at(7, "feat: x", "deadbeef");
8469        let at = argv
8470            .iter()
8471            .position(|a| a == "--match-head-commit")
8472            .unwrap();
8473        assert_eq!(argv[at + 1], "deadbeef");
8474    }
8475
8476    #[tokio::test]
8477    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
8478        let mut state = landing_state();
8479        // The pull request is on the pushed head but the rollup is still the
8480        // previous commit's red, non-required result.
8481        let mut stale = seen("new", Checks::Red, "CLEAN", false);
8482        stale.rollup_head = "old".to_owned();
8483        let forge = Scripted::new(
8484            vec![seen("old", Checks::Green, "CLEAN", true), stale],
8485            vec![],
8486        );
8487        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8488            head: "new".to_owned(),
8489        });
8490        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8491            .await
8492            .unwrap();
8493        assert!(!forge.calls().contains(&"merge"));
8494        assert_eq!(state.status, RunStatus::Blocked);
8495        let why = state.merge.as_ref().unwrap().detail.clone();
8496        assert!(why.contains("new") && why.contains("old"), "{why}");
8497    }
8498
8499    #[test]
8500    fn a_refusal_read_against_another_commits_checks_is_pending() {
8501        let mut after = seen("a", Checks::Green, "BLOCKED", false);
8502        after.rollup_head = "old".to_owned();
8503        assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
8504    }
8505
8506    #[tokio::test]
8507    async fn a_matching_head_with_red_non_required_checks_still_merges() {
8508        let mut state = landing_state();
8509        let forge = Scripted::new(
8510            vec![seen("a", Checks::Red, "CLEAN", false)],
8511            vec![(true, "")],
8512        );
8513        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8514            .await
8515            .unwrap();
8516        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
8517        assert_eq!(state.status, RunStatus::Merged);
8518    }
8519
8520    #[tokio::test]
8521    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
8522        let mut state = landing_state();
8523        let forge = Scripted::new(
8524            vec![
8525                seen("a", Checks::Green, "CLEAN", false),
8526                seen("a", Checks::Green, "CLEAN", false),
8527                // Re-viewed after the refusal: someone pushed.
8528                seen("b", Checks::Green, "BLOCKED", false),
8529                seen("b", Checks::Green, "CLEAN", false),
8530            ],
8531            vec![(false, REFUSED), (true, "")],
8532        );
8533        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8534            .await
8535            .unwrap();
8536        assert_eq!(
8537            forge.calls(),
8538            [
8539                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
8540            ]
8541        );
8542        assert_eq!(state.status, RunStatus::Merged);
8543    }
8544
8545    fn merged_view(head: &str) -> Seen {
8546        let mut m = seen(head, Checks::Green, "CLEAN", false);
8547        m.pr.state = PrLifecycle::Merged;
8548        m
8549    }
8550
8551    fn has(argv: &[String], flag: &str) -> bool {
8552        argv.iter().any(|a| a == flag)
8553    }
8554
8555    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
8556        let at = argv.iter().position(|a| a == flag)?;
8557        argv.get(at + 1).map(String::as_str)
8558    }
8559
8560    const URL: &str = "https://github.com/o/r/pull/42";
8561
8562    #[tokio::test]
8563    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
8564        let mut state = landing_state();
8565        let forge = Scripted::new(
8566            vec![
8567                seen("abc", Checks::Green, "CLEAN", false),
8568                seen("abc", Checks::Green, "CLEAN", false),
8569            ],
8570            vec![(true, "")],
8571        );
8572        land_with(&mut state, URL, &forge).await.unwrap();
8573        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
8574        let argv = &forge.argvs()[0];
8575        assert!(has(argv, "--squash") && has(argv, "--subject"));
8576        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
8577        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
8578        assert_eq!(state.status, RunStatus::Merged);
8579        assert!(state.land_armed_head.is_none());
8580    }
8581
8582    #[tokio::test]
8583    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
8584        let mut state = landing_state();
8585        state.land_armed_head = Some("a".to_owned());
8586        let forge = Scripted::new(
8587            vec![seen("a", Checks::Green, "CLEAN", false)],
8588            vec![(true, ""), (true, "")],
8589        );
8590        land_with(&mut state, URL, &forge).await.unwrap();
8591        let argvs = forge.argvs();
8592        assert!(has(&argvs[0], "--disable-auto"));
8593        assert!(!has(&argvs[1], "--auto"));
8594        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
8595        assert_eq!(state.status, RunStatus::Merged);
8596        assert!(state.land_armed_head.is_none());
8597    }
8598
8599    #[tokio::test]
8600    async fn an_approval_never_carries_over_to_a_new_head() {
8601        crate::run::pin_test_home();
8602        let mut state = run_state();
8603        state.config.graph.land_approval = true;
8604        let pr = green_pr();
8605        let store = ask::Questions::open();
8606
8607        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
8608            .await
8609            .unwrap();
8610        let mut q = store
8611            .list()
8612            .into_iter()
8613            .find(|q| q.run == state.id)
8614            .unwrap();
8615        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8616        store.put(&mut q).unwrap();
8617        assert_eq!(
8618            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
8619                .await
8620                .unwrap(),
8621            ApprovalGate::Approved,
8622            "the same head keeps its approval"
8623        );
8624
8625        // A new head is a new question, not the old word.
8626        assert_eq!(
8627            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8628                .await
8629                .unwrap(),
8630            ApprovalGate::Pending
8631        );
8632        let all: Vec<_> = store
8633            .list()
8634            .into_iter()
8635            .filter(|q| q.run == state.id)
8636            .collect();
8637        assert_eq!(all.len(), 2);
8638
8639        // A question recorded before heads were tracked is not reused either.
8640        state.land_approval = None;
8641        assert_eq!(
8642            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8643                .await
8644                .unwrap(),
8645            ApprovalGate::Pending
8646        );
8647        let open = store
8648            .list()
8649            .into_iter()
8650            .filter(|q| q.run == state.id && q.status.open())
8651            .count();
8652        assert_eq!(open, 1, "the superseded question was retired");
8653    }
8654
8655    #[tokio::test]
8656    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
8657        let mut state = landing_state();
8658        let forge = Scripted::new(
8659            vec![
8660                seen("a", Checks::Green, "CLEAN", false),
8661                // The fresh read before the merge: someone pushed.
8662                seen("b", Checks::Green, "CLEAN", false),
8663            ],
8664            vec![(true, "")],
8665        );
8666        land_with(&mut state, URL, &forge).await.unwrap();
8667        let argvs = forge.argvs();
8668        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
8669        assert!(!has(&argvs[0], "--auto"));
8670        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
8671        assert_eq!(state.status, RunStatus::Merged);
8672    }
8673
8674    fn passing(label: &str) -> CheckInfo {
8675        CheckInfo {
8676            label: label.to_owned(),
8677            verdict: Verdict::Pass,
8678            required: Some(false),
8679            run: None,
8680        }
8681    }
8682
8683    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
8684        (label.to_owned(), run.map(str::to_owned))
8685    }
8686
8687    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
8688        LandReruns {
8689            head: head.to_owned(),
8690            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
8691        }
8692    }
8693
8694    #[test]
8695    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
8696        let f = [failing("test (macos)", Some("7"))];
8697        assert_eq!(
8698            rerun_step(&f, None, "h", 100),
8699            RerunStep::Rerun(vec!["7".to_owned()])
8700        );
8701        let rec = reran("h", &[("7", 100)]);
8702        let grace = crate::release_watch::RERUN_GRACE;
8703        assert_eq!(
8704            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
8705            RerunStep::Grace
8706        );
8707        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
8708    }
8709
8710    #[test]
8711    fn a_failure_without_a_run_id_goes_to_the_fixer() {
8712        let f = [failing("a", Some("7")), failing("ci/external", None)];
8713        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
8714        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
8715    }
8716
8717    #[test]
8718    fn a_moved_head_forgets_the_reruns() {
8719        let f = [failing("a", Some("7"))];
8720        let rec = reran("old", &[("7", 100)]);
8721        assert_eq!(
8722            rerun_step(&f, Some(&rec), "new", 101),
8723            RerunStep::Rerun(vec!["7".to_owned()])
8724        );
8725    }
8726
8727    #[test]
8728    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
8729        let f = [
8730            failing("a", Some("7")),
8731            failing("b", Some("7")),
8732            failing("c", Some("8")),
8733        ];
8734        assert_eq!(
8735            rerun_step(&f, None, "h", 0),
8736            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
8737        );
8738        let rec = reran("h", &[("7", 0)]);
8739        assert_eq!(
8740            rerun_step(&f, Some(&rec), "h", 1),
8741            RerunStep::Rerun(vec!["8".to_owned()])
8742        );
8743    }
8744
8745    #[test]
8746    fn only_blocking_failures_are_candidates_for_a_rerun() {
8747        let check = |label: &str, verdict, required, run: &str| CheckInfo {
8748            label: label.to_owned(),
8749            verdict,
8750            required,
8751            run: Some(run.to_owned()),
8752        };
8753        let got = rerunnable(&[
8754            check("passed", Verdict::Pass, Some(true), "1"),
8755            check("optional", Verdict::Fail, Some(false), "2"),
8756            check("required", Verdict::Fail, Some(true), "3"),
8757            check("unknown", Verdict::Fail, None, "4"),
8758        ]);
8759        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
8760        assert_eq!(labels, ["required", "unknown"]);
8761    }
8762
8763    fn red(head: &str, run: Option<&str>) -> Seen {
8764        let mut s = seen(head, Checks::Red, "BLOCKED", false);
8765        s.pr.failing = vec!["test (macos)".to_owned()];
8766        s.contexts = vec![CheckInfo {
8767            label: "test (macos)".to_owned(),
8768            verdict: Verdict::Fail,
8769            required: Some(true),
8770            run: run.map(str::to_owned),
8771        }];
8772        s
8773    }
8774
8775    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
8776
8777    #[tokio::test]
8778    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
8779        let mut state = landing_state();
8780        let forge = Scripted::new(
8781            vec![
8782                red("a", Some("7")),
8783                seen("a", Checks::Pending, "BLOCKED", false),
8784                seen("a", Checks::Green, "CLEAN", false),
8785            ],
8786            vec![(true, "")],
8787        );
8788        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8789        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8790        assert!(!forge.calls().contains(&"fix"));
8791        assert_eq!(state.status, RunStatus::Merged);
8792        assert_eq!(state.pr.as_ref().unwrap().round, 0);
8793        let argvs = forge.argvs();
8794        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
8795        assert!(
8796            state
8797                .events
8798                .iter()
8799                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
8800        );
8801    }
8802
8803    #[tokio::test]
8804    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
8805        let mut state = landing_state();
8806        let forge = Scripted::new(
8807            vec![
8808                red("a", Some("7")),
8809                seen("a", Checks::Green, "CLEAN", false),
8810            ],
8811            vec![(true, "")],
8812        );
8813        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8814        // The red view repeats for the grace, then the fixer is asked once.
8815        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8816        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
8817        let calls = forge.calls();
8818        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
8819        let fix_at = calls.iter().position(|c| *c == "fix");
8820        assert!(fix_at.is_none_or(|f| f > rerun_at));
8821    }
8822
8823    #[tokio::test]
8824    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
8825        let mut state = landing_state();
8826        let mut v = red("a", Some("7"));
8827        v.pr.checks = Checks::Pending;
8828        v.contexts.push(CheckInfo {
8829            label: "slow".to_owned(),
8830            verdict: Verdict::Pending,
8831            required: Some(true),
8832            run: Some("8".to_owned()),
8833        });
8834        let forge = Scripted::new(
8835            vec![v, seen("a", Checks::Green, "CLEAN", false)],
8836            vec![(true, "")],
8837        );
8838        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8839        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8840        assert_eq!(state.status, RunStatus::Merged);
8841    }
8842
8843    #[test]
8844    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
8845        let mut v = red("a", Some("7"));
8846        v.contexts.push(CheckInfo {
8847            label: "same run, still going".to_owned(),
8848            verdict: Verdict::Pending,
8849            required: Some(true),
8850            run: Some("7".to_owned()),
8851        });
8852        assert!(rerunnable(&v.contexts).is_empty());
8853    }
8854
8855    #[tokio::test]
8856    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
8857        let mut state = landing_state();
8858        state.config.graph.land_rounds = 0;
8859        let forge = Scripted::new(
8860            vec![
8861                red("a", Some("7")),
8862                seen("a", Checks::Green, "CLEAN", false),
8863            ],
8864            vec![(true, "")],
8865        );
8866        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8867        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8868        assert_eq!(state.status, RunStatus::Merged);
8869        assert!(!forge.calls().contains(&"fix"));
8870        assert_eq!(state.pr.as_ref().unwrap().round, 0);
8871    }
8872
8873    #[tokio::test]
8874    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
8875        let mut state = landing_state();
8876        state.config.graph.land_rounds = 1;
8877        let forge = Scripted::new(
8878            vec![
8879                seen("a", Checks::Green, "CLEAN", true),
8880                red("b", Some("7")),
8881                seen("b", Checks::Green, "CLEAN", false),
8882            ],
8883            vec![(true, "")],
8884        );
8885        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8886            head: "b".to_owned(),
8887        });
8888        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8889        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8890        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
8891        assert_eq!(state.pr.as_ref().unwrap().round, 1);
8892        assert_eq!(state.status, RunStatus::Merged);
8893        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
8894        assert_eq!(
8895            value_of(&forge.argvs()[0], "--match-head-commit"),
8896            Some("b")
8897        );
8898    }
8899
8900    #[tokio::test]
8901    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
8902        let mut state = landing_state();
8903        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8904        *forge.rerun_fails.lock().unwrap() = true;
8905        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8906        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8907        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
8908        assert_eq!(
8909            forge.calls().iter().filter(|c| **c == "fix").count(),
8910            1,
8911            "{:?}",
8912            forge.calls()
8913        );
8914    }
8915
8916    #[tokio::test]
8917    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
8918        let mut state = landing_state();
8919        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8920        // The record was written just before the previous pass died.
8921        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
8922        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8923        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8924        assert!(forge.reruns.lock().unwrap().is_empty());
8925    }
8926
8927    #[tokio::test]
8928    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
8929        let mut state = landing_state();
8930        state.config.graph.rerun_flaky_checks = false;
8931        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8932        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8933        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8934        assert!(forge.reruns.lock().unwrap().is_empty());
8935        assert!(forge.calls().contains(&"fix"));
8936
8937        let mut state = landing_state();
8938        let forge = Scripted::new(vec![red("a", None)], vec![]);
8939        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8940        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8941        assert!(forge.reruns.lock().unwrap().is_empty());
8942        assert!(forge.calls().contains(&"fix"));
8943    }
8944
8945    fn names(xs: &[&str]) -> BTreeSet<String> {
8946        xs.iter().map(|x| (*x).to_owned()).collect()
8947    }
8948
8949    #[test]
8950    fn a_required_check_the_rollup_never_listed_is_named() {
8951        let req = names(&["build"]);
8952        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
8953        assert!(why.contains("never reported: build"), "{why}");
8954        assert!(!why.contains("probably waiting for a review"), "{why}");
8955    }
8956
8957    #[test]
8958    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
8959        let why = waiting_on("BLOCKED", &[passing("review")], None);
8960        assert!(why.contains("could not be read"), "{why}");
8961        assert!(!why.contains("probably waiting for a review"), "{why}");
8962    }
8963
8964    #[test]
8965    fn all_required_reported_keeps_the_review_guess() {
8966        let req = names(&["build"]);
8967        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8968        assert!(why.contains("probably waiting for a review"), "{why}");
8969        assert!(!why.contains("never reported"), "{why}");
8970    }
8971
8972    #[test]
8973    fn required_names_match_the_rollup_ignoring_case_only() {
8974        let req = names(&["Build"]);
8975        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8976        assert!(!why.contains("never reported"), "{why}");
8977    }
8978
8979    #[test]
8980    fn required_contexts_are_read_from_protection_and_rulesets() {
8981        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
8982        assert_eq!(
8983            parse_classic_required(classic),
8984            Some(names(&["build", "lint"]))
8985        );
8986        let rules = r#"[{"type":"pull_request","parameters":{}},
8987            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
8988        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
8989        assert_eq!(parse_ruleset_required("nope"), None);
8990        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
8991    }
8992
8993    #[test]
8994    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
8995        let shown = BTreeSet::new();
8996        let ok = seen("a", Checks::Green, "CLEAN", false);
8997        let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
8998        assert!(guard(Some(&ok)));
8999        assert!(!guard(None));
9000        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
9001        let mut stale = ok.clone();
9002        stale.rollup_head = "old".to_owned();
9003        assert!(!guard(Some(&stale)));
9004        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
9005        assert!(!guard(Some(&merged_view("a"))));
9006    }
9007
9008    #[test]
9009    fn the_rollup_node_carries_whether_each_check_is_required() {
9010        let node = node_json("new", "SUCCESS", false)
9011            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
9012        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
9013        assert_eq!(s.contexts.len(), 1);
9014        assert_eq!(s.contexts[0].required, Some(true));
9015        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
9016        assert_eq!(s.contexts[0].required, None);
9017    }
9018
9019    #[tokio::test]
9020    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
9021        let mut state = landing_state();
9022        state.land_armed_head = Some("a".to_owned());
9023        let forge = Scripted::new(
9024            vec![seen("a", Checks::Green, "CLEAN", true)],
9025            vec![(false, "disable exploded")],
9026        );
9027        land_with(&mut state, URL, &forge).await.unwrap();
9028        assert!(!forge.calls().contains(&"fix"));
9029        assert_eq!(state.status, RunStatus::Blocked);
9030        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
9031        let why = state.merge.as_ref().unwrap().detail.clone();
9032        assert!(why.contains("disable exploded"), "{why}");
9033    }
9034}