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