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            sender: None,
6404            breaks: Some(Vec::new()),
6405            messages: Vec::new(),
6406            posted_by: None,
6407            who: crate::talk::Who::Operator,
6408            body: "Merge this pull request now".into(),
6409            at: Timestamp::now(),
6410            attachments: Vec::new(),
6411            usage: None,
6412        });
6413        talks.put(&mut talk).unwrap();
6414        crate::consult::validate_answer(
6415            &q,
6416            &talks,
6417            &talk.id,
6418            crate::queue::CHAT_NODE,
6419            APPROVE,
6420            Some("Merge this pull request now"),
6421        )
6422        .unwrap();
6423        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6424        store.put(&mut q).unwrap();
6425
6426        assert_eq!(
6427            approval_gate(&mut state, &pr, "feat: x", None, "abc")
6428                .await
6429                .unwrap(),
6430            ApprovalGate::Approved
6431        );
6432    }
6433
6434    #[tokio::test]
6435    async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
6436        crate::run::pin_test_home();
6437        let store = ask::Questions::open();
6438
6439        let mut held_state = run_state();
6440        held_state.config.graph.land_approval = true;
6441        let pr = green_pr();
6442        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6443            .await
6444            .unwrap();
6445        let mut q = store
6446            .list()
6447            .into_iter()
6448            .find(|q| q.run == held_state.id)
6449            .expect("filed above");
6450        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
6451        store.put(&mut q).unwrap();
6452        assert_eq!(
6453            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6454                .await
6455                .unwrap(),
6456            ApprovalGate::Held
6457        );
6458
6459        let mut abandoned_state = run_state();
6460        abandoned_state.config.graph.land_approval = true;
6461        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6462            .await
6463            .unwrap();
6464        let mut q = store
6465            .list()
6466            .into_iter()
6467            .find(|q| q.run == abandoned_state.id)
6468            .expect("filed above");
6469        q.abandon("no answer within the timeout");
6470        store.put(&mut q).unwrap();
6471        assert_eq!(
6472            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6473                .await
6474                .unwrap(),
6475            ApprovalGate::Held,
6476            "silence must never merge"
6477        );
6478    }
6479
6480    fn contested() -> ContestedHandoff {
6481        let finding = |id: &str, n: u32| crate::verdict::Finding {
6482            id: id.to_owned(),
6483            severity: crate::verdict::Severity::Major,
6484            file: Some("src/a.rs".to_owned()),
6485            line: Some(n),
6486            title: format!("problem {id}"),
6487            detail: String::new(),
6488        };
6489        ContestedHandoff {
6490            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
6491            rejecters: vec![(1, "alpha".to_owned())],
6492        }
6493    }
6494
6495    #[test]
6496    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
6497        let mut state = run_state();
6498        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
6499        state.contested_handoff = Some(contested());
6500        assert!(contested_to_ask(&state).is_some());
6501        state.config.graph.hold_contested_merge = false;
6502        assert!(
6503            contested_to_ask(&state).is_none(),
6504            "the switch restores today"
6505        );
6506    }
6507
6508    #[test]
6509    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
6510        let q = ask::Question::new(
6511            "run-1".to_owned(),
6512            APPROVAL_NODE.to_owned(),
6513            "land".to_owned(),
6514            "Merge?".to_owned(),
6515            String::new(),
6516            vec![APPROVE.to_owned(), HOLD.to_owned()],
6517        );
6518        let none = deputy_brief(&q, None);
6519        assert!(none.contains("could not be read"), "{none}");
6520        assert!(none.contains("Silence is a hold"), "{none}");
6521
6522        let mut state = run_state();
6523        state.pr = Some(crate::run::PrRecord {
6524            url: "https://example.test/pull/7".to_owned(),
6525            number: 7,
6526            state: "open".to_owned(),
6527            checks: "green".to_owned(),
6528            round: 0,
6529            rounds: 3,
6530            red_at_merge: Vec::new(),
6531        });
6532        state.contested_handoff = Some(contested());
6533        let b = deputy_brief(&q, Some(&state));
6534        assert!(b.contains("https://example.test/pull/7"), "{b}");
6535        assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
6536        assert!(b.contains("#1"), "the rejecting seat: {b}");
6537        state.contested_handoff = None;
6538        assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
6539    }
6540
6541    #[test]
6542    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
6543        for lang in ["en", "ja"] {
6544            let mut cfg = crate::config::Config::default();
6545            cfg.graph.language = lang.to_owned();
6546            let w = words(&cfg.graph.language);
6547            let text = w.approval_detail(
6548                "https://github.com/yukimemi/magi/pull/42",
6549                "main",
6550                "feat: x",
6551                Some(&contested()),
6552            );
6553            assert!(text.contains("pull/42"), "{text}");
6554            assert!(
6555                text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
6556                "{text}"
6557            );
6558            assert!(text.contains("R3-1-5"), "{text}");
6559            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
6560            assert!(text.contains("2"), "the rest are counted: {text}");
6561            assert!(text.contains("#1 (alpha)"), "{text}");
6562        }
6563        let plain = words("en").approval_detail("u", "main", "s", None);
6564        assert!(!plain.contains("reject"), "{plain}");
6565    }
6566
6567    #[tokio::test]
6568    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
6569        crate::run::pin_test_home();
6570        let mut state = run_state();
6571        state.config.graph.land_approval = false;
6572        state.contested_handoff = Some(contested());
6573        let pr = green_pr();
6574        let c = contested_to_ask(&state);
6575        assert_eq!(
6576            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6577                .await
6578                .unwrap(),
6579            ApprovalGate::Pending,
6580            "silence is a hold"
6581        );
6582        let store = ask::Questions::open();
6583        let filed: Vec<_> = store
6584            .list()
6585            .into_iter()
6586            .filter(|q| q.run == state.id)
6587            .collect();
6588        assert_eq!(filed.len(), 1);
6589        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
6590
6591        assert_eq!(
6592            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6593                .await
6594                .unwrap(),
6595            ApprovalGate::Pending
6596        );
6597        let mut q = store
6598            .list()
6599            .into_iter()
6600            .find(|q| q.run == state.id)
6601            .unwrap();
6602        assert_eq!(q.id, filed[0].id, "the same question after a resume");
6603        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6604        store.put(&mut q).unwrap();
6605        assert_eq!(
6606            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6607                .await
6608                .unwrap(),
6609            ApprovalGate::Approved
6610        );
6611    }
6612
6613    #[test]
6614    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
6615        let rows = parse_numstat(NUMSTAT);
6616        assert_eq!(
6617            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
6618            ["src/web.rs", "src/land.rs", "assets/logo.png"]
6619        );
6620        assert_eq!(rows[2].added, None, "a binary file has no line counts");
6621    }
6622    #[test]
6623    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
6624        // Reported from a real run: the merge question arrived in English on a
6625        // repository with `language = "ja"`. magi's own strings have to follow
6626        // that setting too - "it is a literal in Rust" is not an answer.
6627        let mut state = run_state();
6628        state.config.graph.language = "ja".to_owned();
6629        let pr = green_pr();
6630        let commits = ["c1".to_owned()];
6631
6632        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6633        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
6634        assert!(ja.contains("squash されるコミット"), "{ja}");
6635        assert!(ja.contains("レビューコメント"), "{ja}");
6636        assert!(ja.contains("差分"), "{ja}");
6637        assert!(
6638            !ja.contains("Commits being squashed"),
6639            "no English left over"
6640        );
6641
6642        let w = words("ja");
6643        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6644        assert!(
6645            w.approval_detail("http://x/1", "main", "feat: x", None)
6646                .contains("パネル")
6647        );
6648
6649        // The evidence itself is language-neutral and must survive either way.
6650        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6651        assert!(ja.contains("feat: x"), "nor is the merge subject");
6652
6653        // English stays the default, and a language magi cannot check falls
6654        // back to it rather than shipping a guess.
6655        state.config.graph.language = "en".to_owned();
6656        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6657        assert!(en.contains("Commits being squashed"), "{en}");
6658        assert_eq!(words("Klingon").html_lang, "en");
6659    }
6660
6661    /// A `gh pr list` result naming exactly one pull request whose base and
6662    /// merge time both fit the run is exactly the case
6663    /// [`find_external_merge`] exists to act on.
6664    #[test]
6665    fn pick_open_pr_classifies_by_count_and_base() {
6666        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6667        assert_eq!(
6668            pick_open_pr(one, "main").unwrap(),
6669            OpenPr::One {
6670                url: "https://x/pull/58".into(),
6671                title: "t".into()
6672            }
6673        );
6674        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6675        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6676        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6677                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6678        assert_eq!(
6679            pick_open_pr(two, "main").unwrap(),
6680            OpenPr::Many(vec!["u1".into(), "u2".into()])
6681        );
6682        // A fork's same-named branch is not a pull request for this head.
6683        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
6684        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
6685        assert!(pick_open_pr("not json", "main").is_err());
6686        // An incomplete record is an error, never "nothing open".
6687        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6688        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6689    }
6690
6691    #[test]
6692    fn pick_merged_pr_picks_the_unique_match() {
6693        let json = r#"[
6694            {"url": "https://github.com/o/r/pull/42", "number": 42,
6695             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6696        ]"#;
6697        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6698        let found = pick_merged_pr(json, "main", created_at)
6699            .expect("valid json")
6700            .expect("one unambiguous match");
6701        assert_eq!(found.url, "https://github.com/o/r/pull/42");
6702        assert_eq!(found.number, 42);
6703    }
6704
6705    /// Two candidates surviving the filter is exactly as uninformative as
6706    /// zero — a branch name can be reused across runs — so neither is
6707    /// preferred over the other and nothing is recorded automatically.
6708    #[test]
6709    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6710        let json = r#"[
6711            {"url": "https://github.com/o/r/pull/42", "number": 42,
6712             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6713            {"url": "https://github.com/o/r/pull/43", "number": 43,
6714             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6715        ]"#;
6716        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6717        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6718    }
6719
6720    /// A pull request that targets a different base branch cannot be this
6721    /// run's, whatever its head branch is named — a reused branch name from
6722    /// an unrelated task must not be recorded as this run's merge.
6723    #[test]
6724    fn pick_merged_pr_ignores_a_different_base_branch() {
6725        let json = r#"[
6726            {"url": "https://github.com/o/r/pull/42", "number": 42,
6727             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6728        ]"#;
6729        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6730        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6731    }
6732
6733    /// A pull request merged before this run was even created cannot be this
6734    /// run's winner, no matter how its head branch is spelled.
6735    #[test]
6736    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6737        let json = r#"[
6738            {"url": "https://github.com/o/r/pull/42", "number": 42,
6739             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6740        ]"#;
6741        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6742        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6743    }
6744
6745    #[test]
6746    fn slug_of_pr_url_reads_host_owner_and_repo() {
6747        assert_eq!(
6748            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6749            Some("github.com/yukimemi/shun")
6750        );
6751    }
6752
6753    #[test]
6754    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6755        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6756        assert_eq!(slug_of_pr_url("not a url at all"), None);
6757        assert_eq!(slug_of_pr_url("https://github.com"), None);
6758    }
6759
6760    #[test]
6761    fn slug_of_repo_url_reads_host_owner_and_repo() {
6762        assert_eq!(
6763            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6764            Some("github.com/yukimemi/magi")
6765        );
6766        assert_eq!(slug_of_repo_url("https://github.com"), None);
6767    }
6768
6769    #[test]
6770    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6771        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6772            .expect("same repo, different case");
6773    }
6774
6775    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
6776    /// own in-progress run and rewrote its status from a pull request in a
6777    /// completely different repository. This is the guard that must catch
6778    /// that even when an explicit (but wrong) id is given.
6779    #[test]
6780    fn ensure_same_repo_refuses_a_different_repo() {
6781        let err =
6782            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6783        let msg = format!("{err:#}");
6784        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6785        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6786    }
6787
6788    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
6789    /// instance mirroring a `github.com` repository's name, say) must not be
6790    /// treated as the same repository just because the trailing path
6791    /// matches.
6792    #[test]
6793    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6794        let err = ensure_same_repo(
6795            "github.com/yukimemi/magi",
6796            "github.example.com/yukimemi/magi",
6797        )
6798        .unwrap_err();
6799        let msg = format!("{err:#}");
6800        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6801        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6802    }
6803
6804    /// No winner decided yet means there is no branch to ask GitHub about at
6805    /// all — `find_external_merge` must return `None` without ever spawning
6806    /// `gh`, which this proves by never providing a real repository to spawn
6807    /// it in.
6808    #[tokio::test]
6809    async fn find_external_merge_returns_none_without_a_winner() {
6810        let state = RunState::new(
6811            PathBuf::from("/no/such/repo"),
6812            "main".to_owned(),
6813            "0000000000000000000000000000000000000000".to_owned(),
6814            "irrelevant".to_owned(),
6815            crate::config::Config::default(),
6816        );
6817        assert_eq!(find_external_merge(&state).await.unwrap(), None);
6818    }
6819
6820    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6821        let mut run = RunState::new(
6822            PathBuf::from(repo),
6823            "main".to_owned(),
6824            "abcdef1234".to_owned(),
6825            "x".to_owned(),
6826            crate::config::Config::default(),
6827        );
6828        run.id = id.to_owned();
6829        run.status = status;
6830        run.pr = Some(crate::run::PrRecord {
6831            number: url.rsplit('/').next().unwrap().parse().unwrap(),
6832            url: url.to_owned(),
6833            state: state.to_owned(),
6834            checks: "red".to_owned(),
6835            round: 0,
6836            rounds: 2,
6837            red_at_merge: Vec::new(),
6838        });
6839        run.save_under(home).unwrap();
6840    }
6841
6842    fn recorded(home: &Path, id: &str) -> String {
6843        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6844        serde_json::from_str::<RunState>(&body)
6845            .unwrap()
6846            .pr
6847            .unwrap()
6848            .state
6849    }
6850
6851    const PR: &str = "https://github.com/o/r/pull/7";
6852
6853    #[test]
6854    fn write_through_updates_predecessors_and_siblings_only() {
6855        let tmp = tempfile::tempdir().unwrap();
6856        let h = tmp.path();
6857        pr_run(
6858            h,
6859            "20261004-100000-aaaa",
6860            "/repo/r",
6861            RunStatus::Superseded,
6862            PR,
6863            "open",
6864        );
6865        pr_run(
6866            h,
6867            "20261004-100100-bbbb",
6868            "/repo/r",
6869            RunStatus::Blocked,
6870            PR,
6871            "open",
6872        );
6873        // Not terminal: a driver may be writing it.
6874        pr_run(
6875            h,
6876            "20261004-100200-cccc",
6877            "/repo/r",
6878            RunStatus::Landing,
6879            PR,
6880            "open",
6881        );
6882        // Another repository's pull request with the same number.
6883        pr_run(
6884            h,
6885            "20261004-100300-dddd",
6886            "/repo/other",
6887            RunStatus::Blocked,
6888            "https://github.com/o/other/pull/7",
6889            "open",
6890        );
6891        // A different pull request of the same repository.
6892        pr_run(
6893            h,
6894            "20261004-100400-eeee",
6895            "/repo/r",
6896            RunStatus::Blocked,
6897            "https://github.com/o/r/pull/8",
6898            "open",
6899        );
6900        pr_run(
6901            h,
6902            "20261004-100500-ffff",
6903            "/repo/r",
6904            RunStatus::Merged,
6905            PR,
6906            "open",
6907        );
6908        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6909
6910        assert_eq!(
6911            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6912            2
6913        );
6914        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6915        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6916        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6917        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6918        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6919        // The source's own record is the caller's to write.
6920        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6921        // Idempotent.
6922        assert_eq!(
6923            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6924            0
6925        );
6926        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6927        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6928    }
6929
6930    #[test]
6931    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6932        let tmp = tempfile::tempdir().unwrap();
6933        let h = tmp.path();
6934        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6935        pr_run(
6936            h,
6937            "20261004-100000-aaaa",
6938            "/repo/r",
6939            RunStatus::Superseded,
6940            &url(1),
6941            "open",
6942        );
6943        pr_run(
6944            h,
6945            "20261004-100100-bbbb",
6946            "/repo/r",
6947            RunStatus::Blocked,
6948            &url(2),
6949            "open",
6950        );
6951        pr_run(
6952            h,
6953            "20261004-100200-cccc",
6954            "/repo/r",
6955            RunStatus::Ready,
6956            &url(3),
6957            "open",
6958        );
6959        pr_run(
6960            h,
6961            "20261004-100300-dddd",
6962            "/repo/r",
6963            RunStatus::Ready,
6964            &url(4),
6965            "open",
6966        );
6967        pr_run(
6968            h,
6969            "20261004-100400-eeee",
6970            "/repo/r",
6971            RunStatus::Implementing,
6972            &url(1),
6973            "open",
6974        );
6975        assert_eq!(stale_open_prs(h).len(), 4);
6976
6977        let mut known = BTreeMap::new();
6978        known.insert(url(1), PrLifecycle::Merged);
6979        known.insert(url(2), PrLifecycle::Closed);
6980        known.insert(url(3), PrLifecycle::Open);
6981        // #4: the forge could not be read, so it has no answer.
6982        assert_eq!(apply_pr_states(h, &known), 2);
6983        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6984        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6985        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6986        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6987        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6988        assert_eq!(apply_pr_states(h, &known), 0);
6989    }
6990
6991    // --- the land loop against a scripted forge -------------------------
6992
6993    use std::collections::VecDeque;
6994    use std::sync::Mutex;
6995
6996    /// Answers views from a script (the last one repeats), merges from a
6997    /// queue, and records every call so a test can assert the order.
6998    struct Scripted {
6999        views: Mutex<VecDeque<Seen>>,
7000        merges: Mutex<VecDeque<(bool, String)>>,
7001        fix: Mutex<Option<Fixed>>,
7002        log: Mutex<Vec<&'static str>>,
7003        argvs: Mutex<Vec<Vec<String>>>,
7004        required: Mutex<Option<BTreeSet<String>>>,
7005        /// Set once a merge answered ok: the forge then reports `merged`,
7006        /// unless `queued` says the merge only entered a queue.
7007        merged: Mutex<bool>,
7008        queued: Mutex<bool>,
7009        /// Views fail once a merge answered ok.
7010        unreadable_after_merge: Mutex<bool>,
7011        /// Workflow runs asked to rerun, and whether that call fails.
7012        reruns: Mutex<Vec<String>>,
7013        rerun_fails: Mutex<bool>,
7014        /// Unix seconds; every poll moves it on by `POLL`.
7015        clock: Mutex<i64>,
7016    }
7017
7018    impl Scripted {
7019        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
7020            Self {
7021                views: Mutex::new(views.into()),
7022                merges: Mutex::new(
7023                    merges
7024                        .into_iter()
7025                        .map(|(ok, m)| (ok, m.to_owned()))
7026                        .collect(),
7027                ),
7028                fix: Mutex::new(None),
7029                log: Mutex::new(Vec::new()),
7030                argvs: Mutex::new(Vec::new()),
7031                required: Mutex::new(None),
7032                merged: Mutex::new(false),
7033                queued: Mutex::new(false),
7034                unreadable_after_merge: Mutex::new(false),
7035                reruns: Mutex::new(Vec::new()),
7036                rerun_fails: Mutex::new(false),
7037                clock: Mutex::new(1_000),
7038            }
7039        }
7040        fn argvs(&self) -> Vec<Vec<String>> {
7041            self.argvs.lock().unwrap().clone()
7042        }
7043        fn calls(&self) -> Vec<&'static str> {
7044            self.log.lock().unwrap().clone()
7045        }
7046    }
7047
7048    impl Forge for Scripted {
7049        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
7050            self.log.lock().unwrap().push("view");
7051            if *self.unreadable_after_merge.lock().unwrap()
7052                && !self.argvs.lock().unwrap().is_empty()
7053            {
7054                anyhow::bail!("forge unreachable");
7055            }
7056            let mut v = self.views.lock().unwrap();
7057            let mut seen = if v.len() > 1 {
7058                v.pop_front().unwrap()
7059            } else {
7060                v[0].clone()
7061            };
7062            if *self.merged.lock().unwrap() {
7063                seen.pr.state = PrLifecycle::Merged;
7064            }
7065            Ok(seen)
7066        }
7067        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
7068            self.log.lock().unwrap().push("merge");
7069            self.argvs.lock().unwrap().push(argv.to_vec());
7070            let out = self
7071                .merges
7072                .lock()
7073                .unwrap()
7074                .pop_front()
7075                .expect("unscripted merge");
7076            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
7077            {
7078                *self.merged.lock().unwrap() = true;
7079            }
7080            Ok(out)
7081        }
7082        async fn poll(&self) {
7083            self.log.lock().unwrap().push("poll");
7084            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
7085        }
7086        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
7087            self.log.lock().unwrap().push("rerun");
7088            self.reruns.lock().unwrap().push(run.to_owned());
7089            if *self.rerun_fails.lock().unwrap() {
7090                anyhow::bail!("gh run rerun: no");
7091            }
7092            Ok(())
7093        }
7094        fn now(&self) -> i64 {
7095            *self.clock.lock().unwrap()
7096        }
7097        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
7098            self.required.lock().unwrap().clone()
7099        }
7100        async fn fix(
7101            &self,
7102            _state: &mut RunState,
7103            _pr: &PrState,
7104            _round: usize,
7105            _budget: usize,
7106            _reason: &str,
7107            _logs: &str,
7108        ) -> Result<Fixed> {
7109            self.log.lock().unwrap().push("fix");
7110            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
7111        }
7112    }
7113
7114    const REFUSED: &str =
7115        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
7116
7117    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
7118        let mut pr = green_pr();
7119        pr.checks = checks;
7120        pr.blocking = Blocking::of(merge_state);
7121        if !comments {
7122            pr.review_comments.clear();
7123        }
7124        Seen {
7125            pr,
7126            title: "feat: x".to_owned(),
7127            failing_urls: Vec::new(),
7128            head: head.to_owned(),
7129            rollup_head: head.to_owned(),
7130            merge_state: merge_state.to_owned(),
7131            contexts: Vec::new(),
7132            base: "main".to_owned(),
7133        }
7134    }
7135
7136    fn landing_state() -> RunState {
7137        crate::run::pin_test_home();
7138        let mut state = run_state();
7139        state.config.graph.land_approval = false;
7140        state
7141    }
7142
7143    #[test]
7144    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
7145        assert!(!awaiting_new_head(None, "aaa"));
7146        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
7147        assert!(awaiting_new_head(Some("abc123"), "def456"));
7148        assert!(awaiting_new_head(Some("abc123"), ""));
7149    }
7150
7151    #[test]
7152    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
7153        let open = |c, m: &str| seen("a", c, m, false);
7154        let table = [
7155            (None, false, Refused::Pending),
7156            (
7157                Some(open(Checks::Pending, "BLOCKED")),
7158                false,
7159                Refused::Pending,
7160            ),
7161            (
7162                Some(open(Checks::Unknown, "BLOCKED")),
7163                false,
7164                Refused::Pending,
7165            ),
7166            (
7167                Some(open(Checks::Green, "UNKNOWN")),
7168                false,
7169                Refused::Pending,
7170            ),
7171            (Some(open(Checks::Green, "")), false, Refused::Pending),
7172            (
7173                Some(open(Checks::Green, "BLOCKED")),
7174                false,
7175                Refused::Recheck,
7176            ),
7177            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
7178        ];
7179        for (after, rechecked, want) in table {
7180            assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
7181        }
7182        let mut closed = open(Checks::Green, "CLEAN");
7183        closed.pr.state = PrLifecycle::Closed;
7184        assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
7185    }
7186
7187    #[tokio::test]
7188    async fn a_normal_landing_merges_on_the_first_look() {
7189        let mut state = landing_state();
7190        let forge = Scripted::new(
7191            vec![seen("a", Checks::Green, "CLEAN", false)],
7192            vec![(true, "")],
7193        );
7194        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7195            .await
7196            .unwrap();
7197        // One fresh read, then the head-bound merge.
7198        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7199        assert_eq!(state.status, RunStatus::Merged);
7200    }
7201
7202    #[tokio::test]
7203    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
7204        let mut state = landing_state();
7205        let forge = Scripted::new(
7206            vec![seen("a", Checks::Green, "CLEAN", false)],
7207            std::iter::repeat_n((true, ""), 100).collect(),
7208        );
7209        *forge.queued.lock().unwrap() = true;
7210        let task = async {
7211            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7212                .await
7213                .unwrap();
7214        };
7215        // Still open after the command succeeded: it keeps watching and
7216        // never records a merge (it stops at the wait ceiling instead).
7217        task.await;
7218        assert_ne!(state.status, RunStatus::Merged);
7219    }
7220
7221    #[tokio::test]
7222    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
7223        let mut state = landing_state();
7224        let forge = Scripted::new(
7225            vec![seen("a", Checks::Green, "CLEAN", false)],
7226            std::iter::repeat_n((true, ""), 100).collect(),
7227        );
7228        *forge.unreadable_after_merge.lock().unwrap() = true;
7229        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7230            .await
7231            .ok();
7232        assert_ne!(state.status, RunStatus::Merged);
7233    }
7234
7235    #[tokio::test]
7236    async fn a_task_public_head_is_awaited_on_land_entry() {
7237        let mut state = landing_state();
7238        state.pr_head = Some("magi/task-abcd-1234".into());
7239        state.pr_pushed_head = Some("new".into());
7240        let forge = Scripted::new(
7241            vec![
7242                seen("old", Checks::Green, "CLEAN", false),
7243                seen("new", Checks::Green, "CLEAN", false),
7244            ],
7245            vec![(true, "")],
7246        );
7247        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7248            .await
7249            .unwrap();
7250        assert_eq!(
7251            forge.calls(),
7252            ["view", "poll", "view", "view", "merge", "view"]
7253        );
7254        assert_eq!(state.status, RunStatus::Merged);
7255    }
7256
7257    #[tokio::test]
7258    async fn a_superseded_public_head_owner_cannot_reenter_land() {
7259        let mut state = landing_state();
7260        state.pr_taken_over_by = Some("next".into());
7261        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
7262        assert!(
7263            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7264                .await
7265                .is_err()
7266        );
7267        assert!(forge.calls().is_empty());
7268    }
7269
7270    #[tokio::test]
7271    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
7272        let mut state = landing_state();
7273        let forge = Scripted::new(
7274            vec![
7275                seen("old", Checks::Green, "CLEAN", true),
7276                // The forge has not moved to the new head yet: still green.
7277                seen("old", Checks::Green, "CLEAN", true),
7278                seen("new", Checks::Pending, "BLOCKED", true),
7279                seen("new", Checks::Green, "CLEAN", true),
7280            ],
7281            vec![(true, "")],
7282        );
7283        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7284            head: "NEW".to_owned(),
7285        });
7286        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7287            .await
7288            .unwrap();
7289        assert_eq!(
7290            forge.calls(),
7291            [
7292                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
7293                "view"
7294            ]
7295        );
7296        assert_eq!(state.status, RunStatus::Merged);
7297    }
7298
7299    #[tokio::test]
7300    async fn a_head_that_never_arrives_stops_naming_both_commits() {
7301        let mut state = landing_state();
7302        let forge = Scripted::new(
7303            vec![
7304                seen("old", Checks::Green, "CLEAN", true),
7305                seen("someone-elses", Checks::Green, "CLEAN", true),
7306            ],
7307            vec![],
7308        );
7309        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7310            head: "mine".to_owned(),
7311        });
7312        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7313            .await
7314            .unwrap();
7315        assert!(!forge.calls().contains(&"merge"));
7316        let why = state.merge.as_ref().unwrap().detail.clone();
7317        assert!(
7318            why.contains("mine") && why.contains("someone-elses"),
7319            "{why}"
7320        );
7321        assert_eq!(state.status, RunStatus::Blocked);
7322    }
7323
7324    #[tokio::test]
7325    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
7326        let mut state = landing_state();
7327        let forge = Scripted::new(
7328            vec![
7329                seen("a", Checks::Green, "CLEAN", false),
7330                seen("a", Checks::Green, "CLEAN", false),
7331                seen("a", Checks::Pending, "BLOCKED", false),
7332                seen("a", Checks::Pending, "BLOCKED", false),
7333                seen("a", Checks::Green, "CLEAN", false),
7334            ],
7335            vec![(false, REFUSED), (true, "")],
7336        );
7337        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7338            .await
7339            .unwrap();
7340        assert_eq!(
7341            forge.calls(),
7342            [
7343                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
7344                "view"
7345            ]
7346        );
7347        assert_eq!(state.status, RunStatus::Merged);
7348    }
7349
7350    #[tokio::test]
7351    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
7352        let mut state = landing_state();
7353        let forge = Scripted::new(
7354            vec![
7355                seen("a", Checks::Green, "CLEAN", false),
7356                seen("a", Checks::Green, "BLOCKED", false),
7357            ],
7358            vec![(false, REFUSED), (false, REFUSED)],
7359        );
7360        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7361            .await
7362            .unwrap();
7363        // One re-look is allowed for the forge's own lag, then it is final.
7364        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
7365        let why = state.merge.as_ref().unwrap().detail.clone();
7366        assert!(
7367            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
7368            "{why}"
7369        );
7370        assert_eq!(state.status, RunStatus::Blocked);
7371    }
7372
7373    #[test]
7374    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
7375        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
7376        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
7377        // The pull request is still on the commit before the push.
7378        assert_eq!(bound_head("old", "old", Some("new")), None);
7379        // The checks are the previous commit's.
7380        assert_eq!(bound_head("new", "old", Some("new")), None);
7381        assert_eq!(bound_head("new", "old", None), None);
7382        // Nothing readable.
7383        assert_eq!(bound_head("", "", None), None);
7384        assert_eq!(bound_head("", "", Some("new")), None);
7385        assert_eq!(bound_head("abc", "", None), None);
7386    }
7387
7388    fn view_json(head: &str) -> String {
7389        format!(
7390            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
7391            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
7392            "reviews":[],"comments":[]}}"#
7393        )
7394    }
7395
7396    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
7397        format!(
7398            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
7399            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
7400            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
7401            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
7402        )
7403    }
7404
7405    #[test]
7406    fn rollup_is_bound_to_the_commit_in_the_same_node() {
7407        // The view already points at the new head, but the node still answers
7408        // for the old commit with its red check: the head stays unbound.
7409        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
7410        assert_eq!(s.rollup_head, "old");
7411        assert_eq!(s.pr.checks, Checks::Red);
7412        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
7413        assert_eq!(s.failing_urls.len(), 1);
7414    }
7415
7416    #[test]
7417    fn checks_come_from_the_node_not_the_view() {
7418        let view = view_json("new").replace(
7419            r#""reviews""#,
7420            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
7421        );
7422        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
7423        assert_eq!(s.pr.checks, Checks::Green);
7424        assert!(s.pr.failing.is_empty());
7425        assert_eq!(
7426            bound_head(&s.head, &s.rollup_head, Some("new")),
7427            Some("new")
7428        );
7429    }
7430
7431    #[test]
7432    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
7433        for node in [
7434            None,
7435            Some("not json".to_owned()),
7436            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
7437            Some(node_json("new", "SUCCESS", true)),
7438        ] {
7439            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
7440            assert!(s.rollup_head.is_empty());
7441            assert_eq!(s.pr.checks, Checks::Unknown);
7442            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
7443        }
7444    }
7445
7446    #[test]
7447    fn the_merge_command_is_pinned_to_the_observed_head() {
7448        let argv = merge_argv_at(7, "feat: x", "deadbeef");
7449        let at = argv
7450            .iter()
7451            .position(|a| a == "--match-head-commit")
7452            .unwrap();
7453        assert_eq!(argv[at + 1], "deadbeef");
7454    }
7455
7456    #[tokio::test]
7457    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
7458        let mut state = landing_state();
7459        // The pull request is on the pushed head but the rollup is still the
7460        // previous commit's red, non-required result.
7461        let mut stale = seen("new", Checks::Red, "CLEAN", false);
7462        stale.rollup_head = "old".to_owned();
7463        let forge = Scripted::new(
7464            vec![seen("old", Checks::Green, "CLEAN", true), stale],
7465            vec![],
7466        );
7467        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7468            head: "new".to_owned(),
7469        });
7470        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7471            .await
7472            .unwrap();
7473        assert!(!forge.calls().contains(&"merge"));
7474        assert_eq!(state.status, RunStatus::Blocked);
7475        let why = state.merge.as_ref().unwrap().detail.clone();
7476        assert!(why.contains("new") && why.contains("old"), "{why}");
7477    }
7478
7479    #[test]
7480    fn a_refusal_read_against_another_commits_checks_is_pending() {
7481        let mut after = seen("a", Checks::Green, "BLOCKED", false);
7482        after.rollup_head = "old".to_owned();
7483        assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
7484    }
7485
7486    #[tokio::test]
7487    async fn a_matching_head_with_red_non_required_checks_still_merges() {
7488        let mut state = landing_state();
7489        let forge = Scripted::new(
7490            vec![seen("a", Checks::Red, "CLEAN", false)],
7491            vec![(true, "")],
7492        );
7493        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7494            .await
7495            .unwrap();
7496        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7497        assert_eq!(state.status, RunStatus::Merged);
7498    }
7499
7500    #[tokio::test]
7501    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
7502        let mut state = landing_state();
7503        let forge = Scripted::new(
7504            vec![
7505                seen("a", Checks::Green, "CLEAN", false),
7506                seen("a", Checks::Green, "CLEAN", false),
7507                // Re-viewed after the refusal: someone pushed.
7508                seen("b", Checks::Green, "BLOCKED", false),
7509                seen("b", Checks::Green, "CLEAN", false),
7510            ],
7511            vec![(false, REFUSED), (true, "")],
7512        );
7513        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7514            .await
7515            .unwrap();
7516        assert_eq!(
7517            forge.calls(),
7518            [
7519                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
7520            ]
7521        );
7522        assert_eq!(state.status, RunStatus::Merged);
7523    }
7524
7525    fn merged_view(head: &str) -> Seen {
7526        let mut m = seen(head, Checks::Green, "CLEAN", false);
7527        m.pr.state = PrLifecycle::Merged;
7528        m
7529    }
7530
7531    fn has(argv: &[String], flag: &str) -> bool {
7532        argv.iter().any(|a| a == flag)
7533    }
7534
7535    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
7536        let at = argv.iter().position(|a| a == flag)?;
7537        argv.get(at + 1).map(String::as_str)
7538    }
7539
7540    const URL: &str = "https://github.com/o/r/pull/42";
7541
7542    #[tokio::test]
7543    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
7544        let mut state = landing_state();
7545        let forge = Scripted::new(
7546            vec![
7547                seen("abc", Checks::Green, "CLEAN", false),
7548                seen("abc", Checks::Green, "CLEAN", false),
7549            ],
7550            vec![(true, "")],
7551        );
7552        land_with(&mut state, URL, &forge).await.unwrap();
7553        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7554        let argv = &forge.argvs()[0];
7555        assert!(has(argv, "--squash") && has(argv, "--subject"));
7556        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
7557        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
7558        assert_eq!(state.status, RunStatus::Merged);
7559        assert!(state.land_armed_head.is_none());
7560    }
7561
7562    #[tokio::test]
7563    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
7564        let mut state = landing_state();
7565        state.land_armed_head = Some("a".to_owned());
7566        let forge = Scripted::new(
7567            vec![seen("a", Checks::Green, "CLEAN", false)],
7568            vec![(true, ""), (true, "")],
7569        );
7570        land_with(&mut state, URL, &forge).await.unwrap();
7571        let argvs = forge.argvs();
7572        assert!(has(&argvs[0], "--disable-auto"));
7573        assert!(!has(&argvs[1], "--auto"));
7574        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
7575        assert_eq!(state.status, RunStatus::Merged);
7576        assert!(state.land_armed_head.is_none());
7577    }
7578
7579    #[tokio::test]
7580    async fn an_approval_never_carries_over_to_a_new_head() {
7581        crate::run::pin_test_home();
7582        let mut state = run_state();
7583        state.config.graph.land_approval = true;
7584        let pr = green_pr();
7585        let store = ask::Questions::open();
7586
7587        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
7588            .await
7589            .unwrap();
7590        let mut q = store
7591            .list()
7592            .into_iter()
7593            .find(|q| q.run == state.id)
7594            .unwrap();
7595        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7596        store.put(&mut q).unwrap();
7597        assert_eq!(
7598            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
7599                .await
7600                .unwrap(),
7601            ApprovalGate::Approved,
7602            "the same head keeps its approval"
7603        );
7604
7605        // A new head is a new question, not the old word.
7606        assert_eq!(
7607            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7608                .await
7609                .unwrap(),
7610            ApprovalGate::Pending
7611        );
7612        let all: Vec<_> = store
7613            .list()
7614            .into_iter()
7615            .filter(|q| q.run == state.id)
7616            .collect();
7617        assert_eq!(all.len(), 2);
7618
7619        // A question recorded before heads were tracked is not reused either.
7620        state.land_approval = None;
7621        assert_eq!(
7622            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7623                .await
7624                .unwrap(),
7625            ApprovalGate::Pending
7626        );
7627        let open = store
7628            .list()
7629            .into_iter()
7630            .filter(|q| q.run == state.id && q.status.open())
7631            .count();
7632        assert_eq!(open, 1, "the superseded question was retired");
7633    }
7634
7635    #[tokio::test]
7636    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
7637        let mut state = landing_state();
7638        let forge = Scripted::new(
7639            vec![
7640                seen("a", Checks::Green, "CLEAN", false),
7641                // The fresh read before the merge: someone pushed.
7642                seen("b", Checks::Green, "CLEAN", false),
7643            ],
7644            vec![(true, "")],
7645        );
7646        land_with(&mut state, URL, &forge).await.unwrap();
7647        let argvs = forge.argvs();
7648        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
7649        assert!(!has(&argvs[0], "--auto"));
7650        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
7651        assert_eq!(state.status, RunStatus::Merged);
7652    }
7653
7654    fn passing(label: &str) -> CheckInfo {
7655        CheckInfo {
7656            label: label.to_owned(),
7657            verdict: Verdict::Pass,
7658            required: Some(false),
7659            run: None,
7660        }
7661    }
7662
7663    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
7664        (label.to_owned(), run.map(str::to_owned))
7665    }
7666
7667    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
7668        LandReruns {
7669            head: head.to_owned(),
7670            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
7671        }
7672    }
7673
7674    #[test]
7675    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
7676        let f = [failing("test (macos)", Some("7"))];
7677        assert_eq!(
7678            rerun_step(&f, None, "h", 100),
7679            RerunStep::Rerun(vec!["7".to_owned()])
7680        );
7681        let rec = reran("h", &[("7", 100)]);
7682        let grace = crate::release_watch::RERUN_GRACE;
7683        assert_eq!(
7684            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
7685            RerunStep::Grace
7686        );
7687        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
7688    }
7689
7690    #[test]
7691    fn a_failure_without_a_run_id_goes_to_the_fixer() {
7692        let f = [failing("a", Some("7")), failing("ci/external", None)];
7693        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
7694        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
7695    }
7696
7697    #[test]
7698    fn a_moved_head_forgets_the_reruns() {
7699        let f = [failing("a", Some("7"))];
7700        let rec = reran("old", &[("7", 100)]);
7701        assert_eq!(
7702            rerun_step(&f, Some(&rec), "new", 101),
7703            RerunStep::Rerun(vec!["7".to_owned()])
7704        );
7705    }
7706
7707    #[test]
7708    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
7709        let f = [
7710            failing("a", Some("7")),
7711            failing("b", Some("7")),
7712            failing("c", Some("8")),
7713        ];
7714        assert_eq!(
7715            rerun_step(&f, None, "h", 0),
7716            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
7717        );
7718        let rec = reran("h", &[("7", 0)]);
7719        assert_eq!(
7720            rerun_step(&f, Some(&rec), "h", 1),
7721            RerunStep::Rerun(vec!["8".to_owned()])
7722        );
7723    }
7724
7725    #[test]
7726    fn only_blocking_failures_are_candidates_for_a_rerun() {
7727        let check = |label: &str, verdict, required, run: &str| CheckInfo {
7728            label: label.to_owned(),
7729            verdict,
7730            required,
7731            run: Some(run.to_owned()),
7732        };
7733        let got = rerunnable(&[
7734            check("passed", Verdict::Pass, Some(true), "1"),
7735            check("optional", Verdict::Fail, Some(false), "2"),
7736            check("required", Verdict::Fail, Some(true), "3"),
7737            check("unknown", Verdict::Fail, None, "4"),
7738        ]);
7739        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
7740        assert_eq!(labels, ["required", "unknown"]);
7741    }
7742
7743    fn red(head: &str, run: Option<&str>) -> Seen {
7744        let mut s = seen(head, Checks::Red, "BLOCKED", false);
7745        s.pr.failing = vec!["test (macos)".to_owned()];
7746        s.contexts = vec![CheckInfo {
7747            label: "test (macos)".to_owned(),
7748            verdict: Verdict::Fail,
7749            required: Some(true),
7750            run: run.map(str::to_owned),
7751        }];
7752        s
7753    }
7754
7755    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
7756
7757    #[tokio::test]
7758    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
7759        let mut state = landing_state();
7760        let forge = Scripted::new(
7761            vec![
7762                red("a", Some("7")),
7763                seen("a", Checks::Pending, "BLOCKED", false),
7764                seen("a", Checks::Green, "CLEAN", false),
7765            ],
7766            vec![(true, "")],
7767        );
7768        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7769        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7770        assert!(!forge.calls().contains(&"fix"));
7771        assert_eq!(state.status, RunStatus::Merged);
7772        assert_eq!(state.pr.as_ref().unwrap().round, 0);
7773        let argvs = forge.argvs();
7774        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
7775        assert!(
7776            state
7777                .events
7778                .iter()
7779                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
7780        );
7781    }
7782
7783    #[tokio::test]
7784    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
7785        let mut state = landing_state();
7786        let forge = Scripted::new(
7787            vec![
7788                red("a", Some("7")),
7789                seen("a", Checks::Green, "CLEAN", false),
7790            ],
7791            vec![(true, "")],
7792        );
7793        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7794        // The red view repeats for the grace, then the fixer is asked once.
7795        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7796        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
7797        let calls = forge.calls();
7798        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
7799        let fix_at = calls.iter().position(|c| *c == "fix");
7800        assert!(fix_at.is_none_or(|f| f > rerun_at));
7801    }
7802
7803    #[tokio::test]
7804    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
7805        let mut state = landing_state();
7806        let mut v = red("a", Some("7"));
7807        v.pr.checks = Checks::Pending;
7808        v.contexts.push(CheckInfo {
7809            label: "slow".to_owned(),
7810            verdict: Verdict::Pending,
7811            required: Some(true),
7812            run: Some("8".to_owned()),
7813        });
7814        let forge = Scripted::new(
7815            vec![v, seen("a", Checks::Green, "CLEAN", false)],
7816            vec![(true, "")],
7817        );
7818        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7819        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7820        assert_eq!(state.status, RunStatus::Merged);
7821    }
7822
7823    #[test]
7824    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
7825        let mut v = red("a", Some("7"));
7826        v.contexts.push(CheckInfo {
7827            label: "same run, still going".to_owned(),
7828            verdict: Verdict::Pending,
7829            required: Some(true),
7830            run: Some("7".to_owned()),
7831        });
7832        assert!(rerunnable(&v.contexts).is_empty());
7833    }
7834
7835    #[tokio::test]
7836    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
7837        let mut state = landing_state();
7838        state.config.graph.land_rounds = 0;
7839        let forge = Scripted::new(
7840            vec![
7841                red("a", Some("7")),
7842                seen("a", Checks::Green, "CLEAN", false),
7843            ],
7844            vec![(true, "")],
7845        );
7846        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7847        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7848        assert_eq!(state.status, RunStatus::Merged);
7849        assert!(!forge.calls().contains(&"fix"));
7850        assert_eq!(state.pr.as_ref().unwrap().round, 0);
7851    }
7852
7853    #[tokio::test]
7854    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
7855        let mut state = landing_state();
7856        state.config.graph.land_rounds = 1;
7857        let forge = Scripted::new(
7858            vec![
7859                seen("a", Checks::Green, "CLEAN", true),
7860                red("b", Some("7")),
7861                seen("b", Checks::Green, "CLEAN", false),
7862            ],
7863            vec![(true, "")],
7864        );
7865        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7866            head: "b".to_owned(),
7867        });
7868        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7869        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7870        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
7871        assert_eq!(state.pr.as_ref().unwrap().round, 1);
7872        assert_eq!(state.status, RunStatus::Merged);
7873        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
7874        assert_eq!(
7875            value_of(&forge.argvs()[0], "--match-head-commit"),
7876            Some("b")
7877        );
7878    }
7879
7880    #[tokio::test]
7881    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
7882        let mut state = landing_state();
7883        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7884        *forge.rerun_fails.lock().unwrap() = true;
7885        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7886        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7887        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
7888        assert_eq!(
7889            forge.calls().iter().filter(|c| **c == "fix").count(),
7890            1,
7891            "{:?}",
7892            forge.calls()
7893        );
7894    }
7895
7896    #[tokio::test]
7897    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
7898        let mut state = landing_state();
7899        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7900        // The record was written just before the previous pass died.
7901        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
7902        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7903        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7904        assert!(forge.reruns.lock().unwrap().is_empty());
7905    }
7906
7907    #[tokio::test]
7908    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
7909        let mut state = landing_state();
7910        state.config.graph.rerun_flaky_checks = false;
7911        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7912        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7913        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7914        assert!(forge.reruns.lock().unwrap().is_empty());
7915        assert!(forge.calls().contains(&"fix"));
7916
7917        let mut state = landing_state();
7918        let forge = Scripted::new(vec![red("a", None)], vec![]);
7919        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7920        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7921        assert!(forge.reruns.lock().unwrap().is_empty());
7922        assert!(forge.calls().contains(&"fix"));
7923    }
7924
7925    fn names(xs: &[&str]) -> BTreeSet<String> {
7926        xs.iter().map(|x| (*x).to_owned()).collect()
7927    }
7928
7929    #[test]
7930    fn a_required_check_the_rollup_never_listed_is_named() {
7931        let req = names(&["build"]);
7932        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
7933        assert!(why.contains("never reported: build"), "{why}");
7934        assert!(!why.contains("probably waiting for a review"), "{why}");
7935    }
7936
7937    #[test]
7938    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
7939        let why = waiting_on("BLOCKED", &[passing("review")], None);
7940        assert!(why.contains("could not be read"), "{why}");
7941        assert!(!why.contains("probably waiting for a review"), "{why}");
7942    }
7943
7944    #[test]
7945    fn all_required_reported_keeps_the_review_guess() {
7946        let req = names(&["build"]);
7947        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7948        assert!(why.contains("probably waiting for a review"), "{why}");
7949        assert!(!why.contains("never reported"), "{why}");
7950    }
7951
7952    #[test]
7953    fn required_names_match_the_rollup_ignoring_case_only() {
7954        let req = names(&["Build"]);
7955        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7956        assert!(!why.contains("never reported"), "{why}");
7957    }
7958
7959    #[test]
7960    fn required_contexts_are_read_from_protection_and_rulesets() {
7961        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7962        assert_eq!(
7963            parse_classic_required(classic),
7964            Some(names(&["build", "lint"]))
7965        );
7966        let rules = r#"[{"type":"pull_request","parameters":{}},
7967            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7968        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7969        assert_eq!(parse_ruleset_required("nope"), None);
7970        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7971    }
7972
7973    #[test]
7974    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7975        let shown = BTreeSet::new();
7976        let ok = seen("a", Checks::Green, "CLEAN", false);
7977        let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7978        assert!(guard(Some(&ok)));
7979        assert!(!guard(None));
7980        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7981        let mut stale = ok.clone();
7982        stale.rollup_head = "old".to_owned();
7983        assert!(!guard(Some(&stale)));
7984        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7985        assert!(!guard(Some(&merged_view("a"))));
7986    }
7987
7988    #[test]
7989    fn the_rollup_node_carries_whether_each_check_is_required() {
7990        let node = node_json("new", "SUCCESS", false)
7991            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7992        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7993        assert_eq!(s.contexts.len(), 1);
7994        assert_eq!(s.contexts[0].required, Some(true));
7995        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7996        assert_eq!(s.contexts[0].required, None);
7997    }
7998
7999    #[tokio::test]
8000    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
8001        let mut state = landing_state();
8002        state.land_armed_head = Some("a".to_owned());
8003        let forge = Scripted::new(
8004            vec![seen("a", Checks::Green, "CLEAN", true)],
8005            vec![(false, "disable exploded")],
8006        );
8007        land_with(&mut state, URL, &forge).await.unwrap();
8008        assert!(!forge.calls().contains(&"fix"));
8009        assert_eq!(state.status, RunStatus::Blocked);
8010        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
8011        let why = state.merge.as_ref().unwrap().detail.clone();
8012        assert!(why.contains("disable exploded"), "{why}");
8013    }
8014}