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