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