Skip to main content

magi/
land.rs

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