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