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