Skip to main content

magi/
land.rs

1//! Landing the winner: watch the pull request, fix what it complains about,
2//! and merge it.
3//!
4//! Opening the pull request used to be where magi stopped and the operator
5//! started: watch the checks, read what the review bots found, push a fix,
6//! wait again, merge. That loop is mechanical, it takes an hour of wall-clock
7//! time per pull request, and doing it by hand six times in one session is how
8//! a queue that drains unattended stops being unattended. So it lives here.
9//!
10//! # Shape
11//!
12//! [`PrState`] is one observation of a pull request and [`decide`] is the whole
13//! policy as a *pure* function of it. Nothing in [`decide`] talks to `gh`,
14//! which is what makes "green with an unresolved comment is a fix, not a merge"
15//! an assertion in a test rather than a claim in a comment. [`land`] is the
16//! only part that performs I/O: observe, decide, act, repeat.
17//!
18//! # What it refuses to do
19//!
20//! Merging is the one irreversible thing magi can do to a repository, so the
21//! loop is built to stop rather than to guess:
22//!
23//! * A red pull request is never merged. When the budget runs out the pull
24//!   request is left open with a comment naming what is still failing, because
25//!   a magi that force-merges a red pull request is worse than one that stops.
26//! * A pull request whose checks cannot be read at all (`gh` reported no
27//!   rollup) is not merged either. Landing is for repositories with CI; with no
28//!   signal there is nothing to be green.
29//! * A pull request a human merged or closed underneath us is
30//!   [`Step::Done`] - the person won, and their decision is not an error.
31//!
32//! # Why `--subject` is not optional
33//!
34//! A candidate branch holds one commit whose subject is
35//! `magi: candidate A (uncommitted work)`, and `gh pr merge --squash` prefers a
36//! single commit's message over the pull request title. Merging without
37//! [`merge_argv`]'s explicit `--subject` therefore writes a `main` history that
38//! says nothing about what landed. `AGENTS.md` records the trap; this module is
39//! where it is prevented.
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::{AgentSpec, MergeMode};
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{ContestedHandoff, LandApproval, MergeOutcome, RunState, RunStatus, tail};
58
59/// How often the pull request is re-read while its checks are still running.
60///
61/// Thirty seconds: a CI matrix takes minutes, so anything shorter is spent
62/// entirely on `gh` invocations, and anything much longer adds latency to every
63/// single round of a loop that already waits for agents.
64pub const POLL: Duration = Duration::from_secs(30);
65
66/// How long one wait may last before landing gives up on the checks finishing.
67///
68/// A workflow that has not settled in forty-five minutes is stuck on a runner
69/// queue, a missing approval, or a hung job - none of which more polling fixes,
70/// and all of which a person needs to see.
71pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
72
73/// How long the checks may stay unreadable before landing gives up on them.
74///
75/// GitHub registers a workflow run some seconds after the branch is pushed, so
76/// immediately after a pull request is opened "no checks" and "no CI in this
77/// repository" look identical. Measured on run 01c2: magi opened pull request
78/// 22, read `unknown` four seconds later, refused to merge on a guess and
79/// marked the run blocked - and every check on that pull request was green
80/// minutes afterwards, with the whole competition then re-run from scratch for
81/// a task that was already finished. Three minutes is well past the observed
82/// registration delay and still bounded, so a repository that genuinely has no
83/// checks costs one three-minute wait and then says so.
84pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
85
86/// Bytes of failing log kept per check. The fixer needs the assertion and the
87/// frame around it, not the forty thousand lines of `cargo` output above it.
88const LOG_TAIL: usize = 4_000;
89
90/// Failing checks whose logs are fetched. Beyond a handful the failures share a
91/// cause, and fetching each one costs a `gh` round trip.
92const MAX_LOGS: usize = 3;
93
94/// Marker carried by every comment magi posts on a pull request.
95///
96/// Without it magi's own "still failing" comment is indistinguishable from a
97/// reviewer's, and the next observation would hand magi's own prose to the
98/// fixer as a finding.
99pub const MARKER: &str = "<!-- magi:land -->";
100
101/// Markers a bot puts in a comment to say that the comment is not a review.
102///
103/// CodeRabbit labels its own machinery in HTML comments - the trigger notice,
104/// the walkthrough summary, the "thanks for using" footer - and its actual
105/// findings arrive as *inline* review comments with a path and a line. Taking
106/// the bot at its word is more honest than guessing from prose, and it is the
107/// difference between a fix round that has something to fix and one that asks
108/// an agent to act on a quota notice.
109const NOT_A_REVIEW: [&str; 3] = [
110    "skip review by coderabbit.ai",
111    "summarize by coderabbit.ai",
112    "<!-- tips_start -->",
113];
114
115/// Where a pull request is in its life.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum PrLifecycle {
118    /// Still ours to land.
119    Open,
120    /// Already merged, by us or by a person.
121    Merged,
122    /// Closed without merging.
123    Closed,
124}
125
126/// The aggregate verdict of a pull request's checks.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum Checks {
129    /// At least one check has not finished.
130    Pending,
131    /// Every check passed (a skipped check counts as passed: the review
132    /// workflow skips release and bot pull requests by design).
133    Green,
134    /// At least one check finished without passing.
135    Red,
136    /// Nothing readable - no rollup at all, or a status magi does not know.
137    Unknown,
138}
139
140impl PrLifecycle {
141    /// Stable lower-case name, as the API and the reports spell it.
142    pub fn as_str(self) -> &'static str {
143        match self {
144            Self::Open => "open",
145            Self::Merged => "merged",
146            Self::Closed => "closed",
147        }
148    }
149}
150
151impl Checks {
152    /// Stable lower-case name, as the API and the reports spell it.
153    pub fn as_str(self) -> &'static str {
154        match self {
155            Self::Pending => "pending",
156            Self::Green => "green",
157            Self::Red => "red",
158            Self::Unknown => "unknown",
159        }
160    }
161}
162
163/// One outstanding review comment, human or bot.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct ReviewComment {
166    /// Login of whoever wrote it.
167    pub author: String,
168    /// File it was left on, for inline review comments.
169    pub path: Option<String>,
170    /// Line it was left on, when the comment is inline and still anchored.
171    pub line: Option<u64>,
172    /// The comment itself, as written.
173    pub body: String,
174}
175
176/// One observation of a pull request.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct PrState {
179    /// Pull request url, as `gh` reports it.
180    pub url: String,
181    /// Pull request number.
182    pub number: u64,
183    /// Open, merged, or closed.
184    pub state: PrLifecycle,
185    /// Aggregate check verdict.
186    pub checks: Checks,
187    /// Names of the checks that finished without passing.
188    pub failing: Vec<String>,
189    /// Comments that still want an answer, human and bot.
190    pub review_comments: Vec<ReviewComment>,
191    /// Whether the forge itself considers the failures blocking.
192    pub blocking: Blocking,
193}
194
195/// Whether a failing check actually stands between the pull request and
196/// `main`, according to the forge.
197///
198/// The rollup lists every check equally, so `coverage` going red on a
199/// repository that deliberately does not require it looked exactly like a
200/// broken build - and magi answered by spending a fix round on a change that
201/// was fine. Pull request 37 had to be merged by hand for that reason: the
202/// only red check was `editorconfig`, which was failing because the *action*
203/// could not fetch its own binary, and which the repository does not require.
204///
205/// `mergeStateStatus` is where GitHub applies the required-check set, so it
206/// is the one field that can tell the difference.
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum Blocking {
209    /// Required checks are satisfied and the branch merges cleanly.
210    No,
211    /// Something required is failing or missing.
212    Yes,
213    /// The branch no longer merges: the base moved under it.
214    Conflict,
215    /// The forge did not say - an older `gh`, or a token without the scope.
216    /// Treated as `Yes`, because refusing to guess is the rule everywhere
217    /// else in this module.
218    Unsaid,
219}
220
221impl Blocking {
222    /// Read `mergeStateStatus`, which is upper-case in `gh`'s output.
223    fn of(raw: &str) -> Self {
224        match raw.to_ascii_uppercase().as_str() {
225            // Mergeable. `UNSTABLE` is the interesting one: mergeable, with a
226            // non-required check failing or still running.
227            "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
228            "DIRTY" => Self::Conflict,
229            "" | "UNKNOWN" => Self::Unsaid,
230            // BLOCKED, BEHIND, DRAFT: something has to change first.
231            _ => Self::Yes,
232        }
233    }
234
235    /// Does this stand between the pull request and the base branch?
236    #[must_use]
237    pub fn stops_a_merge(self) -> bool {
238        !matches!(self, Self::No)
239    }
240}
241
242/// What the loop decided to do next. Pure, so the policy is testable.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum Step {
245    /// Checks are still running; re-read the pull request after [`POLL`].
246    Wait,
247    /// The base moved and the branch no longer merges: rebase it.
248    ///
249    /// Not a fix round. Nothing is wrong with the change - a competition
250    /// that runs for two hours against a repository merging pull requests
251    /// all day conflicts on the way in, and that is arithmetic rather than a
252    /// defect. Pull requests 35 and 37 were both rebased by hand for exactly
253    /// this.
254    Rebase,
255    /// Red checks or unresolved comments; run a fix round.
256    Fix {
257        /// What is unhappy, in one line, for the run log and the fix prompt.
258        reason: String,
259    },
260    /// Green and nothing outstanding; merge it.
261    Merge,
262    /// The pull request left our hands.
263    Done {
264        /// Did it land, or was it closed?
265        merged: bool,
266    },
267    /// Stop and leave the pull request to a person.
268    GiveUp {
269        /// Why magi stopped, in one line.
270        reason: String,
271    },
272}
273
274/// The outcome to record when `gh pr merge` exits non-zero, given what the
275/// pull request looked like immediately afterwards.
276///
277/// `gh pr merge` merges server-side first and only then does local work -
278/// deleting the branch, switching back to a base branch - so a non-zero exit
279/// does not mean the merge did not happen. In a jj-colocated repository it
280/// reliably does not mean that: git HEAD is detached, and `--delete-branch`
281/// ends with "could not determine current branch: not on any branch" *after*
282/// the merge has landed. Run ec12 merged pull request 28 into `main` and
283/// recorded `ok: false`, and its task was held waiting for a merge that was
284/// already done.
285///
286/// So the forge is asked, and its answer wins - the same authority [`decide`]
287/// gives the pull request's own state over everything else. The recorded
288/// detail carries both facts, because "the command failed and the merge
289/// happened anyway" is exactly what someone reading the run later needs to
290/// know.
291///
292/// `None` means the merge really did not happen, including when the pull
293/// request could not be read at all: an unreadable answer is not evidence of
294/// success.
295pub(crate) fn merged_after_all(
296    argv: &[String],
297    stderr: &str,
298    after: Option<PrLifecycle>,
299) -> Option<MergeOutcome> {
300    if after? != PrLifecycle::Merged {
301        return None;
302    }
303    Some(MergeOutcome {
304        mode: MergeMode::Pr,
305        ok: true,
306        detail: format!(
307            "gh {} (the command reported `{}`, but the pull request is merged)",
308            argv.join(" "),
309            stderr.trim()
310        ),
311        empty: false,
312    })
313}
314
315/// Decide the next step. No I/O.
316///
317/// `round` counts the fix rounds already spent, so `round == budget` means the
318/// budget is gone. A wait never spends a round: waiting is free, and a slow CI
319/// must not consume the allowance meant for actual fixes.
320///
321/// The order of the tests is the policy:
322///
323/// 1. **The pull request's own state wins.** A merge or a close that happened
324///    underneath us is the end of the story regardless of what the checks say.
325/// 2. **Pending beats red.** A check that is still running may yet fail, and one
326///    fix round that addresses every failure is cheaper than two that each
327///    address half - the fix pushes and restarts the whole suite anyway.
328/// 3. **Comments outrank green.** An unresolved comment holds the merge even
329///    when CI is happy; that is what a review is for.
330/// 4. **Unreadable is not absent.** Checks that cannot be read yet are waited
331///    on for [`CHECKS_GRACE`], because a pull request opened a moment ago has
332///    not been given its workflow runs yet. Past the grace they are treated as
333///    genuinely missing and magi stops rather than merge on a guess.
334pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
335    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=\"{}\" 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(repo: &Path, url: &str, title: &str) -> Result<()> {
2145    let (ok, out) = gh(
2146        repo,
2147        &[
2148            "pr".to_owned(),
2149            "edit".to_owned(),
2150            url.to_owned(),
2151            "--title".to_owned(),
2152            title.to_owned(),
2153        ],
2154    )
2155    .await?;
2156    if !ok {
2157        bail!("gh pr edit failed: {out}");
2158    }
2159    Ok(())
2160}
2161
2162/// Ask GitHub whether this run's winning candidate branch was actually merged
2163/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2164/// --merged` exists to close, minus the operator having to find the URL by
2165/// hand.
2166///
2167/// `Ok(None)` covers every case where nothing can be said with confidence: no
2168/// winner decided yet (nothing to check a branch for), no merged pull request
2169/// found, or [`pick_merged_pr`] found more than one candidate and would not
2170/// guess between them. Never wired to a weaker, URL-less signal like
2171/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2172/// separately, precisely because it cannot drive an automatic correction on
2173/// its own (see that function's own doc).
2174pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2175    let Some(winner) = state.winner() else {
2176        return Ok(None);
2177    };
2178    let branch = winner.branch.clone();
2179    let out = gh(
2180        &state.repo,
2181        &[
2182            "pr".to_owned(),
2183            "list".to_owned(),
2184            "--head".to_owned(),
2185            branch.clone(),
2186            "--state".to_owned(),
2187            "merged".to_owned(),
2188            "--json".to_owned(),
2189            "url,number,mergedAt,baseRefName".to_owned(),
2190        ],
2191    )
2192    .await?;
2193    if !out.0 {
2194        bail!("gh pr list --head {branch}: {}", out.1);
2195    }
2196    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2197}
2198
2199/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2200/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2201///
2202/// Deliberately never consulted by [`find_external_merge`]: a base branch
2203/// that has moved since the run started can make an old, abandoned branch
2204/// look like an ancestor of the *current* base for reasons that have nothing
2205/// to do with a merge (a later commit that happens to supersede it, an
2206/// unrelated squash), and there is no pull request URL here to confirm
2207/// against or to land through anyway. Its only honest use is a weaker
2208/// notice — "this looks merged, go check" — never an automatic rewrite of
2209/// `status`/`merge`.
2210pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2211    let out = tokio::process::Command::new("git")
2212        .args(["merge-base", "--is-ancestor", branch, base_branch])
2213        .current_dir(repo)
2214        .quiet()
2215        .stdin(std::process::Stdio::null())
2216        .output()
2217        .await
2218        .context("spawn git merge-base --is-ancestor")?;
2219    Ok(out.status.success())
2220}
2221
2222/// Parse `host/owner/repo` out of a forge URL, with no network access.
2223///
2224/// The host is part of the slug, not discarded: `owner/repo` alone would
2225/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2226/// repository, which is exactly the mix-up the same-repo guard exists to
2227/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2228/// shape at all.
2229fn forge_slug(url: &str) -> Option<(String, &str)> {
2230    let rest = url.rsplit("://").next()?;
2231    let (host, path) = rest.split_once('/')?;
2232    if host.is_empty() {
2233        return None;
2234    }
2235    Some((host.to_ascii_lowercase(), path))
2236}
2237
2238/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2239/// access - the first half of the same-repo guard [`correct_manual_merge`]
2240/// applies before it writes anything.
2241///
2242/// Returns `None` for anything that does not look like
2243/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2244/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2245pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2246    let (host, path) = forge_slug(url)?;
2247    let mut segments = path.split('/');
2248    let owner = segments.next()?;
2249    let repo = segments.next()?;
2250    let kind = segments.next()?;
2251    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2252        return None;
2253    }
2254    Some(format!("{host}/{owner}/{repo}"))
2255}
2256
2257/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2258/// suffix), the shape `gh repo view --json url` returns - the other half of
2259/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2260fn slug_of_repo_url(url: &str) -> Option<String> {
2261    let (host, path) = forge_slug(url)?;
2262    let mut segments = path.split('/');
2263    let owner = segments.next()?;
2264    let repo = segments.next()?;
2265    if owner.is_empty() || repo.is_empty() {
2266        return None;
2267    }
2268    Some(format!("{host}/{owner}/{repo}"))
2269}
2270
2271/// Refuse to correct a run against a pull request from a different
2272/// repository than the one it is recorded against.
2273///
2274/// This is the guard the shun/8c75 incident argued for: an operator ran
2275/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2276/// in a different repository, omitted the run id, and the id defaulted to
2277/// this machine's most recently created run - an unrelated, still-in-progress
2278/// run in a completely different repository - which then had its `status`
2279/// rewritten to `merged` from a pull request it had nothing to do with.
2280/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2281/// CLI help), but a mistyped or stale id could still name a run in a
2282/// different repository than the one the URL belongs to, so this checks that
2283/// independently rather than trusting the id alone.
2284///
2285/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2286/// mismatch names both slugs rather than just refusing, so an operator whose
2287/// local checkout's `origin` is a fork of the repository the pull request was
2288/// opened against (a legitimate setup this cannot tell apart from a genuine
2289/// mix-up) can judge for themselves rather than being blocked with no way to
2290/// see why.
2291pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2292    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2293        return Ok(());
2294    }
2295    bail!(
2296        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2297         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2298         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2299         verify by hand before treating this as a false positive)"
2300    );
2301}
2302
2303/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2304/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2305///
2306/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2307/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2308/// repository the same way GitHub itself would recognize it, so the
2309/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2310/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2311/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2312/// a same-named one on a GitHub Enterprise host.
2313async fn repo_slug(repo: &Path) -> Result<String> {
2314    let out = gh(
2315        repo,
2316        &[
2317            "repo".to_owned(),
2318            "view".to_owned(),
2319            "--json".to_owned(),
2320            "url".to_owned(),
2321        ],
2322    )
2323    .await?;
2324    if !out.0 {
2325        bail!("gh repo view --json url: {}", out.1);
2326    }
2327    #[derive(Debug, Deserialize)]
2328    struct GhRepo {
2329        url: String,
2330    }
2331    let parsed: GhRepo = serde_json::from_str(&out.1)
2332        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2333    slug_of_repo_url(&parsed.url)
2334        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2335}
2336
2337/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2338/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2339/// would have written them had magi opened and merged this pull request
2340/// itself.
2341///
2342/// This is `magi fold --merged`'s whole implementation, and also what the
2343/// web `fold-merged` route calls once it has a URL in hand — an operator
2344/// recovery path for a merge magi could not finish on its own: a PR title too
2345/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2346/// closed by hand with a pull request magi never opened and so never
2347/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2348/// directly keeps this one authoritative: a merged pull request decides
2349/// `Step::Done { merged: true }` on the very first read, before any of
2350/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2351/// safe to call here even though this pull request was never magi's own.
2352///
2353/// [`ensure_same_repo`] is checked before anything else: a pull request from
2354/// a different repository than the one `state` is recorded against is
2355/// refused outright, regardless of its lifecycle. This is the guard for a
2356/// URL an *operator* hands in - the CLI or the web route - where a stale or
2357/// mistyped run id could otherwise get corrected from an unrelated
2358/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2359/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2360/// goes through [`correct_confirmed_external_merge`] instead, which skips
2361/// this check: its `url` was never operator-supplied, it comes from
2362/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2363/// it is already guaranteed to name a pull request in that same repository -
2364/// re-deriving and re-checking the repository here would only be a second
2365/// `gh repo view` call that can fail for reasons that have nothing to do with
2366/// correctness (a rate limit, a network blip), turning a self-heal that would
2367/// otherwise have succeeded into a run left `Blocked` for another pass.
2368///
2369/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2370/// URL fails loudly without writing anything, rather than handing an open
2371/// pull request to the full autonomous loop by accident.
2372///
2373/// Correcting `status` this way does not run `bump::after_merge`
2374/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2375/// which this path never goes through. A release version bump the change
2376/// might have earned is therefore not filed automatically and has to be
2377/// requested by hand - recorded as an event on the run so the gap is visible
2378/// to whoever reads it later, not just wherever this was called from. Follow-up
2379/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2380///
2381/// Returns the status before and after, so every caller (CLI, janitor, web
2382/// route) can build its own log line or response from the same pair rather
2383/// than each re-deriving it.
2384pub async fn correct_manual_merge(
2385    state: &mut RunState,
2386    url: &str,
2387) -> Result<(RunStatus, RunStatus)> {
2388    let Some(pr_slug) = slug_of_pr_url(url) else {
2389        bail!(
2390            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2391             this pull request belongs to"
2392        );
2393    };
2394    let run_slug = repo_slug(&state.repo).await?;
2395    ensure_same_repo(&run_slug, &pr_slug)?;
2396    correct_merge(state, url).await
2397}
2398
2399/// The janitor's own entry point into the same correction
2400/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2401/// same-repo guard - see that function's own doc for why skipping it here is
2402/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2403/// calls this with a `url` [`find_external_merge`] already found by querying
2404/// `state.repo`'s own remote, so the guard could never do anything here but
2405/// fail on its own transient errors.
2406///
2407/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2408/// because it lives in a different module.
2409pub(crate) async fn correct_confirmed_external_merge(
2410    state: &mut RunState,
2411    url: &str,
2412) -> Result<(RunStatus, RunStatus)> {
2413    correct_merge(state, url).await
2414}
2415
2416/// Same pull request, same repository: the url, or the number within one repo.
2417fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2418    let Some(pr) = a.pr.as_ref() else {
2419        return false;
2420    };
2421    if !url.is_empty()
2422        && pr
2423            .url
2424            .trim_end_matches('/')
2425            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2426    {
2427        return true;
2428    }
2429    number > 0
2430        && pr.number == number
2431        && match (a.repo.canonicalize(), repo.canonicalize()) {
2432            (Ok(x), Ok(y)) => x == y,
2433            _ => a.repo == repo,
2434        }
2435}
2436
2437/// Rewrite `pr.state` to a final state on every run record under `home` that
2438/// `decide` picks, and report how many were rewritten.
2439///
2440/// This is the one place a run other than the driver changes another run's
2441/// record, so it is deliberately narrow: only a **terminal** run (never one a
2442/// driver may still be writing), never one a live daemon claims, only a record
2443/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2444/// The record is read as folding reads it (no schema check: every bump so far
2445/// only added fields, and the whole struct round-trips) and written through
2446/// [`RunState::save_under`], the path every record uses, so nothing but
2447/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2448/// A run that cannot be read or written is skipped with a warning.
2449fn rewrite_open_prs(
2450    home: &Path,
2451    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2452) -> usize {
2453    let now = Timestamp::now();
2454    let mut changed = 0;
2455    for id in crate::run::list_ids_in(&home.join("runs")) {
2456        let path = home.join("runs").join(&id).join("run.json");
2457        let Ok(body) = std::fs::read_to_string(&path) else {
2458            continue;
2459        };
2460        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2461            continue;
2462        };
2463        if !state.status.done()
2464            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2465            || crate::daemon::is_working_on(home, &id, now)
2466        {
2467            continue;
2468        }
2469        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2470            continue;
2471        };
2472        if let Some(pr) = state.pr.as_mut() {
2473            pr.state = to.as_str().to_owned();
2474        }
2475        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2476        state.event(
2477            "land",
2478            format!("recorded {url} as {}: another run settled it", to.as_str()),
2479        );
2480        match state.save_under(home) {
2481            Ok(()) => changed += 1,
2482            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2483        }
2484    }
2485    changed
2486}
2487
2488/// A run reached a final state for its pull request: tell every other terminal
2489/// run in the same repository that names the same pull request (handed-over
2490/// predecessors, blocked attempts, anything), so their records stop saying
2491/// `open`. Best effort; `run`'s own record is the caller's.
2492pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2493    if to == PrLifecycle::Open {
2494        return;
2495    }
2496    let Some(home) = crate::run::try_home() else {
2497        return;
2498    };
2499    write_pr_state_through_in(&home, run, to);
2500}
2501
2502pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2503    let Some(pr) = run.pr.as_ref() else {
2504        return 0;
2505    };
2506    let (url, number) = (pr.url.clone(), pr.number);
2507    rewrite_open_prs(home, &mut |other| {
2508        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2509    })
2510}
2511
2512/// Terminal runs whose record still says their pull request is open, as
2513/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
2514pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2515    let now = Timestamp::now();
2516    let mut out = Vec::new();
2517    for id in crate::run::list_ids_in(&home.join("runs")) {
2518        let path = home.join("runs").join(&id).join("run.json");
2519        let Ok(body) = std::fs::read_to_string(&path) else {
2520            continue;
2521        };
2522        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2523            continue;
2524        };
2525        if let Some(pr) = state.pr.as_ref()
2526            && state.status.done()
2527            && pr.state == "open"
2528            && !pr.url.is_empty()
2529            && !crate::daemon::is_working_on(home, &id, now)
2530        {
2531            out.push((id, state.repo.clone(), pr.url.clone()));
2532        }
2533    }
2534    out
2535}
2536
2537/// Apply forge answers (`pr url -> state`) to every stale terminal record.
2538/// A url with no answer (the forge was unreadable) changes nothing.
2539pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2540    rewrite_open_prs(home, &mut |s| {
2541        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2542    })
2543}
2544
2545/// One-time (and idempotent) repair of records that froze an `open` pull
2546/// request: ask the forge about each distinct pull request that a terminal run
2547/// still calls open - at most `max_lookups` of them - and rewrite the merged
2548/// and closed ones. A genuinely open pull request is left alone, and a lookup
2549/// that fails is "unknown, change nothing". Returns `(records rewritten,
2550/// lookups that failed)`.
2551pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2552    let mut known = BTreeMap::new();
2553    let mut failed = 0;
2554    let mut seen = BTreeSet::new();
2555    for (_, repo, url) in stale_open_prs(home) {
2556        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2557            continue;
2558        }
2559        match lifecycle(&repo, &url).await {
2560            Ok(state) => {
2561                known.insert(url, state);
2562            }
2563            Err(e) => {
2564                tracing::warn!("repair pr state of {url}: {e:#}");
2565                failed += 1;
2566            }
2567        }
2568    }
2569    (apply_pr_states(home, &known), failed)
2570}
2571
2572async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2573    match lifecycle(&state.repo, url).await? {
2574        PrLifecycle::Merged => {}
2575        other => bail!(
2576            "{url} is {}, not merged; refusing to record {} as merged on a guess",
2577            other.as_str(),
2578            state.id
2579        ),
2580    }
2581    let before = state.status;
2582    if let Err(e) = land(state, url).await {
2583        // `land` sets `status` to `Landing` and saves before its first read
2584        // of the pull request — see its own doc — so a failure here (a
2585        // transient `gh` hiccup between the two forge reads this function
2586        // makes) can leave the run stuck on that in-between value with
2587        // nothing left driving it. Land it on the same terminal shape an
2588        // automated `land` failure lands on instead of leaving it stuck.
2589        state.status = RunStatus::Blocked;
2590        state.event("fold", format!("manual-merge correction failed: {e:#}"));
2591        state.save()?;
2592        return Err(e).context(format!("confirming the merge of {url}"));
2593    }
2594    state.event(
2595        "fold",
2596        "operator recorded this pull request as a manual merge; this run never \
2597         re-entered `land`, so `bump::after_merge` did not run for it - a release \
2598         bump this change might warrant has to be filed by hand",
2599    );
2600    // Unlike the bump, the findings the merge left open are filed on this
2601    // path too: nothing else would ever carry them forward.
2602    if state.status == RunStatus::Merged {
2603        crate::followup::after_merge(state, url).await;
2604    }
2605    state.save()?;
2606    Ok((before, state.status))
2607}
2608
2609/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
2610/// comments. No I/O.
2611///
2612/// `gh pr view` does not surface inline comments, and inline is exactly where
2613/// both review bots put their findings - a landing loop that read only the
2614/// top-level thread would never see the thing it is supposed to fix.
2615pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2616    let raw: Vec<GhInline> =
2617        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2618    let mut out = Vec::new();
2619    for c in raw {
2620        push_if_outstanding(
2621            &mut out,
2622            ReviewComment {
2623                author: c.user.login,
2624                path: c.path,
2625                line: c.line,
2626                body: c.body,
2627            },
2628        );
2629    }
2630    Ok(out)
2631}
2632
2633/// Keep a comment only when it asks for something.
2634///
2635/// An inline comment always does: it names a file and a line. A top-level
2636/// comment is dropped when it is empty, when it is magi's own, or when it is
2637/// [noise](is_noise).
2638fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2639    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2640        return;
2641    }
2642    if comment.path.is_none() && is_noise(&comment.body) {
2643        return;
2644    }
2645    out.push(comment);
2646}
2647
2648/// Is this comment body machinery rather than a finding?
2649///
2650/// Two tests, both structural, because guessing from prose is how a "looks
2651/// good to me" turns into a fix round:
2652///
2653/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
2654///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
2655///    footer.
2656/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
2657///    horizontal rules, and the bot's own status banner are removed, every
2658///    remaining line is a task-list item. That is exactly the shape of the
2659///    comment the Claude review job posts while it is still working.
2660///
2661/// Anything else is input, including bot prose. A bot that writes a paragraph
2662/// has said something, and the fix prompt tells the fixer it may decline a
2663/// comment with an argument - a wasted sentence in a prompt is cheaper than a
2664/// missed finding.
2665pub fn is_noise(body: &str) -> bool {
2666    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2667        return true;
2668    }
2669    let mut content = false;
2670    for line in strip_blocks(body).lines() {
2671        let line = unquote(line);
2672        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2673            continue;
2674        }
2675        content = true;
2676        break;
2677    }
2678    !content
2679}
2680
2681/// Remove HTML comments and collapsed `<details>` blocks.
2682fn strip_blocks(body: &str) -> String {
2683    let mut out = String::with_capacity(body.len());
2684    let mut rest = body;
2685    loop {
2686        let open = ["<!--", "<details>"]
2687            .iter()
2688            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2689            .min_by_key(|(i, _)| *i);
2690        let Some((at, tag)) = open else {
2691            out.push_str(rest);
2692            return out;
2693        };
2694        out.push_str(&rest[..at]);
2695        let after = &rest[at + tag.len()..];
2696        let close = if tag == "<!--" { "-->" } else { "</details>" };
2697        match after.find(close) {
2698            Some(end) => rest = &after[end + close.len()..],
2699            // Unterminated: the rest of the body is inside the block.
2700            None => return out,
2701        }
2702    }
2703}
2704
2705/// Strip blockquote markers, which both bots wrap their callouts in.
2706fn unquote(line: &str) -> &str {
2707    let mut s = line.trim();
2708    while let Some(rest) = s.strip_prefix('>') {
2709        s = rest.trim_start();
2710    }
2711    s.trim()
2712}
2713
2714/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
2715fn is_checklist(line: &str) -> bool {
2716    let rest = line
2717        .strip_prefix("- ")
2718        .or_else(|| line.strip_prefix("* "))
2719        .unwrap_or("");
2720    let rest = rest.trim_start();
2721    matches!(
2722        rest.get(..3),
2723        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2724    )
2725}
2726
2727/// A heading, a horizontal rule, or a callout tag - shape, never content.
2728fn is_decoration(line: &str) -> bool {
2729    line.starts_with('#')
2730        || line.starts_with("[!")
2731        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2732}
2733
2734/// A line that is nothing but emphasis and links.
2735///
2736/// Both review jobs open with a status banner
2737/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
2738/// to a line-based test and asks for nothing, so it is measured the same way a
2739/// heading is: strip the markup, and if no word survives, it was decoration.
2740fn is_banner(line: &str) -> bool {
2741    let plain = drop_spans(line, "**", "**");
2742    let plain = if plain.contains("](") {
2743        drop_spans(&plain, "[", ")")
2744    } else {
2745        plain
2746    };
2747    !plain.chars().any(char::is_alphanumeric)
2748}
2749
2750/// Remove every `open` .. `close` span, including the delimiters. An
2751/// unterminated span swallows the rest of the input, which is what a reader
2752/// sees too.
2753fn drop_spans(s: &str, open: &str, close: &str) -> String {
2754    let mut out = String::with_capacity(s.len());
2755    let mut rest = s;
2756    while let Some(at) = rest.find(open) {
2757        out.push_str(&rest[..at]);
2758        let after = &rest[at + open.len()..];
2759        match after.find(close) {
2760            Some(end) => rest = &after[end + close.len()..],
2761            None => return out,
2762        }
2763    }
2764    out.push_str(rest);
2765    out
2766}
2767
2768/// The lock that keeps at most one run per repository actually moving the
2769/// base branch at a time: a rebase push, or `gh pr merge`.
2770///
2771/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
2772/// running a fix round in the winner's own worktree, waiting on the owner's
2773/// approval - touches nothing a *different* run in the same repository could
2774/// collide with, and holding a lock across any of that would serialise one
2775/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
2776/// resume, which is precisely the "must not wait on another task" property
2777/// the daemon's slot-freeing exists to give a resume. Only the two moments
2778/// that actually write to the shared base branch need mutual exclusion, and
2779/// both are brief.
2780///
2781/// One entry per repository, each its own `tokio::sync::Mutex`, so two
2782/// different repositories' runs never wait on each other. The outer
2783/// `std::sync::Mutex` guards only the map itself, held long enough to find or
2784/// insert an entry and clone its `Arc`, never across an `.await`.
2785fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
2786    static LOCKS: std::sync::LazyLock<
2787        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
2788    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
2789    LOCKS
2790        .lock()
2791        .unwrap_or_else(std::sync::PoisonError::into_inner)
2792        .entry(repo.to_path_buf())
2793        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
2794        .clone()
2795}
2796
2797/// `owner/repo` out of a pull request url, falling back to the checkout's
2798/// directory name when the url is not the usual `host/owner/repo/pull/N`.
2799fn repo_label(repo: &Path, pr_url: &str) -> String {
2800    let parts: Vec<&str> = pr_url.split('/').collect();
2801    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
2802        && at >= 2
2803        && !parts[at - 1].is_empty()
2804        && !parts[at - 2].is_empty()
2805    {
2806        return format!("{}/{}", parts[at - 2], parts[at - 1]);
2807    }
2808    repo.file_name()
2809        .map(|n| n.to_string_lossy().into_owned())
2810        .unwrap_or_default()
2811}
2812
2813/// The operator-facing sentence for a merge that went ahead with red checks,
2814/// or `None` when the checks were not red. Judged on `checks`, not on
2815/// `failing`, which can be non-empty on a green observation.
2816fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
2817    (pr.checks == Checks::Red).then(|| {
2818        format!(
2819            "Merged {repo_name} PR #{} with red checks: {} ({})",
2820            pr.number,
2821            if pr.failing.is_empty() {
2822                "(none named)".to_owned()
2823            } else {
2824                pr.failing.join(", ")
2825            },
2826            pr.url
2827        )
2828    })
2829}
2830
2831/// After a merge that succeeded: record which checks were red and tell the
2832/// operator. The decision to merge is already made; this only makes it audible.
2833/// Best-effort - a broken notifier never fails the run.
2834async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
2835    let repo_name = repo_label(&state.repo, &pr.url);
2836    let Some(summary) = red_merge_summary(&repo_name, pr) else {
2837        return;
2838    };
2839    if let Some(rec) = state.pr.as_mut() {
2840        rec.red_at_merge = pr.failing.clone();
2841    }
2842    state.event("land", summary.clone());
2843    // The notice pages through `[notify]` itself, once, unless a question
2844    // already carries the cause.
2845    crate::notices::raise_with(
2846        crate::notices::merged_red(&state.id, &summary),
2847        &state.config.notify,
2848    );
2849}
2850
2851/// Run the loop against a real pull request until it merges or the budget runs
2852/// out.
2853///
2854/// The caller decides whether landing happens at all: this is only reached when
2855/// `graph.land` is on. Returns the last observation, so the caller can report
2856/// what magi was looking at when it stopped.
2857pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
2858    land_with(state, pr_url, &GhForge).await
2859}
2860
2861/// The forge calls whose timing the loop's decisions depend on, behind a seam
2862/// so a test can script what the pull request looks like from one observation
2863/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
2864trait Forge {
2865    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
2866    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
2867    async fn poll(&self);
2868    /// The check contexts the base branch requires, for a stop reason only.
2869    /// `None` when they could not be read, which is not the same as none.
2870    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
2871    #[allow(clippy::too_many_arguments)]
2872    async fn fix(
2873        &self,
2874        state: &mut RunState,
2875        pr: &PrState,
2876        round: usize,
2877        budget: usize,
2878        reason: &str,
2879        logs: &str,
2880    ) -> Result<Fixed>;
2881}
2882
2883struct GhForge;
2884
2885impl Forge for GhForge {
2886    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
2887        observe(repo, pr_url).await
2888    }
2889    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
2890        gh(repo, argv).await
2891    }
2892    async fn poll(&self) {
2893        tokio::time::sleep(POLL).await;
2894    }
2895    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2896        required_contexts_of(repo, base).await
2897    }
2898    async fn fix(
2899        &self,
2900        state: &mut RunState,
2901        pr: &PrState,
2902        round: usize,
2903        budget: usize,
2904        reason: &str,
2905        logs: &str,
2906    ) -> Result<Fixed> {
2907        fix_round(state, pr, round, budget, reason, logs).await
2908    }
2909}
2910
2911/// Percent-encode a branch name for a URL path segment (`/` included).
2912fn encode_path_segment(s: &str) -> String {
2913    let mut out = String::new();
2914    for b in s.bytes() {
2915        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
2916            out.push(b as char);
2917        } else {
2918            let _ = write!(out, "%{b:02X}");
2919        }
2920    }
2921    out
2922}
2923
2924/// Read the required contexts of `base` from classic branch protection and
2925/// from rulesets, once, for a stop reason. Organisation-level rulesets that
2926/// these two endpoints do not list are missed. Any unreadable source makes the
2927/// whole answer `None`: a partial set would read as a complete one.
2928async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2929    let enc = encode_path_segment(base);
2930    let mut all = BTreeSet::new();
2931    // Classic protection answers 404 for an unprotected branch, which is
2932    // "nothing required here", not a failure to read.
2933    let classic = gh(
2934        repo,
2935        &[
2936            "api".to_owned(),
2937            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
2938        ],
2939    )
2940    .await
2941    .ok()?;
2942    if classic.0 {
2943        all.extend(parse_classic_required(&classic.1)?);
2944    } else if !classic.1.contains("404") {
2945        return None;
2946    }
2947    let rules = gh(
2948        repo,
2949        &[
2950            "api".to_owned(),
2951            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
2952        ],
2953    )
2954    .await
2955    .ok()?;
2956    if !rules.0 {
2957        return None;
2958    }
2959    all.extend(parse_ruleset_required(&rules.1)?);
2960    Some(all)
2961}
2962
2963/// `required_status_checks` of classic protection: `contexts` plus the
2964/// `checks[].context` form.
2965fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
2966    let v: serde_json::Value = serde_json::from_str(json).ok()?;
2967    let mut out = BTreeSet::new();
2968    for c in v.get("contexts")?.as_array()? {
2969        out.insert(c.as_str()?.to_owned());
2970    }
2971    for c in v
2972        .get("checks")
2973        .and_then(|c| c.as_array())
2974        .into_iter()
2975        .flatten()
2976    {
2977        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
2978            out.insert(name.to_owned());
2979        }
2980    }
2981    Some(out)
2982}
2983
2984/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
2985fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
2986    let v: serde_json::Value = serde_json::from_str(json).ok()?;
2987    let mut out = BTreeSet::new();
2988    for rule in v.as_array()? {
2989        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
2990            continue;
2991        }
2992        let checks = rule
2993            .pointer("/parameters/required_status_checks")?
2994            .as_array()?;
2995        for c in checks {
2996            out.insert(c.get("context")?.as_str()?.to_owned());
2997        }
2998    }
2999    Some(out)
3000}
3001
3002/// Is the observation older than the commit a fix round pushed?
3003///
3004/// The forge takes a few seconds to move the pull request to a new head and
3005/// attach that head's check runs, and until it has, the rollup is the previous
3006/// head's - all green, which is exactly what a merge decision must not read.
3007/// An absent or different head counts as not yet: guessing "close enough"
3008/// would reopen the hole.
3009fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3010    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3011}
3012
3013/// The commit an observation may be decided on, or `None` while it cannot be
3014/// trusted to describe one.
3015///
3016/// `None` when a pushed commit is awaited and the pull request is not on it,
3017/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3018/// last commit's) is not the head's: that is the previous commit's checks. The
3019/// caller re-polls on `None`. A rollup that cannot be read (including one
3020/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3021/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3022fn bound_head<'a>(
3023    seen_head: &'a str,
3024    rollup_head: &str,
3025    awaiting: Option<&str>,
3026) -> Option<&'a str> {
3027    if seen_head.is_empty()
3028        || awaiting_new_head(awaiting, seen_head)
3029        || !rollup_head.eq_ignore_ascii_case(seen_head)
3030    {
3031        return None;
3032    }
3033    Some(seen_head)
3034}
3035
3036/// What a refused `gh pr merge` means.
3037#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3038enum Refused {
3039    /// The branch policy is not satisfied *yet*: go back to waiting.
3040    Pending,
3041    /// Checks have settled and the merge state still says no, seen for the
3042    /// first time. The forge updates `mergeStateStatus` a moment after the last
3043    /// check finishes, so one more look is allowed before believing it.
3044    Recheck,
3045    /// Nothing is in flight and the policy still refuses: a person has to
3046    /// supply what it asks for (a review, say).
3047    Final,
3048}
3049
3050/// Judged from the pull request's state after the refusal, never from the
3051/// refusal's wording, which belongs to the forge and changes.
3052fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3053    let Some(after) = after else {
3054        // Unreadable is not evidence of anything; the next loop reads again.
3055        return Refused::Pending;
3056    };
3057    if after.pr.state != PrLifecycle::Open {
3058        return Refused::Final;
3059    }
3060    // The branch moved after it was observed (the forge refuses a merge pinned
3061    // to the old commit): not a verdict on anything, look again.
3062    if !after.head.eq_ignore_ascii_case(observed_head) {
3063        return Refused::Pending;
3064    }
3065    // The same binding the loop applies: checks of another commit say nothing.
3066    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3067        return Refused::Pending;
3068    }
3069    let state = after.merge_state.to_ascii_uppercase();
3070    if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3071        || state.is_empty()
3072        || state == "UNKNOWN"
3073    {
3074        return Refused::Pending;
3075    }
3076    if rechecked {
3077        Refused::Final
3078    } else {
3079        Refused::Recheck
3080    }
3081}
3082
3083/// Take auto-merge back from the forge and forget that it was armed.
3084///
3085/// `Err` carries the forge's message: the caller must not push or move on, as
3086/// an armed merge left behind could still fire for a commit nobody approved.
3087async fn disarm<F: Forge>(
3088    forge: &F,
3089    state: &mut RunState,
3090    repo: &Path,
3091    number: u64,
3092) -> std::result::Result<(), String> {
3093    let argv = disable_automerge_argv(number);
3094    let out = {
3095        let merge_lock = repo_merge_lock(repo);
3096        let _merge_slot = merge_lock.lock().await;
3097        forge.merge(repo, &argv).await
3098    };
3099    match out {
3100        Ok((true, _)) => {
3101            state.land_armed_head = None;
3102            state.event("land", "auto-merge disabled");
3103            state.save().map_err(|e| format!("{e:#}"))?;
3104            Ok(())
3105        }
3106        Ok((false, msg)) => Err(msg),
3107        Err(e) => Err(format!("{e:#}")),
3108    }
3109}
3110
3111/// [`stop`], first taking back an armed auto-merge so a pull request magi
3112/// gave up on does not merge on its own later. A failure to disarm is added
3113/// to the reason rather than hiding it.
3114async fn stop_disarmed<F: Forge>(
3115    forge: &F,
3116    state: &mut RunState,
3117    repo: &Path,
3118    pr: &PrState,
3119    why: &str,
3120) -> Result<()> {
3121    if state.land_armed_head.is_none() {
3122        return stop(state, repo, pr, why).await;
3123    }
3124    match disarm(forge, state, repo, pr.number).await {
3125        Ok(()) => stop(state, repo, pr, why).await,
3126        Err(e) => {
3127            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3128            stop(state, repo, pr, &why).await
3129        }
3130    }
3131}
3132
3133async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3134    let repo = state.repo.clone();
3135    let budget = state.config.graph.land_rounds;
3136    let mut round = 0usize;
3137    // Counted apart from `round`: a rebase is not a fix, and a base that
3138    // moved is not the change's fault.
3139    let mut rebases = 0usize;
3140    let mut waited = Duration::ZERO;
3141    // Comment bodies the fixer has already been shown. A comment is
3142    // outstanding until it has been handed over once; after that it is a
3143    // recorded decision, not an open question, and re-feeding it would loop the
3144    // budget away on a comment the fixer already declined with an argument.
3145    let mut shown: BTreeSet<String> = BTreeSet::new();
3146    // The head a fix round pushed, until the pull request is seen on it. Memory
3147    // only: a resume after a crash between the push and the next look can read
3148    // the old head's green once more, and a refused merge then waits it out.
3149    let mut awaiting_head: Option<String> = None;
3150    // Whether a settled-checks refusal has already been given its one re-look.
3151    let mut rechecked = false;
3152
3153    // Marks the run resumable through exactly this function, not through a
3154    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
3155    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
3156    // status specifically to know a resumed run belongs back in `land`
3157    // rather than at a second `gh pr create`. Set on every entry - fresh or
3158    // resumed - because a resume that parked here again must keep reading
3159    // `Landing`, not whatever a first pass through `merge` left behind.
3160    state.status = RunStatus::Landing;
3161    state.event("land", format!("watching {pr_url}"));
3162    state.save()?;
3163
3164    // An armed merge recorded by an earlier pass may or may not have reached
3165    // the forge (the record is written before the call). It is taken back on
3166    // the first observation, where a pull request is known to stop with.
3167    let mut resumed_armed = state.land_armed_head.is_some();
3168
3169    loop {
3170        let seen = forge.view(&repo, pr_url).await?;
3171        let mut pr = seen.pr.clone();
3172        pr.review_comments.retain(|c| !shown.contains(&c.body));
3173        state.pr = Some(crate::run::PrRecord {
3174            url: pr.url.clone(),
3175            number: pr.number,
3176            state: pr.state.as_str().to_owned(),
3177            checks: pr.checks.as_str().to_owned(),
3178            round,
3179            rounds: budget,
3180            red_at_merge: Vec::new(),
3181        });
3182        state.save()?;
3183
3184        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3185            // An approval already given for the same head is reused, so
3186            // nothing is asked twice. A failed disable
3187            // stops the run with the record kept: pushing on could change the
3188            // head under an arm that is still live.
3189            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3190                let why = format!(
3191                    "a previous pass may have armed auto-merge and it could not be disabled \
3192                     on resume: {e}"
3193                );
3194                stop(state, &repo, &pr, &why).await?;
3195                return Ok(pr);
3196            }
3197        }
3198
3199        // The head moved away from the one auto-merge was armed on, by
3200        // someone other than this loop. The arm is pinned and would refuse to
3201        // merge the new commit, but the approval was for the old one: take it
3202        // back and go through the normal path again.
3203        if pr.state == PrLifecycle::Open
3204            && !seen.head.is_empty()
3205            && state
3206                .land_armed_head
3207                .as_deref()
3208                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3209        {
3210            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3211                let why = format!(
3212                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
3213                );
3214                stop(state, &repo, &pr, &why).await?;
3215                return Ok(pr);
3216            }
3217        }
3218
3219        if pr.state == PrLifecycle::Open {
3220            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3221                if waited >= WAIT_CEILING {
3222                    let want = awaiting_head.as_deref().unwrap_or_default();
3223                    let why = format!(
3224                        "the pull request's checks were still not about one readable head after \
3225                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
3226                         someone may have pushed over it",
3227                        WAIT_CEILING.as_secs() / 60,
3228                        if want.is_empty() { "any" } else { want },
3229                        if seen.head.is_empty() {
3230                            "nothing readable"
3231                        } else {
3232                            &seen.head
3233                        },
3234                        if seen.rollup_head.is_empty() {
3235                            "nothing readable"
3236                        } else {
3237                            &seen.rollup_head
3238                        },
3239                    );
3240                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
3241                    return Ok(pr);
3242                }
3243                waited += POLL;
3244                forge.poll().await;
3245                continue;
3246            }
3247            // Reset once, when the awaited head first shows up; resetting on
3248            // every re-observation would let a standing refusal wait forever.
3249            if awaiting_head.take().is_some() {
3250                // The checks now being read belong to the new head; give them
3251                // the same grace a fresh pull request gets.
3252                waited = Duration::ZERO;
3253            }
3254        }
3255
3256        let step = decide(&pr, round, budget, waited);
3257        // Armed on exactly this head: the forge is doing the waiting. A
3258        // decision to merge (or keep waiting) is only watched, never re-armed
3259        // and never re-approved; anything else - a red check, a comment, a
3260        // conflict - falls through to its own arm, which disarms first.
3261        let armed_here = state
3262            .land_armed_head
3263            .as_deref()
3264            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3265        if armed_here && matches!(step, Step::Merge | Step::Wait) {
3266            if waited >= WAIT_CEILING {
3267                let required = if seen.base.is_empty() {
3268                    None
3269                } else {
3270                    forge.required_contexts(&repo, &seen.base).await
3271                };
3272                let why = format!(
3273                    "auto-merge was armed on {} but the pull request did not merge within {} \
3274                     minutes ({})",
3275                    seen.head,
3276                    WAIT_CEILING.as_secs() / 60,
3277                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3278                );
3279                stop_disarmed(forge, state, &repo, &pr, &why).await?;
3280                return Ok(pr);
3281            }
3282            waited += POLL;
3283            forge.poll().await;
3284            continue;
3285        }
3286        if armed_here && !matches!(step, Step::Done { .. }) {
3287            // Not merging or waiting any more: whatever is next may change the
3288            // head or give up, and neither may leave the arm standing.
3289            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3290                let why = format!("auto-merge could not be disabled: {e}");
3291                stop(state, &repo, &pr, &why).await?;
3292                return Ok(pr);
3293            }
3294        }
3295        match step {
3296            Step::Wait => {
3297                if waited >= WAIT_CEILING {
3298                    let why = format!(
3299                        "checks were still running after {} minutes",
3300                        WAIT_CEILING.as_secs() / 60
3301                    );
3302                    stop(state, &repo, &pr, &why).await?;
3303                    return Ok(pr);
3304                }
3305                waited += POLL;
3306                forge.poll().await;
3307            }
3308            Step::Done { merged } => {
3309                // Auto-merge is pinned to the approved head, but the forge's
3310                // own handling of a later push is not something magi can
3311                // see: if the pull request merged on another commit, say so
3312                // loudly rather than record a clean landing.
3313                if let Some(armed) = state.land_armed_head.take() {
3314                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3315                        let msg = format!(
3316                            "{} merged on {} but the owner approved {armed}; review what landed",
3317                            pr.url, seen.head
3318                        );
3319                        tracing::warn!("{msg}");
3320                        state.event("land", msg);
3321                        // Only an arm left by an older build can get here.
3322                        // The wording is fixed so a repeat does not relight
3323                        // the notice.
3324                        crate::notices::raise_with(
3325                            crate::notices::Notice::warn(
3326                                &format!("merged-unapproved-head:{}", state.id),
3327                                "A pull request merged on a commit the owner did not approve; \
3328                                 review what landed",
3329                            )
3330                            .link(crate::notices::Link::Run {
3331                                id: state.id.clone(),
3332                            }),
3333                            &state.config.notify,
3334                        );
3335                    }
3336                }
3337                state.status = if merged {
3338                    RunStatus::Merged
3339                } else {
3340                    RunStatus::Ready
3341                };
3342                let detail = if merged {
3343                    format!("{} was merged", pr.url)
3344                } else {
3345                    format!("{} was closed without merging", pr.url)
3346                };
3347                state.merge = Some(MergeOutcome {
3348                    mode: MergeMode::Pr,
3349                    ok: merged,
3350                    detail: detail.clone(),
3351                    empty: false,
3352                });
3353                state.event("land", detail);
3354                state.save()?;
3355                write_pr_state_through(state, pr.state);
3356                return Ok(pr);
3357            }
3358            Step::Merge => {
3359                let subject = merge_subject(
3360                    crate::graph::landing_title(state, &seen.title),
3361                    &crate::graph::landing_subject_source(state),
3362                );
3363                // The owner sees the panel before the one irreversible step,
3364                // and an unanswered question is a hold: silence never merges.
3365                //
3366                // `land_approval` asks about every merge. With it off, a
3367                // review hand-off the panel contested (a blocking finding
3368                // open and a reject vote) is asked about all the same.
3369                let contested = contested_to_ask(state);
3370                if state.config.graph.land_approval || contested.is_some() {
3371                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3372                        .await?
3373                    {
3374                        ApprovalGate::Approved => {}
3375                        ApprovalGate::Held => {
3376                            stop(
3377                                state,
3378                                &repo,
3379                                &pr,
3380                                "the owner did not approve the merge (held or unanswered)",
3381                            )
3382                            .await?;
3383                            return Ok(pr);
3384                        }
3385                        // Filed (or still standing from an earlier visit) and
3386                        // not yet answered. Park here rather than wait: the
3387                        // question survives on disk, the daemon hands this
3388                        // run's slot to something else, and a later resume
3389                        // re-enters `land`, finds the same question, and
3390                        // either merges or stops depending on what it says
3391                        // by then.
3392                        ApprovalGate::Pending => {
3393                            state.parked = true;
3394                            state.event(
3395                                "land",
3396                                "parked awaiting merge approval - resumes once answered",
3397                            );
3398                            state.save()?;
3399                            return Ok(pr);
3400                        }
3401                    }
3402                }
3403                // Open and past the gate above, so `seen.head` is the commit
3404                // the checks were bound to and the owner approved.
3405                //
3406                // Merge directly, bound to that commit. Auto-merge is not
3407                // armed any more: the forge checks `--match-head-commit` only
3408                // when the request is made, so an arm outlives the head it
3409                // was made for, and a push of another commit whose
3410                // requirements are met first would merge without approval.
3411                // A direct merge is checked by the forge at the moment it
3412                // happens, so only the approved head can land.
3413                let observed_head = seen.head.clone();
3414                // Read the pull request again just before: the state seen
3415                // above can be a moment old.
3416                {
3417                    let fresh = forge.view(&repo, pr_url).await.ok();
3418                    if !direct_merge_is_safe(
3419                        fresh.as_ref(),
3420                        &observed_head,
3421                        &shown,
3422                        round,
3423                        budget,
3424                        waited,
3425                    ) {
3426                        if waited >= WAIT_CEILING {
3427                            let why = "the pull request did not settle on the approved head \
3428                                       before it could be merged";
3429                            stop(state, &repo, &pr, why).await?;
3430                            return Ok(pr);
3431                        }
3432                        state.event(
3433                            "land",
3434                            "the pull request changed before merging; looking again",
3435                        );
3436                        waited += POLL;
3437                        forge.poll().await;
3438                        continue;
3439                    }
3440                }
3441                let argv = merge_argv_at(pr.number, &subject, &observed_head);
3442                let out = {
3443                    let merge_lock = repo_merge_lock(&repo);
3444                    let _merge_slot = merge_lock.lock().await;
3445                    forge.merge(&repo, &argv).await?
3446                };
3447                if out.0 {
3448                    // Exit 0 is not proof of a merge: with a merge queue `gh`
3449                    // enqueues (or reports the pull request already queued)
3450                    // and succeeds while it is still open. Ask the forge; an
3451                    // unreadable answer is not a confirmation either, so it
3452                    // is looked at again rather than recorded as merged.
3453                    let confirmed = forge
3454                        .view(&repo, pr_url)
3455                        .await
3456                        .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
3457                    if !confirmed {
3458                        if waited >= WAIT_CEILING {
3459                            let why = "the merge request succeeded but the pull request \
3460                                       could not be confirmed merged after waiting";
3461                            stop(state, &repo, &pr, why).await?;
3462                            return Ok(pr);
3463                        }
3464                        state.event(
3465                            "land",
3466                            "merge accepted but the pull request is not confirmed merged yet; waiting",
3467                        );
3468                        state.save()?;
3469                        waited += POLL;
3470                        forge.poll().await;
3471                        continue;
3472                    }
3473                    pr.state = PrLifecycle::Merged;
3474                    state.status = RunStatus::Merged;
3475                    state.merge = Some(MergeOutcome {
3476                        mode: MergeMode::Pr,
3477                        ok: true,
3478                        detail: format!("gh {}", argv.join(" ")),
3479                        empty: false,
3480                    });
3481                    // The last `state.pr` snapshot is whatever the poll before
3482                    // this merge observed - still `open` - and nothing below
3483                    // refreshes it from GitHub again, so the UI's round rail
3484                    // would otherwise keep animating a merged run forever.
3485                    if let Some(pr_record) = state.pr.as_mut() {
3486                        pr_record.state = pr.state.as_str().to_owned();
3487                    }
3488                    state.event("land", format!("merged {} as `{subject}`", pr.url));
3489                    announce_red_merge(state, &pr).await;
3490                    state.save()?;
3491                    write_pr_state_through(state, pr.state);
3492                    return Ok(pr);
3493                }
3494                let after_seen = forge.view(&repo, pr_url).await.ok();
3495                let after = after_seen.as_ref().map(|s| s.pr.state);
3496                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
3497                    pr.state = PrLifecycle::Merged;
3498                    state.status = RunStatus::Merged;
3499                    state.merge = Some(outcome);
3500                    if let Some(pr_record) = state.pr.as_mut() {
3501                        pr_record.state = pr.state.as_str().to_owned();
3502                    }
3503                    state.event("land", format!("merged {} as `{subject}`", pr.url));
3504                    announce_red_merge(state, &pr).await;
3505                    state.save()?;
3506                    write_pr_state_through(state, pr.state);
3507                    return Ok(pr);
3508                }
3509                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
3510                match verdict {
3511                    Refused::Final => {
3512                        let merge_state = after_seen
3513                            .as_ref()
3514                            .map(|s| s.merge_state.as_str())
3515                            .filter(|m| !m.is_empty())
3516                            .unwrap_or("unknown");
3517                        // Which refusal this was decides what a person has to
3518                        // do about it, so the two are worded apart; both carry
3519                        // the forge's own message.
3520                        let why = format!(
3521                            "the merge was refused: {} (merge state: {merge_state})",
3522                            out.1
3523                        );
3524                        stop(state, &repo, &pr, &why).await?;
3525                        return Ok(pr);
3526                    }
3527                    verdict => {
3528                        // Back to the top, which re-decides and passes the
3529                        // approval gate again, a poll later. `waited` is not
3530                        // reset here: only a pushed fix restarts it, or a
3531                        // standing refusal would wait forever.
3532                        if waited >= WAIT_CEILING {
3533                            let why = format!(
3534                                "the merge was still refused after {} minutes: {}",
3535                                WAIT_CEILING.as_secs() / 60,
3536                                out.1
3537                            );
3538                            stop(state, &repo, &pr, &why).await?;
3539                            return Ok(pr);
3540                        }
3541                        if verdict == Refused::Recheck {
3542                            rechecked = true;
3543                        }
3544                        state.event(
3545                            "land",
3546                            "merge refused while the branch policy is not satisfied yet; waiting",
3547                        );
3548                        state.save()?;
3549                        waited += POLL;
3550                        forge.poll().await;
3551                    }
3552                }
3553            }
3554            Step::Rebase => {
3555                // Bounded by the same budget as a fix, because a rebase that
3556                // keeps being needed means the base moves faster than this
3557                // run can land and a person should decide what to do. It
3558                // spends none of that budget: the change is not what is
3559                // wrong.
3560                if rebases >= budget {
3561                    let why = format!(
3562                        "the base moved under this branch {budget} time(s) and it still does \
3563                         not merge; rebasing again would only race it"
3564                    );
3565                    stop(state, &repo, &pr, &why).await?;
3566                    return Ok(pr);
3567                }
3568                rebases += 1;
3569                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
3570                    stop(
3571                        state,
3572                        &repo,
3573                        &pr,
3574                        "the pull request conflicts and this run has no winning branch to rebase",
3575                    )
3576                    .await?;
3577                    return Ok(pr);
3578                };
3579                let base = state.base_branch.clone();
3580                state.event(
3581                    "land",
3582                    format!("{} no longer merges; rebasing onto {base}", pr.url),
3583                );
3584                state.save()?;
3585
3586                // Onto the base as the *remote* has it: the local ref may be
3587                // behind, and rebasing onto a stale base produces a branch
3588                // that conflicts all over again.
3589                git::fetch(&repo, "origin", &base).await.ok();
3590                let scratch = state.dir().join("rebase");
3591                let onto = format!("origin/{base}");
3592                let rebased =
3593                    match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
3594                        Ok(crate::rebase::Rebased::Applied) => Ok(None),
3595                        Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3596                        Err(e) => Err(e),
3597                    };
3598                match rebased {
3599                    Ok(None) => {
3600                        let pushed = {
3601                            let merge_lock = repo_merge_lock(&repo);
3602                            let _merge_slot = merge_lock.lock().await;
3603                            git::push_rewritten(&repo, "origin", &branch).await?
3604                        };
3605                        if !pushed.ok() {
3606                            let why = format!(
3607                                "rebased {branch} but could not push it: {}",
3608                                pushed.stderr.trim()
3609                            );
3610                            stop(state, &repo, &pr, &why).await?;
3611                            return Ok(pr);
3612                        }
3613                        // Bind to what was pushed: until the pull request is
3614                        // seen on it, the rollup is the old head's.
3615                        let head =
3616                            match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
3617                                Ok(head) => head,
3618                                Err(e) => {
3619                                    let why = format!(
3620                                        "rebased and pushed {branch} but could not read the pushed \
3621                                     commit: {e:#}"
3622                                    );
3623                                    stop(state, &repo, &pr, &why).await?;
3624                                    return Ok(pr);
3625                                }
3626                            };
3627                        crate::graph::refresh_reviewed_commits(state, &branch).await;
3628                        awaiting_head = Some(head);
3629                        rechecked = false;
3630                        state.event("land", format!("rebased {branch} onto {base}"));
3631                        state.save()?;
3632                        // The forge has to re-run its checks against the
3633                        // rebased head before anything else can be decided.
3634                        waited = Duration::ZERO;
3635                        tokio::time::sleep(POLL).await;
3636                    }
3637                    // The fixer's rounds are spent (or it could not finish):
3638                    // that is a decision for a person.
3639                    Ok(Some(conflict)) => {
3640                        let why = format!(
3641                            "{} conflicts with {base} and the rebase did not apply: {}",
3642                            pr.url,
3643                            conflict.chars().take(600).collect::<String>()
3644                        );
3645                        stop(state, &repo, &pr, &why).await?;
3646                        return Ok(pr);
3647                    }
3648                    Err(e) => {
3649                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
3650                        stop(state, &repo, &pr, &why).await?;
3651                        return Ok(pr);
3652                    }
3653                }
3654            }
3655            Step::GiveUp { reason } => {
3656                stop(state, &repo, &pr, &reason).await?;
3657                return Ok(pr);
3658            }
3659            Step::Fix { reason } => {
3660                round += 1;
3661                waited = Duration::ZERO;
3662                for c in &pr.review_comments {
3663                    shown.insert(c.body.clone());
3664                }
3665                state.event("land", format!("round {round}: {reason}"));
3666                state.save()?;
3667
3668                let logs = failing_logs(&repo, &seen.failing_urls).await;
3669                let was_red = pr.checks == Checks::Red;
3670                match forge.fix(state, &pr, round, budget, &reason, &logs).await? {
3671                    Fixed::Committed { head } => {
3672                        // Whatever the next look shows may still be the
3673                        // previous head; see `awaiting_new_head`.
3674                        awaiting_head = Some(head);
3675                        rechecked = false;
3676                        waited = Duration::ZERO;
3677                        forge.poll().await;
3678                    }
3679                    Fixed::Declined if was_red => {
3680                        let why = format!(
3681                            "the fixer produced no commit while {} check(s) were failing \
3682                             ({}); stopping instead of looping on an unchanged tree",
3683                            pr.failing.len(),
3684                            pr.failing.join(", ")
3685                        );
3686                        stop(state, &repo, &pr, &why).await?;
3687                        return Ok(pr);
3688                    }
3689                    // Comment-driven round with no commit: the fixer read the
3690                    // comments and changed nothing, which is a decision it is
3691                    // allowed to make. The comments are recorded as shown, so
3692                    // the next observation sees a clean pull request.
3693                    Fixed::Declined => state.event(
3694                        "land",
3695                        format!("round {round}: fixer declined the comments, nothing committed"),
3696                    ),
3697                    Fixed::Failed(why) => {
3698                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
3699                        return Ok(pr);
3700                    }
3701                }
3702                state.save()?;
3703            }
3704        }
3705    }
3706}
3707
3708/// One observation, plus the two things [`PrState`] deliberately does not carry:
3709/// the title (needed for the squash subject) and where the failing checks'
3710/// logs live.
3711#[derive(Clone)]
3712struct Seen {
3713    pr: PrState,
3714    title: String,
3715    failing_urls: Vec<(String, String)>,
3716    /// `headRefOid`: the commit this observation, checks included, is about.
3717    head: String,
3718    /// The commit the checks belong to, read from the same GraphQL node as
3719    /// the checks themselves. Empty when unreadable.
3720    rollup_head: String,
3721    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
3722    /// BEHIND and DRAFT together, and a stop reason has to say which.
3723    merge_state: String,
3724    /// Every check of the rollup, for naming what an armed merge waits on.
3725    contexts: Vec<CheckInfo>,
3726    /// `baseRefName`, to look up which contexts the base requires.
3727    base: String,
3728}
3729
3730/// One check of the rollup as far as a stop reason needs it.
3731#[derive(Clone)]
3732struct CheckInfo {
3733    label: String,
3734    verdict: Verdict,
3735    required: Option<bool>,
3736}
3737
3738/// Read the pull request: `gh pr view` for the top-level thread and merge
3739/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
3740/// inline review comments `gh pr view` does not report.
3741async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
3742    let view = gh(
3743        repo,
3744        &[
3745            "pr".to_owned(),
3746            "view".to_owned(),
3747            pr_url.to_owned(),
3748            "--json".to_owned(),
3749            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
3750                .to_owned(),
3751        ],
3752    )
3753    .await?;
3754    if !view.0 {
3755        bail!("gh pr view {pr_url}: {}", view.1);
3756    }
3757    let number = parse_pr(&view.1)?.number;
3758    let node = last_commit_node(repo, number).await;
3759    let mut seen = seen_from(&view.1, node.as_deref())?;
3760
3761    let inline = gh(
3762        repo,
3763        &[
3764            "api".to_owned(),
3765            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
3766        ],
3767    )
3768    .await?;
3769    if inline.0 {
3770        match parse_inline_comments(&inline.1) {
3771            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
3772            // An unreadable inline thread must not end a landing: the rollup
3773            // and the top-level thread are still real signal.
3774            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
3775        }
3776    } else {
3777        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
3778    }
3779    Ok(seen)
3780}
3781
3782/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
3783/// response. No I/O.
3784///
3785/// The commit oid and the checks are read from the *same* node, so the rollup
3786/// is bound to the commit it belongs to by construction; two reads that agree
3787/// before and after another response prove nothing about what that response
3788/// held. The view's own rollup is never used. A node that is missing,
3789/// unreadable, carries GraphQL errors, or whose checks run past the page
3790/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
3791/// as "look again" and never as a reason to merge.
3792fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
3793    let mut pr = parse_pr(view_json)?;
3794    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
3795
3796    let mut rollup_head = String::new();
3797    let mut failing_urls = Vec::new();
3798    let mut contexts = Vec::new();
3799    let mut checks = Checks::Unknown;
3800    let mut failing = Vec::new();
3801    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
3802        (checks, failing) = rollup_verdict(&rollup);
3803        failing_urls = rollup
3804            .iter()
3805            .filter(|c| c.verdict() == Verdict::Fail)
3806            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
3807            .collect();
3808        contexts = rollup
3809            .iter()
3810            .map(|c| CheckInfo {
3811                label: c.label(),
3812                verdict: c.verdict(),
3813                required: c.is_required,
3814            })
3815            .collect();
3816        rollup_head = oid;
3817    }
3818    pr.checks = checks;
3819    pr.failing = failing;
3820
3821    Ok(Seen {
3822        pr,
3823        title: raw.title,
3824        failing_urls,
3825        head: raw.head_ref_oid,
3826        rollup_head,
3827        merge_state: raw.merge_state_status,
3828        contexts,
3829        base: raw.base_ref_name,
3830    })
3831}
3832
3833/// The last commit's oid and its checks from one GraphQL response, `None`
3834/// when anything about it cannot be trusted.
3835fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
3836    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3837    if v.get("errors").is_some_and(|e| !e.is_null()) {
3838        return None;
3839    }
3840    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
3841    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
3842    let contexts = commit.pointer("/statusCheckRollup/contexts");
3843    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
3844        // No rollup at all: the commit has no checks.
3845        return Some((oid.to_owned(), Vec::new()));
3846    };
3847    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
3848        return None;
3849    }
3850    let nodes = contexts.get("nodes")?.as_array()?;
3851    let rollup = nodes
3852        .iter()
3853        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
3854        .collect::<Result<Vec<_>, _>>()
3855        .ok()?;
3856    Some((oid.to_owned(), rollup))
3857}
3858
3859/// The pull request's last commit and its checks, in one response. `None`
3860/// when the forge could not be asked.
3861async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
3862    let out = gh(
3863        repo,
3864        &[
3865            "api".to_owned(),
3866            "graphql".to_owned(),
3867            "-F".to_owned(),
3868            "owner={owner}".to_owned(),
3869            "-F".to_owned(),
3870            "repo={repo}".to_owned(),
3871            "-F".to_owned(),
3872            format!("number={number}"),
3873            "-f".to_owned(),
3874            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
3875             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
3876             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
3877             ... on CheckRun{name status conclusion detailsUrl \
3878             isRequired(pullRequestNumber:$number)} \
3879             ... on StatusContext{context state targetUrl \
3880             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
3881                .to_owned(),
3882        ],
3883    )
3884    .await
3885    .ok()?;
3886    out.0.then_some(out.1)
3887}
3888
3889/// What a fix round did.
3890#[doc(hidden)]
3891#[derive(Debug, PartialEq)]
3892pub enum Fixed {
3893    /// The fixer committed something, and this is the head that was pushed.
3894    Committed {
3895        /// The commit now at the tip of the pushed branch.
3896        head: String,
3897    },
3898    /// The fixer ran and chose to change nothing.
3899    Declined,
3900    /// The fixer could not run, or said nothing usable.
3901    Failed(String),
3902}
3903
3904/// Hand the failures and the comments to the fixer, then commit and push.
3905///
3906/// The fixer works in the winner's own worktree so its commits land on the
3907/// branch the pull request is built from, and it runs with `allow_write` for
3908/// the same reason.
3909#[doc(hidden)]
3910pub async fn fix_round(
3911    state: &mut RunState,
3912    pr: &PrState,
3913    round: usize,
3914    budget: usize,
3915    reason: &str,
3916    logs: &str,
3917) -> Result<Fixed> {
3918    let winner = state
3919        .winner()
3920        .cloned()
3921        .context("landing needs a winning candidate; none is recorded on this run")?;
3922    let roles = state
3923        .config
3924        .resolve_roles()
3925        .context("resolve the roster for the fix round")?;
3926    // Same rule as the review loop: an explicitly configured fixer, otherwise
3927    // the winner's own author continuing its own conversation - the competition
3928    // is over, so its context is pure benefit.
3929    let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
3930        Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
3931        _ => (
3932            state
3933                .config
3934                .agent(&winner.agent)
3935                .cloned()
3936                .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
3937            format!("impl-{}", winner.label),
3938        ),
3939    };
3940
3941    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
3942    let mut seat = seat_of(state, &seat_key, &spec.id);
3943    let artifacts = agent::artifacts_dir(&state.dir());
3944    let prompt = if state.config.cache_dir().is_some() {
3945        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
3946    } else {
3947        prompt
3948    };
3949    // Read before the fixer runs: it normally commits for itself, so HEAD has
3950    // already moved by the time it returns and a later read would see no
3951    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
3952    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
3953    let out = agent::invoke(
3954        &spec,
3955        &mut seat,
3956        &Invocation {
3957            cwd: &winner.worktree,
3958            prompt: &prompt,
3959            timeout: Duration::from_secs(state.config.graph.timeout_fix),
3960            allow_write: true,
3961            sessions: state.config.graph.sessions,
3962            artifacts: &artifacts,
3963            stem: &format!("land-{round}"),
3964            run: &state.id,
3965            node: "land",
3966            cache_dir: state.config.cache_dir().as_deref(),
3967            attachments: &[],
3968            writable: &[],
3969        },
3970    )
3971    .await;
3972    state.seats.insert(seat.key.clone(), seat);
3973
3974    match out {
3975        Ok(o) if o.quota_exhausted() => {
3976            return Ok(Fixed::Failed(
3977                "rate limited (quota); the fixer could not run".to_owned(),
3978            ));
3979        }
3980        Ok(o) if !o.usable() => {
3981            return Ok(Fixed::Failed(format!(
3982                "the fixer produced nothing usable (exit {:?}, timed out: {})",
3983                o.exit_code, o.timed_out
3984            )));
3985        }
3986        Ok(_) => {}
3987        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
3988    }
3989
3990    // An agent that edited files but never committed would otherwise push
3991    // nothing and look like a refusal.
3992    if let Ok(r) = git::rescue_commit(
3993        &winner.worktree,
3994        &format!("magi: land round {round} fixes (uncommitted work)"),
3995    )
3996    .await
3997    {
3998        state.note_withheld("land", &r.withheld);
3999    }
4000    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4001    if after == before {
4002        return Ok(Fixed::Declined);
4003    }
4004
4005    let remote = state.config.merge.remote.clone();
4006    let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
4007    if !push.ok() {
4008        return Ok(Fixed::Failed(format!(
4009            "pushing {} to {remote} failed: {}",
4010            winner.branch, push.stderr
4011        )));
4012    }
4013    state.event(
4014        "land",
4015        format!("round {round}: pushed a fix to {}", winner.branch),
4016    );
4017    Ok(Fixed::Committed { head: after })
4018}
4019
4020/// Fetch or create a seat, keeping its conversation across nodes.
4021pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4022    if let Some(existing) = state.seats.get(key)
4023        && existing.agent == agent
4024    {
4025        return existing.clone();
4026    }
4027    let fresh = SeatState::new(key, agent, state.seed);
4028    state.seats.insert(key.to_owned(), fresh.clone());
4029    fresh
4030}
4031
4032/// What the fixer is told.
4033fn fix_prompt(
4034    state: &RunState,
4035    pr: &PrState,
4036    round: usize,
4037    budget: usize,
4038    reason: &str,
4039    logs: &str,
4040) -> String {
4041    let mut s = format!(
4042        "Your patch is open as a pull request and it is not landing. Land round \
4043         {round} of {budget}.\n\n\
4044         Pull request: {}\n\n\
4045         What is holding it: {reason}\n\n\
4046         # The task\n\n{}\n",
4047        pr.url, state.instruction
4048    );
4049
4050    if pr.failing.is_empty() {
4051        s.push_str("\n# Failing checks\n\n(none)\n");
4052    } else {
4053        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4054        if logs.trim().is_empty() {
4055            s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4056        } else {
4057            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4058        }
4059    }
4060
4061    if pr.review_comments.is_empty() {
4062        s.push_str("\n# Review comments\n\n(none)\n");
4063    } else {
4064        s.push_str("\n# Review comments\n");
4065        for c in &pr.review_comments {
4066            let where_ = match (&c.path, c.line) {
4067                (Some(p), Some(l)) => format!(" ({p}:{l})"),
4068                (Some(p), None) => format!(" ({p})"),
4069                _ => String::new(),
4070            };
4071            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4072        }
4073    }
4074
4075    s.push_str(
4076        "\n# Rules\n\n\
4077         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4078            failing test; do not silence a lint with an allow attribute; do not \
4079            stretch a timeout to hide a race. If the check is right, the code is \
4080            wrong.\n\
4081         2. Change nothing the checks and the comments did not raise. A \
4082            drive-by refactor turns a one-line fix into a pull request that \
4083            needs reviewing again.\n\
4084         3. If a comment is wrong, say so with a checkable argument and change \
4085            nothing for it. A declined comment with a reason is a correct \
4086            outcome; a change made to appease a reviewer is not.\n\
4087         4. Commit in this worktree. magi pushes to the pull request's branch \
4088            for you; do not push, merge, or close anything yourself.\n\
4089         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4090         # Output\n\n\
4091         Say what you changed and why, and what you declined and why.",
4092    );
4093
4094    let language = &state.config.graph.language;
4095    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4096        let _ = write!(s, "\n\nWrite all prose in {language}.");
4097    }
4098    // After the language line, so the exception is the last word on it.
4099    s.push_str(&crate::prompt::github_english(language));
4100    if let Some(overlay) = state.config.prompts.overlay("fix") {
4101        let _ = write!(s, "\n\n{overlay}");
4102    }
4103    s
4104}
4105
4106/// Failing log tails, the way the operator collects them by hand:
4107/// `gh run view --log-failed`.
4108async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4109    let mut out = String::new();
4110    for (name, url) in failing.iter().take(MAX_LOGS) {
4111        let args = match (job_of(url), run_of(url)) {
4112            (Some(job), _) => vec![
4113                "run".to_owned(),
4114                "view".to_owned(),
4115                "--log-failed".to_owned(),
4116                "--job".to_owned(),
4117                job,
4118            ],
4119            (None, Some(run)) => vec![
4120                "run".to_owned(),
4121                "view".to_owned(),
4122                run,
4123                "--log-failed".to_owned(),
4124            ],
4125            // Not a GitHub Actions check - an external status has no log here.
4126            (None, None) => continue,
4127        };
4128        let (ok, body) = match gh(repo, &args).await {
4129            Ok(v) => v,
4130            Err(e) => (false, format!("{e:#}")),
4131        };
4132        if !ok && body.trim().is_empty() {
4133            continue;
4134        }
4135        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4136    }
4137    out
4138}
4139
4140/// Job id out of a check's `detailsUrl`
4141/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
4142fn job_of(details_url: &str) -> Option<String> {
4143    let after = details_url.split("/job/").nth(1)?;
4144    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4145    (!id.is_empty()).then_some(id)
4146}
4147
4148/// Workflow run id out of a check's `detailsUrl`.
4149pub(crate) fn run_of(details_url: &str) -> Option<String> {
4150    let after = details_url.split("/actions/runs/").nth(1)?;
4151    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4152    (!id.is_empty()).then_some(id)
4153}
4154
4155/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
4156/// it lands on GitHub, not in front of the operator. Pure so a test can hold
4157/// it to that.
4158fn stop_comment(run_id: &str, why: &str) -> String {
4159    format!(
4160        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4161         The branch is untouched and the run is `{run_id}`. Nothing was merged."
4162    )
4163}
4164
4165/// Leave the pull request open, say why on it, and mark the run blocked.
4166///
4167/// The comment is what makes an unattended stop actionable: the operator wakes
4168/// up to a pull request that explains itself rather than to a silent queue.
4169async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4170    let body = stop_comment(&state.id, why);
4171    let posted = gh(
4172        repo,
4173        &[
4174            "pr".to_owned(),
4175            "comment".to_owned(),
4176            pr.number.to_string(),
4177            "--body".to_owned(),
4178            body,
4179        ],
4180    )
4181    .await;
4182    match posted {
4183        Ok((true, _)) => {}
4184        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4185        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4186    }
4187    state.status = RunStatus::Blocked;
4188    state.merge = Some(MergeOutcome {
4189        mode: MergeMode::Pr,
4190        ok: false,
4191        detail: why.to_owned(),
4192        empty: false,
4193    });
4194    state.event("land", format!("stopped: {why}"));
4195    state.save()?;
4196    Ok(())
4197}
4198
4199/// Run `gh` in `repo`, returning success and the combined output.
4200///
4201/// Combined because `gh` reports a refused merge on stderr and the pull request
4202/// json on stdout, and both are evidence.
4203///
4204/// `GH_REPO` is stripped from the child's environment: every call site here
4205/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
4206/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
4207/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
4208/// `GH_REPO` the operator happens to have exported for an unrelated script
4209/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
4210/// below) to a different repository than the one actually on disk - which
4211/// for the same-repo guard in [`correct_manual_merge`] would mean the check
4212/// could be made to agree with whatever repository a forged `--merged` URL
4213/// claims, defeating it entirely.
4214pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4215    let out = tokio::process::Command::new("gh")
4216        .args(args)
4217        .current_dir(cwd)
4218        .env_remove("GH_REPO")
4219        .quiet()
4220        .stdin(std::process::Stdio::null())
4221        .output()
4222        .await
4223        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4224    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4225    let err = String::from_utf8_lossy(&out.stderr);
4226    if body.trim().is_empty() {
4227        body = err.into_owned();
4228    } else if !err.trim().is_empty() {
4229        body.push_str(&err);
4230    }
4231    Ok((out.status.success(), body.trim().to_owned()))
4232}
4233
4234/// Verdict of one entry in the status rollup.
4235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4236pub(crate) enum Verdict {
4237    Pass,
4238    Fail,
4239    Pending,
4240    Unknown,
4241}
4242
4243#[derive(Debug, Deserialize)]
4244#[serde(rename_all = "camelCase")]
4245struct GhPr {
4246    #[serde(default)]
4247    url: String,
4248    #[serde(default)]
4249    number: u64,
4250    #[serde(default)]
4251    state: String,
4252    #[serde(default)]
4253    title: String,
4254    #[serde(default)]
4255    status_check_rollup: Vec<GhCheck>,
4256    /// GitHub's own verdict on whether the pull request can be merged.
4257    ///
4258    /// Worth asking for because it is the only place the *required* check set
4259    /// is applied: the rollup lists every check equally, so a repository that
4260    /// deliberately does not require `coverage` still looks red here. See
4261    /// [`Blocking`].
4262    #[serde(default)]
4263    merge_state_status: String,
4264    /// The commit the pull request currently points at. Compared with the
4265    /// commit a fix round pushed, it is how the loop knows the forge has moved
4266    /// on and the rollup belongs to the new head.
4267    #[serde(default)]
4268    head_ref_oid: String,
4269    #[serde(default)]
4270    base_ref_name: String,
4271    #[serde(default)]
4272    reviews: Vec<GhReview>,
4273    #[serde(default)]
4274    comments: Vec<GhComment>,
4275}
4276
4277/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
4278/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
4279/// status API, which is how CodeRabbit reports - has `context`/`state` and no
4280/// conclusion at all.
4281#[derive(Debug, Deserialize)]
4282#[serde(rename_all = "camelCase")]
4283struct GhCheck {
4284    #[serde(default)]
4285    name: Option<String>,
4286    #[serde(default)]
4287    context: Option<String>,
4288    #[serde(default)]
4289    status: Option<String>,
4290    #[serde(default)]
4291    conclusion: Option<String>,
4292    #[serde(default)]
4293    state: Option<String>,
4294    #[serde(default)]
4295    details_url: Option<String>,
4296    #[serde(default)]
4297    target_url: Option<String>,
4298    /// Whether the base branch requires this check. Only the GraphQL node
4299    /// carries it; `None` is "not read", never "not required".
4300    #[serde(default)]
4301    is_required: Option<bool>,
4302}
4303
4304impl GhCheck {
4305    /// Name to show a human and hand to the fixer.
4306    fn label(&self) -> String {
4307        self.name
4308            .clone()
4309            .or_else(|| self.context.clone())
4310            .unwrap_or_else(|| "(unnamed check)".to_owned())
4311    }
4312
4313    /// Where this check's logs live, when it has any.
4314    fn url(&self) -> Option<&str> {
4315        self.details_url
4316            .as_deref()
4317            .or(self.target_url.as_deref())
4318            .filter(|u| !u.is_empty())
4319    }
4320
4321    /// Did it pass?
4322    ///
4323    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
4324    /// skips release and bot pull requests by design, and a skip that blocked
4325    /// landing would block exactly the pull requests that need no review.
4326    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
4327    /// merging over one is merging over a check that never ran.
4328    fn verdict(&self) -> Verdict {
4329        if let Some(status) = self.status.as_deref() {
4330            if !status.eq_ignore_ascii_case("COMPLETED") {
4331                return Verdict::Pending;
4332            }
4333        }
4334        let outcome = self
4335            .conclusion
4336            .as_deref()
4337            .or(self.state.as_deref())
4338            .unwrap_or("");
4339        match outcome.to_ascii_uppercase().as_str() {
4340            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4341            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4342            | "ACTION_REQUIRED" => Verdict::Fail,
4343            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4344                Verdict::Pending
4345            }
4346            _ => Verdict::Unknown,
4347        }
4348    }
4349}
4350
4351#[derive(Debug, Deserialize)]
4352struct GhAuthor {
4353    #[serde(default)]
4354    login: String,
4355}
4356
4357#[derive(Debug, Deserialize)]
4358struct GhReview {
4359    #[serde(default)]
4360    author: GhAuthor,
4361    #[serde(default)]
4362    body: String,
4363}
4364
4365#[derive(Debug, Deserialize)]
4366struct GhComment {
4367    #[serde(default)]
4368    author: GhAuthor,
4369    #[serde(default)]
4370    body: String,
4371}
4372
4373#[derive(Debug, Deserialize)]
4374struct GhUser {
4375    #[serde(default)]
4376    login: String,
4377}
4378
4379#[derive(Debug, Deserialize)]
4380struct GhInline {
4381    #[serde(default)]
4382    user: GhUser,
4383    #[serde(default)]
4384    path: Option<String>,
4385    #[serde(default)]
4386    line: Option<u64>,
4387    #[serde(default)]
4388    body: String,
4389}
4390
4391impl Default for GhAuthor {
4392    fn default() -> Self {
4393        Self {
4394            login: "(unknown)".to_owned(),
4395        }
4396    }
4397}
4398
4399impl Default for GhUser {
4400    fn default() -> Self {
4401        Self {
4402            login: "(unknown)".to_owned(),
4403        }
4404    }
4405}
4406
4407#[cfg(test)]
4408mod tests {
4409    use super::*;
4410    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4411
4412    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4413        format!(
4414            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4415        )
4416    }
4417
4418    #[test]
4419    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4420        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4421        assert_eq!(
4422            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4423            Ok(())
4424        );
4425        for (json, why) in [
4426            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4427            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4428            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4429            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4430            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4431            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4432        ] {
4433            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4434                .unwrap_err()
4435                .why;
4436            assert!(err.contains(why), "{json}: {err}");
4437        }
4438        // A head that moved on past what was verified is left alone.
4439        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4440        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4441        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4442        assert!(
4443            !closable(
4444                &head_json("x", "main", "OPEN", false),
4445                "magi/27b2/A",
4446                "main",
4447                &[]
4448            )
4449            .unwrap_err()
4450            .retry
4451        );
4452        assert!(is_forge_url("https://github.com/o/r.git"));
4453        assert!(is_forge_url("git@github.com:o/r.git"));
4454        assert!(!is_forge_url("/tmp/origin.git"));
4455        assert!(!is_forge_url("C:\\work\\origin.git"));
4456        assert!(!is_forge_url("file:///tmp/origin.git"));
4457        assert!(forge_unavailable(
4458            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4459        ));
4460        assert!(!forge_unavailable(
4461            "gh pr list failed: error connecting to api.github.com"
4462        ));
4463        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4464        // A record that does not say whether it is a fork is not trusted.
4465        assert!(
4466            closable(
4467                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4468                "b",
4469                "main",
4470                &["aaa".to_owned()]
4471            )
4472            .is_err()
4473        );
4474    }
4475
4476    #[test]
4477    fn the_close_comment_names_the_commit_on_the_base() {
4478        let e = crate::already::Evidence {
4479            proof: crate::already::Proof::PatchId,
4480            tip: "1234567890".to_owned(),
4481            commits: vec!["0e368de0000".to_owned()],
4482        };
4483        let c = superseded_comment("main", &e);
4484        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4485    }
4486
4487    /// 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.
4488    const GREEN_OPEN: &str = r####"{
4489  "url": "https://github.com/yukimemi/magi/pull/10",
4490  "number": 10,
4491  "state": "OPEN",
4492  "mergeStateStatus": "CLEAN",
4493  "statusCheckRollup": [
4494    {
4495      "__typename": "CheckRun",
4496      "conclusion": "SKIPPED",
4497      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4498      "name": "review",
4499      "status": "COMPLETED",
4500      "workflowName": "claude-review"
4501    },
4502    {
4503      "__typename": "CheckRun",
4504      "conclusion": "SUCCESS",
4505      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4506      "name": "check (ubuntu-latest)",
4507      "status": "COMPLETED",
4508      "workflowName": "CI"
4509    },
4510    {
4511      "__typename": "CheckRun",
4512      "conclusion": "SUCCESS",
4513      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4514      "name": "rustfmt",
4515      "status": "COMPLETED",
4516      "workflowName": "CI"
4517    },
4518    {
4519      "__typename": "StatusContext",
4520      "context": "CodeRabbit",
4521      "state": "SUCCESS",
4522      "targetUrl": ""
4523    }
4524  ],
4525  "reviews": [],
4526  "comments": [
4527    {
4528      "author": {
4529        "login": "coderabbitai"
4530      },
4531      "authorAssociation": "NONE",
4532      "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"
4533    }
4534  ]
4535}"####;
4536
4537    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
4538    const RED_OPEN: &str = r####"{
4539  "url": "https://github.com/yukimemi/magi/pull/9",
4540  "number": 9,
4541  "state": "OPEN",
4542  "mergeStateStatus": "UNSTABLE",
4543  "statusCheckRollup": [
4544    {
4545      "__typename": "CheckRun",
4546      "conclusion": "SUCCESS",
4547      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4548      "name": "check (ubuntu-latest)",
4549      "status": "COMPLETED",
4550      "workflowName": "CI"
4551    },
4552    {
4553      "__typename": "CheckRun",
4554      "conclusion": "SUCCESS",
4555      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4556      "name": "rustfmt",
4557      "status": "COMPLETED",
4558      "workflowName": "CI"
4559    },
4560    {
4561      "__typename": "CheckRun",
4562      "conclusion": "FAILURE",
4563      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4564      "name": "editorconfig",
4565      "status": "COMPLETED",
4566      "workflowName": "CI"
4567    },
4568    {
4569      "__typename": "StatusContext",
4570      "context": "CodeRabbit",
4571      "state": "SUCCESS",
4572      "targetUrl": ""
4573    }
4574  ],
4575  "reviews": [],
4576  "comments": [
4577    {
4578      "author": {
4579        "login": "coderabbitai"
4580      },
4581      "authorAssociation": "NONE",
4582      "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"
4583    }
4584  ]
4585}"####;
4586
4587    /// 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.
4588    const PENDING_OPEN: &str = r####"{
4589  "url": "https://github.com/yukimemi/magi/pull/9",
4590  "number": 9,
4591  "state": "OPEN",
4592  "statusCheckRollup": [
4593    {
4594      "__typename": "CheckRun",
4595      "conclusion": "SUCCESS",
4596      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4597      "name": "check (ubuntu-latest)",
4598      "status": "COMPLETED",
4599      "workflowName": "CI"
4600    },
4601    {
4602      "__typename": "CheckRun",
4603      "conclusion": "SUCCESS",
4604      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4605      "name": "rustfmt",
4606      "status": "COMPLETED",
4607      "workflowName": "CI"
4608    },
4609    {
4610      "__typename": "CheckRun",
4611      "conclusion": null,
4612      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4613      "name": "editorconfig",
4614      "status": "IN_PROGRESS",
4615      "workflowName": "CI"
4616    },
4617    {
4618      "__typename": "StatusContext",
4619      "context": "CodeRabbit",
4620      "state": "SUCCESS",
4621      "targetUrl": ""
4622    }
4623  ],
4624  "reviews": [],
4625  "comments": []
4626}"####;
4627
4628    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
4629    const MERGED: &str = r####"{
4630  "url": "https://github.com/yukimemi/magi/pull/16",
4631  "number": 16,
4632  "state": "MERGED",
4633  "statusCheckRollup": [
4634    {
4635      "__typename": "CheckRun",
4636      "conclusion": "SUCCESS",
4637      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4638      "name": "check (ubuntu-latest)",
4639      "status": "COMPLETED",
4640      "workflowName": "CI"
4641    },
4642    {
4643      "__typename": "CheckRun",
4644      "conclusion": "SUCCESS",
4645      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4646      "name": "review",
4647      "status": "COMPLETED",
4648      "workflowName": "claude-review"
4649    }
4650  ],
4651  "reviews": [],
4652  "comments": []
4653}"####;
4654
4655    /// 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.
4656    const REVIEWED_OPEN: &str = r####"{
4657  "url": "https://github.com/yukimemi/magi/pull/12",
4658  "number": 12,
4659  "state": "OPEN",
4660  "statusCheckRollup": [
4661    {
4662      "__typename": "CheckRun",
4663      "conclusion": "SUCCESS",
4664      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4665      "name": "check (ubuntu-latest)",
4666      "status": "COMPLETED",
4667      "workflowName": "CI"
4668    },
4669    {
4670      "__typename": "CheckRun",
4671      "conclusion": "SUCCESS",
4672      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4673      "name": "review",
4674      "status": "COMPLETED",
4675      "workflowName": "claude-review"
4676    }
4677  ],
4678  "reviews": [
4679    {
4680      "author": {
4681        "login": "claude"
4682      },
4683      "state": "COMMENTED",
4684      "body": ""
4685    }
4686  ],
4687  "comments": [
4688    {
4689      "author": {
4690        "login": "coderabbitai"
4691      },
4692      "authorAssociation": "NONE",
4693      "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"
4694    },
4695    {
4696      "author": {
4697        "login": "claude"
4698      },
4699      "authorAssociation": "NONE",
4700      "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"
4701    }
4702  ]
4703}"####;
4704
4705    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
4706    const INLINE: &str = r####"[
4707  {
4708    "user": {
4709      "login": "claude[bot]"
4710    },
4711    "path": "src/graph.rs",
4712    "line": 231,
4713    "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"
4714  }
4715]"####;
4716
4717    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
4718    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4719<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4720
4721> [!IMPORTANT]
4722> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4723> 
4724> This repository does not receive automatic reviews because it has fewer than 10 stars.
4725> 
4726> <details>
4727> <summary>⚙️ Run configuration</summary>
4728> 
4729> **Configuration used**: defaults
4730> 
4731> **Review profile**: CHILL
4732> 
4733> **Plan**: Team
4734> 
4735> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4736> 
4737> </details>
4738
4739<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4740
4741<!-- tips_start -->
4742
4743---
4744
4745Thanks 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.
4746
4747<details>
4748<summary>❤️ Share</summary>
4749
4750- [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"####;
4751
4752    /// 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.
4753    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
4754
4755---
4756### Reviewing PR #16
4757
4758- [x] Read AGENTS.md conventions
4759- [x] Review `src/daemon.rs` changes
4760- [x] Review `src/main.rs` changes (new `doctor` reporting)
4761- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
4762- [x] Check test coverage for new behavior
4763- [x] Run verification commands (blocked — see note)
4764- [x] Post findings"####;
4765
4766    /// The same job's real comment on pull request #12 once it had something to say.
4767    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
4768
4769---
4770### Review: `magi review <branch>` — cheap-half-only graph
4771
4772Read 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.
4773
4774**Correctness**
4775
4776- 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"####;
4777
4778    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
4779        PrState {
4780            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
4781            number: 16,
4782            state: PrLifecycle::Open,
4783            checks,
4784            // These tests are about red-means-fix, so a red here is one the
4785            // forge gates on. Without saying so they would assert the new
4786            // "merge past a check nobody requires" path by accident.
4787            blocking: if matches!(checks, Checks::Red) {
4788                Blocking::Yes
4789            } else {
4790                Blocking::No
4791            },
4792            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
4793            review_comments: (0..comments)
4794                .map(|i| ReviewComment {
4795                    author: "coderabbitai".to_owned(),
4796                    path: Some("src/graph.rs".to_owned()),
4797                    line: Some(231),
4798                    body: format!("finding {i}"),
4799                })
4800                .collect(),
4801        }
4802    }
4803
4804    #[test]
4805    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
4806        use CiExpectation::{Absent, Expected};
4807        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
4808            let p = pr(checks, &["x"], 0);
4809            for waited in [Duration::ZERO, CHECKS_GRACE] {
4810                assert_eq!(
4811                    decide_with(&p, 0, 4, waited, Expected),
4812                    decide(&p, 0, 4, waited)
4813                );
4814            }
4815        }
4816        // Nothing will ever report: no wait, no give-up, no fix round.
4817        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
4818            let p = pr(checks, &["x"], 0);
4819            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
4820            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
4821        }
4822        // A conflict is not a check: it still wants the rebase.
4823        let mut p = pr(Checks::Unknown, &[], 0);
4824        p.blocking = Blocking::Conflict;
4825        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
4826        // And a pull request that left our hands is still done.
4827        p.state = PrLifecycle::Merged;
4828        assert_eq!(
4829            decide_with(&p, 0, 4, Duration::ZERO, Absent),
4830            Step::Done { merged: true }
4831        );
4832    }
4833
4834    #[test]
4835    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
4836        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
4837        assert_eq!(state.number, 10);
4838        assert_eq!(state.state, PrLifecycle::Open);
4839        assert_eq!(state.checks, Checks::Green);
4840        assert!(state.failing.is_empty());
4841        assert!(
4842            state.review_comments.is_empty(),
4843            "the only comment is CodeRabbit's trigger notice: {:?}",
4844            state.review_comments
4845        );
4846        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
4847    }
4848
4849    #[test]
4850    fn a_failing_check_parses_as_red_and_is_named() {
4851        let state = parse_pr(RED_OPEN).expect("red fixture parses");
4852        assert_eq!(state.checks, Checks::Red);
4853        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
4854        // The captured payload says `UNSTABLE` - mergeable, with a check
4855        // nobody requires red - which is exactly the shape that had to be
4856        // merged by hand. Asserted separately, in
4857        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
4858        // test is about is that a red check is *named*, so the reason a fixer
4859        // is handed says which one; so it asks the blocking question here.
4860        let mut blocking = state.clone();
4861        blocking.blocking = Blocking::Yes;
4862        match decide(&blocking, 0, 4, Duration::ZERO) {
4863            Step::Fix { reason } => {
4864                assert!(reason.contains("editorconfig"), "reason: {reason}");
4865                assert!(reason.contains("failing"), "reason: {reason}");
4866            }
4867            other => panic!("expected a fix round, got {other:?}"),
4868        }
4869    }
4870
4871    #[test]
4872    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
4873        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
4874        assert_eq!(state.checks, Checks::Pending);
4875        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
4876    }
4877
4878    #[test]
4879    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
4880        let state = parse_pr(MERGED).expect("merged fixture parses");
4881        assert_eq!(state.state, PrLifecycle::Merged);
4882        assert_eq!(
4883            decide(&state, 0, 4, Duration::ZERO),
4884            Step::Done { merged: true }
4885        );
4886    }
4887
4888    #[test]
4889    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
4890        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
4891        assert_eq!(state.checks, Checks::Green);
4892        let authors: Vec<&str> = state
4893            .review_comments
4894            .iter()
4895            .map(|c| c.author.as_str())
4896            .collect();
4897        assert_eq!(
4898            authors,
4899            vec!["claude"],
4900            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
4901        );
4902        match decide(&state, 0, 4, Duration::ZERO) {
4903            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
4904            other => panic!("expected a fix round, got {other:?}"),
4905        }
4906    }
4907
4908    #[test]
4909    fn inline_review_comments_keep_their_file_and_line() {
4910        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
4911        assert_eq!(comments.len(), 1);
4912        assert_eq!(comments[0].author, "claude[bot]");
4913        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
4914        assert_eq!(comments[0].line, Some(231));
4915        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
4916    }
4917
4918    #[test]
4919    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
4920        assert!(
4921            is_noise(CODERABBIT_TRIGGER),
4922            "CodeRabbit's trigger notice declares itself not a review"
4923        );
4924        assert!(
4925            is_noise(CLAUDE_CHECKLIST),
4926            "a progress checklist asks for nothing"
4927        );
4928        assert!(
4929            !is_noise(CLAUDE_FINDING),
4930            "a review that names a bug is input, not noise"
4931        );
4932
4933        let mut clean = pr(Checks::Green, &[], 0);
4934        clean.review_comments.push(ReviewComment {
4935            author: "coderabbitai".to_owned(),
4936            path: None,
4937            line: None,
4938            body: CODERABBIT_TRIGGER.to_owned(),
4939        });
4940        clean.review_comments.retain(|c| !is_noise(&c.body));
4941        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
4942
4943        let mut found = pr(Checks::Green, &[], 0);
4944        found.review_comments.push(ReviewComment {
4945            author: "claude".to_owned(),
4946            path: None,
4947            line: None,
4948            body: CLAUDE_FINDING.to_owned(),
4949        });
4950        found.review_comments.retain(|c| !is_noise(&c.body));
4951        assert!(matches!(
4952            decide(&found, 0, 4, Duration::ZERO),
4953            Step::Fix { .. }
4954        ));
4955    }
4956
4957    #[test]
4958    fn the_policy_table_holds_for_every_combination_that_matters() {
4959        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
4960            (
4961                "pending checks are waited for, even on the last round",
4962                pr(Checks::Pending, &[], 0),
4963                4,
4964                4,
4965                Duration::ZERO,
4966                Step::Wait,
4967            ),
4968            (
4969                "red checks are fixed",
4970                pr(Checks::Red, &["editorconfig"], 0),
4971                0,
4972                4,
4973                Duration::ZERO,
4974                Step::Fix {
4975                    reason: "1 check(s) failing: editorconfig".to_owned(),
4976                },
4977            ),
4978            (
4979                "green with comments is fixed, not merged",
4980                pr(Checks::Green, &[], 2),
4981                1,
4982                4,
4983                Duration::ZERO,
4984                Step::Fix {
4985                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
4986                        .to_owned(),
4987                },
4988            ),
4989            (
4990                "green and clean merges",
4991                pr(Checks::Green, &[], 0),
4992                3,
4993                4,
4994                Duration::ZERO,
4995                Step::Merge,
4996            ),
4997            (
4998                "an unreadable rollup is waited on while the grace lasts",
4999                pr(Checks::Unknown, &[], 0),
5000                0,
5001                4,
5002                Duration::ZERO,
5003                Step::Wait,
5004            ),
5005            (
5006                "an unreadable rollup is never merged once the grace is spent",
5007                pr(Checks::Unknown, &[], 0),
5008                0,
5009                4,
5010                CHECKS_GRACE,
5011                Step::GiveUp {
5012                    reason: "no check status is readable on the pull request after 3 minute(s); \
5013                             refusing to merge on a guess"
5014                        .to_owned(),
5015                },
5016            ),
5017        ];
5018        for (what, state, round, budget, waited, want) in cases {
5019            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5020        }
5021    }
5022
5023    #[test]
5024    fn the_forge_verdict_survives_the_round_trip_from_gh() {
5025        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
5026        // requested but never parsed is the kind of thing that looks wired up
5027        // and answers `Unsaid` forever.
5028        let green = parse_pr(GREEN_OPEN).expect("parse");
5029        assert_eq!(green.blocking, Blocking::No);
5030        let red = parse_pr(RED_OPEN).expect("parse");
5031        assert_eq!(
5032            red.blocking,
5033            Blocking::No,
5034            "`UNSTABLE` is mergeable: the red check is one nobody requires"
5035        );
5036        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5037        // A payload from an older `gh` has no such field at all.
5038        let quiet =
5039            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5040        assert_eq!(quiet.blocking, Blocking::Unsaid);
5041    }
5042
5043    #[test]
5044    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5045        // Pull request 37's only red check was `editorconfig`, failing
5046        // because the action could not fetch its own binary after
5047        // editorconfig-checker v4 renamed its release assets. The repository
5048        // does not require it. magi answered by asking a fixer to repair a
5049        // change that was fine, and the pull request had to be merged by hand.
5050        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5051        nonblocking.blocking = Blocking::No;
5052        assert_eq!(
5053            decide(&nonblocking, 0, 4, Duration::ZERO),
5054            Step::Merge,
5055            "the forge says nothing is in the way, so nothing is"
5056        );
5057
5058        // The same red, gated on: that is a fix round, as before.
5059        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5060        blocking.blocking = Blocking::Yes;
5061        assert!(matches!(
5062            decide(&blocking, 0, 4, Duration::ZERO),
5063            Step::Fix { .. }
5064        ));
5065
5066        // A review comment still outranks green-enough: a non-required red
5067        // must not become a way to merge past an unanswered reviewer.
5068        let mut commented = pr(Checks::Red, &["coverage"], 1);
5069        commented.blocking = Blocking::No;
5070        assert!(matches!(
5071            decide(&commented, 0, 4, Duration::ZERO),
5072            Step::Fix { .. }
5073        ));
5074
5075        // And silence from the forge is not consent.
5076        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5077        unsaid.blocking = Blocking::Unsaid;
5078        assert!(matches!(
5079            decide(&unsaid, 0, 4, Duration::ZERO),
5080            Step::Fix { .. }
5081        ));
5082    }
5083
5084    #[test]
5085    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5086        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5087        red.blocking = Blocking::No;
5088        assert_eq!(
5089            decide(&red, 0, 4, Duration::ZERO),
5090            Step::Merge,
5091            "announcing must not change the decision"
5092        );
5093        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5094        assert!(said.contains("yukimemi/magi"), "{said}");
5095        assert!(said.contains("#16"), "{said}");
5096        assert!(
5097            said.contains("https://github.com/yukimemi/magi/pull/16"),
5098            "{said}"
5099        );
5100        assert!(
5101            said.contains("test (windows-latest)") && said.contains("coverage"),
5102            "{said}"
5103        );
5104
5105        // `failing` can be left over on a green observation; only `checks` counts.
5106        let green = pr(Checks::Green, &["stale"], 0);
5107        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5108    }
5109
5110    #[test]
5111    fn the_repo_label_comes_from_the_pull_request_url() {
5112        let p = Path::new("/tmp/checkout");
5113        assert_eq!(
5114            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5115            "yukimemi/magi"
5116        );
5117        assert_eq!(repo_label(p, "not a url"), "checkout");
5118    }
5119
5120    #[test]
5121    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5122        // Pull requests 35 and 37 were both rebased by hand: a competition
5123        // that runs for two hours against a repository merging pull requests
5124        // all day conflicts on the way in, and that is arithmetic rather
5125        // than a defect in the change.
5126        let mut conflicted = pr(Checks::Green, &[], 0);
5127        conflicted.blocking = Blocking::Conflict;
5128        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5129
5130        // Decided before the checks, and even with the rounds spent: every
5131        // check on a branch that cannot land is an answer about a state that
5132        // cannot land, and a conflict is not the change's fault.
5133        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5134        red.blocking = Blocking::Conflict;
5135        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5136
5137        // The lifecycle still wins over everything, conflict included.
5138        let mut merged = pr(Checks::Red, &[], 0);
5139        merged.blocking = Blocking::Conflict;
5140        merged.state = PrLifecycle::Merged;
5141        assert_eq!(
5142            decide(&merged, 0, 4, Duration::ZERO),
5143            Step::Done { merged: true }
5144        );
5145    }
5146
5147    #[test]
5148    fn the_forge_verdict_is_read_off_merge_state_status() {
5149        // The spellings that mean "mergeable". `UNSTABLE` is the one that
5150        // matters: mergeable, with a non-required check red or still running.
5151        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5152            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5153            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5154        }
5155        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5156        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5157        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5158        // An older `gh`, or a token without the scope, says nothing - and
5159        // refusing to guess is the rule everywhere else in this module.
5160        for quiet in ["", "UNKNOWN"] {
5161            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5162            assert!(Blocking::of(quiet).stops_a_merge());
5163        }
5164    }
5165
5166    #[test]
5167    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5168        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5169        // The exact stderr from run ec12, in a jj-colocated repository.
5170        let jj = "could not determine current branch: failed to run git: not on any branch";
5171
5172        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5173            .expect("the forge says merged, so it merged");
5174        assert!(landed.ok);
5175        assert!(
5176            landed.detail.contains("but the pull request is merged"),
5177            "the record must not read as a clean success: {}",
5178            landed.detail
5179        );
5180        assert!(
5181            landed.detail.contains("not on any branch"),
5182            "and it must keep what the command actually said: {}",
5183            landed.detail
5184        );
5185
5186        // A pull request still open means the merge really failed.
5187        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5188        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5189        // And an unreadable answer is not evidence of success.
5190        assert!(merged_after_all(&argv, jj, None).is_none());
5191    }
5192
5193    #[test]
5194    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5195        let mut state = pr(Checks::Red, &["editorconfig"], 3);
5196        state.state = PrLifecycle::Closed;
5197        assert_eq!(
5198            decide(&state, 0, 4, Duration::ZERO),
5199            Step::Done { merged: false },
5200            "a human closing the pull request ends the loop, whatever CI says"
5201        );
5202    }
5203
5204    #[test]
5205    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5206        let red = decide(
5207            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5208            4,
5209            4,
5210            Duration::ZERO,
5211        );
5212        match red {
5213            Step::GiveUp { reason } => {
5214                assert!(reason.contains("editorconfig"), "reason: {reason}");
5215                assert!(reason.contains("test (macos)"), "reason: {reason}");
5216                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5217            }
5218            other => panic!("expected a give-up, got {other:?}"),
5219        }
5220
5221        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5222        match commented {
5223            Step::GiveUp { reason } => {
5224                assert!(reason.contains("unresolved"), "reason: {reason}");
5225                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5226            }
5227            other => panic!("expected a give-up, got {other:?}"),
5228        }
5229    }
5230
5231    #[test]
5232    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5233        let candidate_commit = "magi: candidate A (uncommitted work)";
5234        let subject = merge_subject(candidate_commit, "add retries to the uploader");
5235        let argv = merge_argv(16, &subject);
5236
5237        assert!(argv.contains(&"--squash".to_owned()));
5238        assert!(argv.contains(&"--delete-branch".to_owned()));
5239        assert!(argv.contains(&"--subject".to_owned()));
5240        assert_eq!(
5241            argv.last().map(String::as_str),
5242            Some("add retries to the uploader"),
5243            "the subject must not be the candidate commit message"
5244        );
5245        assert_ne!(subject, candidate_commit);
5246    }
5247
5248    #[test]
5249    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5250        assert_eq!(
5251            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5252            "feat: a queue, an unattended loop, and a phone UI"
5253        );
5254        assert_eq!(
5255            merge_subject("", "# port the retry logic\n\ndetails"),
5256            "port the retry logic",
5257            "an empty title falls back to the task's first line, heading marks stripped"
5258        );
5259    }
5260
5261    #[test]
5262    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5263        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5264        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5265        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5266        assert_eq!(job_of("https://coderabbit.ai/status"), None);
5267        assert_eq!(run_of(""), None);
5268    }
5269
5270    #[test]
5271    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5272        let mut out = Vec::new();
5273        push_if_outstanding(
5274            &mut out,
5275            ReviewComment {
5276                author: "yukimemi".to_owned(),
5277                path: None,
5278                line: None,
5279                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5280            },
5281        );
5282        assert!(out.is_empty());
5283    }
5284
5285    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
5286    /// falls back to the repository - which keeps these tests free of a
5287    /// worktree, a `git` invocation and a network.
5288    fn run_state() -> RunState {
5289        let mut state = RunState::new(
5290            std::path::PathBuf::from("/repo/magi"),
5291            "main".to_owned(),
5292            "abcdef1234".to_owned(),
5293            "add retries to the uploader".to_owned(),
5294            crate::config::Config::default(),
5295        );
5296        // Tests run in parallel against one persistent home and the ids
5297        // `RunState::new` draws from the clock can repeat, so two tests would
5298        // share a run's questions. The home also outlives the process, so the
5299        // counter alone would reuse an earlier run's ids.
5300        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5301        let nanos = std::time::SystemTime::now()
5302            .duration_since(std::time::UNIX_EPOCH)
5303            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5304        state.id = format!(
5305            "20261004-{nanos:06}-{:04x}",
5306            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5307        );
5308        state
5309    }
5310
5311    fn green_pr() -> PrState {
5312        PrState {
5313            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5314            number: 42,
5315            state: PrLifecycle::Open,
5316            checks: Checks::Green,
5317            // The forge sees nothing in the way unless a test says otherwise.
5318            blocking: Blocking::No,
5319            failing: Vec::new(),
5320            review_comments: vec![ReviewComment {
5321                author: "coderabbitai".to_owned(),
5322                path: Some("src/land.rs".to_owned()),
5323                line: Some(212),
5324                body: "this branch never checks the exit code".to_owned(),
5325            }],
5326        }
5327    }
5328
5329    #[test]
5330    fn github_facing_land_text_is_english_whatever_the_language() {
5331        let mut state = run_state();
5332        state.config.graph.language = "ja".to_owned();
5333        let comment = stop_comment(&state.id, "checks are still red");
5334        assert!(comment.is_ascii(), "{comment}");
5335        assert!(comment.starts_with(MARKER));
5336
5337        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5338        let ja_at = p.find("Write all prose in ja").unwrap();
5339        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5340        assert!(ja_at < rule_at, "{p}");
5341        assert!(p.contains("stays in Japanese"), "{p}");
5342
5343        state.config.graph.language = "en".to_owned();
5344        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5345        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5346        assert!(!p.contains("does not apply"), "{p}");
5347    }
5348
5349    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5350
5351    fn panel() -> String {
5352        approval_panel(
5353            &run_state(),
5354            &green_pr(),
5355            NUMSTAT,
5356            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5357            &[
5358                "land: ask before merging".to_owned(),
5359                "land: colour the diff".to_owned(),
5360            ],
5361            "feat: merge approval from the phone",
5362        )
5363    }
5364
5365    #[test]
5366    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5367        let html = panel();
5368        for needle in [
5369            "42",
5370            "main",
5371            "src/land.rs",
5372            "src/web.rs",
5373            "assets/logo.png",
5374            "feat: merge approval from the phone",
5375            "land: ask before merging",
5376            "land: colour the diff",
5377            "coderabbitai",
5378            "this branch never checks the exit code",
5379            "green",
5380        ] {
5381            assert!(html.contains(needle), "the panel must state `{needle}`");
5382        }
5383    }
5384
5385    /// A candidate whose label is `A` and has won, so [`RunState::winner`]
5386    /// resolves to it.
5387    fn winning_candidate(summary: &str) -> Candidate {
5388        Candidate {
5389            index: 0,
5390            label: 'A',
5391            agent: "opus".to_owned(),
5392            branch: "magi/x/A".to_owned(),
5393            worktree: PathBuf::from("/wt/A"),
5394            summary: summary.to_owned(),
5395            stat: String::new(),
5396            files: 1,
5397            commits: 1,
5398            empty: false,
5399            failed: None,
5400            verified_noop: None,
5401            duration_ms: 0,
5402            folded: false,
5403        }
5404    }
5405
5406    fn uncontested_tally() -> Tally {
5407        Tally {
5408            first_choice: BTreeMap::from([('A', 1)]),
5409            borda: BTreeMap::new(),
5410            winner: 'A',
5411            rankings: 1,
5412            unanimous_initial: true,
5413            deliberated: false,
5414            changed_votes: 0,
5415            unanimous_final: true,
5416            tie_break: None,
5417            judges: 1,
5418            present: 1,
5419            quorum: 1,
5420            met_quorum: true,
5421            uncontested: None,
5422        }
5423    }
5424
5425    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5426        ReviewRecord {
5427            attempts: 0,
5428            reviewer,
5429            agent: agent.to_owned(),
5430            summary: summary.to_owned(),
5431            findings: Vec::new(),
5432            vote: None,
5433            failed: None,
5434            duration_ms: 0,
5435        }
5436    }
5437
5438    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5439        let answered = reviews.len();
5440        ReviewRound {
5441            round,
5442            head: "abc1234".to_owned(),
5443            verified_head: None,
5444            verified_at: None,
5445            reviews,
5446            e2e: Vec::new(),
5447            verify_retried: false,
5448            e2e_deferred: false,
5449            e2e_defer_reason: None,
5450            fix: None,
5451            blocking: 0,
5452            answered,
5453            expected: answered,
5454            clean: true,
5455            progressed: false,
5456            vote_split: false,
5457            reconsideration: Vec::new(),
5458            verdict: None,
5459        }
5460    }
5461
5462    #[test]
5463    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5464        let en = panel();
5465        assert!(en.contains("Task"), "{en}");
5466        assert!(en.contains("add retries to the uploader"), "{en}");
5467
5468        let mut state = run_state();
5469        state.config.graph.language = "ja".to_owned();
5470        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5471        assert!(ja.contains("タスク"), "{ja}");
5472        assert!(
5473            ja.contains("add retries to the uploader"),
5474            "the task itself is not translated: {ja}"
5475        );
5476    }
5477
5478    #[test]
5479    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5480        // `run_state()` has no candidates, no tally and no reviews - exactly
5481        // the shape a run has before anything has judged or reviewed it, and
5482        // the panel must not print an empty box for either.
5483        let html = panel();
5484        assert!(!html.contains("What changed"), "{html}");
5485        assert!(!html.contains("Review verdict"), "{html}");
5486    }
5487
5488    #[test]
5489    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5490        let mut state = run_state();
5491        state.candidates = vec![winning_candidate("")];
5492        state.tally = Some(uncontested_tally());
5493        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5494        assert!(
5495            !html.contains("What changed"),
5496            "an empty summary must not render an empty box: {html}"
5497        );
5498    }
5499
5500    #[test]
5501    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5502        let mut state = run_state();
5503        state.candidates = vec![winning_candidate(
5504            "Added a retry loop around the uploader PUT call.",
5505        )];
5506        state.tally = Some(uncontested_tally());
5507        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5508        assert!(en.contains("What changed"), "{en}");
5509        assert!(
5510            en.contains("Added a retry loop around the uploader PUT call."),
5511            "{en}"
5512        );
5513
5514        state.config.graph.language = "ja".to_owned();
5515        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5516        assert!(ja.contains("変更内容"), "{ja}");
5517        assert!(
5518            ja.contains("Added a retry loop around the uploader PUT call."),
5519            "{ja}"
5520        );
5521    }
5522
5523    #[test]
5524    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5525        let mut state = run_state();
5526        state.reviews = vec![
5527            review_round(
5528                1,
5529                vec![review_record(1, "alpha", "found a race, sent back")],
5530            ),
5531            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5532        ];
5533        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5534        assert!(en.contains("Review verdict"), "{en}");
5535        assert!(en.contains("race is fixed, clean"), "{en}");
5536        assert!(
5537            !en.contains("found a race, sent back"),
5538            "only the round that actually cleared the merge should show: {en}"
5539        );
5540
5541        state.config.graph.language = "ja".to_owned();
5542        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5543        assert!(ja.contains("レビューの結論"), "{ja}");
5544        assert!(ja.contains("レビュアー"), "{ja}");
5545        assert!(ja.contains("race is fixed, clean"), "{ja}");
5546    }
5547
5548    /// The `incomplete_review = "warn"` policy (see
5549    /// `graph::Runner::review_loop`) can push a `clean` round to
5550    /// `state.reviews` while one seat's own record still has `failed: Some`
5551    /// and an empty `summary` - a seat that never answered, not one that
5552    /// answered with nothing to say.
5553    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5554        ReviewRecord {
5555            attempts: 0,
5556            reviewer,
5557            agent: agent.to_owned(),
5558            summary: String::new(),
5559            findings: Vec::new(),
5560            vote: None,
5561            failed: Some(reason.to_owned()),
5562            duration_ms: 0,
5563        }
5564    }
5565
5566    #[test]
5567    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5568        let mut state = run_state();
5569        state.reviews = vec![review_round(
5570            1,
5571            vec![
5572                review_record(1, "alpha", "clean, nothing to add"),
5573                unanswered_review_record(2, "beta", "timed out"),
5574            ],
5575        )];
5576        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5577        assert!(en.contains("clean, nothing to add"), "{en}");
5578        assert!(
5579            en.contains("produced no answer: timed out"),
5580            "a seat that never answered must say so, not render a blank box: {en}"
5581        );
5582        assert!(
5583            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5584            "no reviewer box may be left empty: {en}"
5585        );
5586
5587        state.config.graph.language = "ja".to_owned();
5588        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5589        assert!(ja.contains("回答なし: timed out"), "{ja}");
5590    }
5591
5592    #[test]
5593    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5594        let html = panel();
5595        assert!(!html.contains("<script"), "no script survives the csp");
5596        assert!(!html.contains("<form"), "form-action is 'none'");
5597        let pr = green_pr();
5598        assert_eq!(
5599            html.matches("http").count(),
5600            html.matches(pr.url.as_str()).count(),
5601            "the only http url in the panel is the pull request's own link"
5602        );
5603    }
5604
5605    #[test]
5606    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5607        let html = panel();
5608        assert!(
5609            html.contains(">+</span>"),
5610            "an added line carries a `+` in the gutter, not only a background"
5611        );
5612        assert!(
5613            html.contains(">-</span>"),
5614            "a removed line carries a `-` in the gutter, not only a background"
5615        );
5616        assert!(
5617            html.contains(">new line</span>"),
5618            "the marker is moved to the gutter, so the body is printed once without it"
5619        );
5620    }
5621
5622    #[test]
5623    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5624        let total = DIFF_MAX_LINES + 100;
5625        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5626        let html = approval_panel(
5627            &run_state(),
5628            &green_pr(),
5629            NUMSTAT,
5630            &diff,
5631            &[],
5632            "feat: something long",
5633        );
5634        assert!(
5635            html.contains(&format!("100 of {total} diff lines omitted")),
5636            "the note must say exactly how much was cut"
5637        );
5638        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5639        assert!(
5640            !html.contains(&format!("line {DIFF_MAX_LINES}")),
5641            "nothing past the threshold is rendered"
5642        );
5643        assert!(
5644            html.contains("/repo/magi"),
5645            "the note says where the rest is"
5646        );
5647    }
5648
5649    #[test]
5650    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5651        let html = approval_panel(
5652            &run_state(),
5653            &green_pr(),
5654            "1\t2\tsrc/<b>&\"x\"'.rs",
5655            "",
5656            &[],
5657            "subject",
5658        );
5659        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
5660        assert!(
5661            !html.contains("<b>"),
5662            "an agent-influenced path must never become markup"
5663        );
5664    }
5665
5666    #[tokio::test]
5667    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5668        let a = std::path::PathBuf::from("/repo/a");
5669        let b = std::path::PathBuf::from("/repo/b");
5670
5671        let held = repo_merge_lock(&a).lock_owned().await;
5672
5673        // A second, concurrent land run against the *same* repository must
5674        // wait - `try_lock` fails while `held` is alive.
5675        assert!(
5676            repo_merge_lock(&a).try_lock().is_err(),
5677            "a second merge into the same repository must not proceed concurrently"
5678        );
5679
5680        // A run against a *different* repository must not be blocked by it -
5681        // this is what keeps a slow rebase or `gh pr merge` in one
5682        // repository from also stalling a land-approval resume in another.
5683        assert!(
5684            repo_merge_lock(&b).try_lock().is_ok(),
5685            "a different repository's merge lock must be independent"
5686        );
5687
5688        drop(held);
5689        assert!(
5690            repo_merge_lock(&a).try_lock().is_ok(),
5691            "the lock is released once the holder is done"
5692        );
5693    }
5694
5695    #[test]
5696    fn only_the_merge_choice_merges_and_silence_holds() {
5697        let table = [
5698            (None, Approval::Hold),
5699            (Some("merge"), Approval::Merge),
5700            (Some(" merge\n"), Approval::Merge),
5701            (Some("hold"), Approval::Hold),
5702            (Some(""), Approval::Hold),
5703            (Some("yes"), Approval::Hold),
5704        ];
5705        for (answer, want) in table {
5706            assert_eq!(
5707                approval(answer),
5708                want,
5709                "answer {answer:?} must resolve to {want:?}"
5710            );
5711        }
5712    }
5713
5714    #[tokio::test]
5715    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5716        let mut state = landing_state();
5717        state.config.graph.land_approval = true;
5718        let pr = green_pr();
5719
5720        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5721            .await
5722            .unwrap();
5723        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5724        assert!(
5725            !state.parked,
5726            "approval_gate itself never sets `parked`; only its caller does"
5727        );
5728
5729        let store = ask::Questions::open();
5730        let filed: Vec<_> = store
5731            .list()
5732            .into_iter()
5733            .filter(|q| q.run == state.id)
5734            .collect();
5735        assert_eq!(filed.len(), 1, "exactly one question is filed");
5736        assert_eq!(filed[0].node, APPROVAL_NODE);
5737        assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
5738        assert!(filed[0].status.open());
5739
5740        // A second visit - standing in for a resumed run whose slot the
5741        // daemon handed to something else while nobody had answered - must
5742        // find the same question rather than filing a second one.
5743        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5744            .await
5745            .unwrap();
5746        assert_eq!(again, ApprovalGate::Pending);
5747        let still_one = store
5748            .list()
5749            .into_iter()
5750            .filter(|q| q.run == state.id)
5751            .count();
5752        assert_eq!(
5753            still_one, 1,
5754            "asking twice must not double-file the question"
5755        );
5756    }
5757
5758    #[tokio::test]
5759    async fn approving_the_existing_question_is_read_back_as_approved() {
5760        crate::run::pin_test_home();
5761        let mut state = run_state();
5762        state.config.graph.land_approval = true;
5763        let pr = green_pr();
5764        assert_eq!(
5765            approval_gate(&mut state, &pr, "feat: x", None, "abc")
5766                .await
5767                .unwrap(),
5768            ApprovalGate::Pending
5769        );
5770
5771        let store = ask::Questions::open();
5772        let mut q = store
5773            .list()
5774            .into_iter()
5775            .find(|q| q.run == state.id)
5776            .expect("filed above");
5777        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5778        store.put(&mut q).unwrap();
5779
5780        assert_eq!(
5781            approval_gate(&mut state, &pr, "feat: x", None, "abc")
5782                .await
5783                .unwrap(),
5784            ApprovalGate::Approved
5785        );
5786    }
5787
5788    #[tokio::test]
5789    async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
5790        crate::run::pin_test_home();
5791        let store = ask::Questions::open();
5792
5793        let mut held_state = run_state();
5794        held_state.config.graph.land_approval = true;
5795        let pr = green_pr();
5796        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5797            .await
5798            .unwrap();
5799        let mut q = store
5800            .list()
5801            .into_iter()
5802            .find(|q| q.run == held_state.id)
5803            .expect("filed above");
5804        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
5805        store.put(&mut q).unwrap();
5806        assert_eq!(
5807            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5808                .await
5809                .unwrap(),
5810            ApprovalGate::Held
5811        );
5812
5813        let mut abandoned_state = run_state();
5814        abandoned_state.config.graph.land_approval = true;
5815        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5816            .await
5817            .unwrap();
5818        let mut q = store
5819            .list()
5820            .into_iter()
5821            .find(|q| q.run == abandoned_state.id)
5822            .expect("filed above");
5823        q.abandon("no answer within the timeout");
5824        store.put(&mut q).unwrap();
5825        assert_eq!(
5826            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5827                .await
5828                .unwrap(),
5829            ApprovalGate::Held,
5830            "silence must never merge"
5831        );
5832    }
5833
5834    fn contested() -> ContestedHandoff {
5835        let finding = |id: &str, n: u32| crate::verdict::Finding {
5836            id: id.to_owned(),
5837            severity: crate::verdict::Severity::Major,
5838            file: Some("src/a.rs".to_owned()),
5839            line: Some(n),
5840            title: format!("problem {id}"),
5841            detail: String::new(),
5842        };
5843        ContestedHandoff {
5844            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
5845            rejecters: vec![(1, "alpha".to_owned())],
5846        }
5847    }
5848
5849    #[test]
5850    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
5851        let mut state = run_state();
5852        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
5853        state.contested_handoff = Some(contested());
5854        assert!(contested_to_ask(&state).is_some());
5855        state.config.graph.hold_contested_merge = false;
5856        assert!(
5857            contested_to_ask(&state).is_none(),
5858            "the switch restores today"
5859        );
5860    }
5861
5862    #[test]
5863    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
5864        let q = ask::Question::new(
5865            "run-1".to_owned(),
5866            APPROVAL_NODE.to_owned(),
5867            "land".to_owned(),
5868            "Merge?".to_owned(),
5869            String::new(),
5870            vec![APPROVE.to_owned(), HOLD.to_owned()],
5871        );
5872        let none = deputy_brief(&q, None);
5873        assert!(none.contains("could not be read"), "{none}");
5874        assert!(none.contains("Silence is a hold"), "{none}");
5875
5876        let mut state = run_state();
5877        state.pr = Some(crate::run::PrRecord {
5878            url: "https://example.test/pull/7".to_owned(),
5879            number: 7,
5880            state: "open".to_owned(),
5881            checks: "green".to_owned(),
5882            round: 0,
5883            rounds: 3,
5884            red_at_merge: Vec::new(),
5885        });
5886        state.contested_handoff = Some(contested());
5887        let b = deputy_brief(&q, Some(&state));
5888        assert!(b.contains("https://example.test/pull/7"), "{b}");
5889        assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
5890        assert!(b.contains("#1"), "the rejecting seat: {b}");
5891        state.contested_handoff = None;
5892        assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
5893    }
5894
5895    #[test]
5896    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
5897        for lang in ["en", "ja"] {
5898            let mut cfg = crate::config::Config::default();
5899            cfg.graph.language = lang.to_owned();
5900            let w = words(&cfg.graph.language);
5901            let text = w.approval_detail(
5902                "https://github.com/yukimemi/magi/pull/42",
5903                "main",
5904                "feat: x",
5905                Some(&contested()),
5906            );
5907            assert!(text.contains("pull/42"), "{text}");
5908            assert!(
5909                text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
5910                "{text}"
5911            );
5912            assert!(text.contains("R3-1-5"), "{text}");
5913            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
5914            assert!(text.contains("2"), "the rest are counted: {text}");
5915            assert!(text.contains("#1 (alpha)"), "{text}");
5916        }
5917        let plain = words("en").approval_detail("u", "main", "s", None);
5918        assert!(!plain.contains("reject"), "{plain}");
5919    }
5920
5921    #[tokio::test]
5922    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
5923        crate::run::pin_test_home();
5924        let mut state = run_state();
5925        state.config.graph.land_approval = false;
5926        state.contested_handoff = Some(contested());
5927        let pr = green_pr();
5928        let c = contested_to_ask(&state);
5929        assert_eq!(
5930            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5931                .await
5932                .unwrap(),
5933            ApprovalGate::Pending,
5934            "silence is a hold"
5935        );
5936        let store = ask::Questions::open();
5937        let filed: Vec<_> = store
5938            .list()
5939            .into_iter()
5940            .filter(|q| q.run == state.id)
5941            .collect();
5942        assert_eq!(filed.len(), 1);
5943        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
5944
5945        assert_eq!(
5946            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5947                .await
5948                .unwrap(),
5949            ApprovalGate::Pending
5950        );
5951        let mut q = store
5952            .list()
5953            .into_iter()
5954            .find(|q| q.run == state.id)
5955            .unwrap();
5956        assert_eq!(q.id, filed[0].id, "the same question after a resume");
5957        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5958        store.put(&mut q).unwrap();
5959        assert_eq!(
5960            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5961                .await
5962                .unwrap(),
5963            ApprovalGate::Approved
5964        );
5965    }
5966
5967    #[test]
5968    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
5969        let rows = parse_numstat(NUMSTAT);
5970        assert_eq!(
5971            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
5972            ["src/web.rs", "src/land.rs", "assets/logo.png"]
5973        );
5974        assert_eq!(rows[2].added, None, "a binary file has no line counts");
5975    }
5976    #[test]
5977    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
5978        // Reported from a real run: the merge question arrived in English on a
5979        // repository with `language = "ja"`. magi's own strings have to follow
5980        // that setting too - "it is a literal in Rust" is not an answer.
5981        let mut state = run_state();
5982        state.config.graph.language = "ja".to_owned();
5983        let pr = green_pr();
5984        let commits = ["c1".to_owned()];
5985
5986        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
5987        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
5988        assert!(ja.contains("squash されるコミット"), "{ja}");
5989        assert!(ja.contains("レビューコメント"), "{ja}");
5990        assert!(ja.contains("差分"), "{ja}");
5991        assert!(
5992            !ja.contains("Commits being squashed"),
5993            "no English left over"
5994        );
5995
5996        let w = words("ja");
5997        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
5998        assert!(
5999            w.approval_detail("http://x/1", "main", "feat: x", None)
6000                .contains("パネル")
6001        );
6002
6003        // The evidence itself is language-neutral and must survive either way.
6004        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6005        assert!(ja.contains("feat: x"), "nor is the merge subject");
6006
6007        // English stays the default, and a language magi cannot check falls
6008        // back to it rather than shipping a guess.
6009        state.config.graph.language = "en".to_owned();
6010        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6011        assert!(en.contains("Commits being squashed"), "{en}");
6012        assert_eq!(words("Klingon").html_lang, "en");
6013    }
6014
6015    /// A `gh pr list` result naming exactly one pull request whose base and
6016    /// merge time both fit the run is exactly the case
6017    /// [`find_external_merge`] exists to act on.
6018    #[test]
6019    fn pick_open_pr_classifies_by_count_and_base() {
6020        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6021        assert_eq!(
6022            pick_open_pr(one, "main").unwrap(),
6023            OpenPr::One {
6024                url: "https://x/pull/58".into(),
6025                title: "t".into()
6026            }
6027        );
6028        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6029        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6030        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6031                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6032        assert_eq!(
6033            pick_open_pr(two, "main").unwrap(),
6034            OpenPr::Many(vec!["u1".into(), "u2".into()])
6035        );
6036        assert!(pick_open_pr("not json", "main").is_err());
6037        // An incomplete record is an error, never "nothing open".
6038        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6039        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6040    }
6041
6042    #[test]
6043    fn pick_merged_pr_picks_the_unique_match() {
6044        let json = r#"[
6045            {"url": "https://github.com/o/r/pull/42", "number": 42,
6046             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6047        ]"#;
6048        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6049        let found = pick_merged_pr(json, "main", created_at)
6050            .expect("valid json")
6051            .expect("one unambiguous match");
6052        assert_eq!(found.url, "https://github.com/o/r/pull/42");
6053        assert_eq!(found.number, 42);
6054    }
6055
6056    /// Two candidates surviving the filter is exactly as uninformative as
6057    /// zero — a branch name can be reused across runs — so neither is
6058    /// preferred over the other and nothing is recorded automatically.
6059    #[test]
6060    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6061        let json = r#"[
6062            {"url": "https://github.com/o/r/pull/42", "number": 42,
6063             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6064            {"url": "https://github.com/o/r/pull/43", "number": 43,
6065             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6066        ]"#;
6067        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6068        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6069    }
6070
6071    /// A pull request that targets a different base branch cannot be this
6072    /// run's, whatever its head branch is named — a reused branch name from
6073    /// an unrelated task must not be recorded as this run's merge.
6074    #[test]
6075    fn pick_merged_pr_ignores_a_different_base_branch() {
6076        let json = r#"[
6077            {"url": "https://github.com/o/r/pull/42", "number": 42,
6078             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6079        ]"#;
6080        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6081        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6082    }
6083
6084    /// A pull request merged before this run was even created cannot be this
6085    /// run's winner, no matter how its head branch is spelled.
6086    #[test]
6087    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6088        let json = r#"[
6089            {"url": "https://github.com/o/r/pull/42", "number": 42,
6090             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6091        ]"#;
6092        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6093        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6094    }
6095
6096    #[test]
6097    fn slug_of_pr_url_reads_host_owner_and_repo() {
6098        assert_eq!(
6099            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6100            Some("github.com/yukimemi/shun")
6101        );
6102    }
6103
6104    #[test]
6105    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6106        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6107        assert_eq!(slug_of_pr_url("not a url at all"), None);
6108        assert_eq!(slug_of_pr_url("https://github.com"), None);
6109    }
6110
6111    #[test]
6112    fn slug_of_repo_url_reads_host_owner_and_repo() {
6113        assert_eq!(
6114            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6115            Some("github.com/yukimemi/magi")
6116        );
6117        assert_eq!(slug_of_repo_url("https://github.com"), None);
6118    }
6119
6120    #[test]
6121    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6122        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6123            .expect("same repo, different case");
6124    }
6125
6126    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
6127    /// own in-progress run and rewrote its status from a pull request in a
6128    /// completely different repository. This is the guard that must catch
6129    /// that even when an explicit (but wrong) id is given.
6130    #[test]
6131    fn ensure_same_repo_refuses_a_different_repo() {
6132        let err =
6133            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6134        let msg = format!("{err:#}");
6135        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6136        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6137    }
6138
6139    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
6140    /// instance mirroring a `github.com` repository's name, say) must not be
6141    /// treated as the same repository just because the trailing path
6142    /// matches.
6143    #[test]
6144    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6145        let err = ensure_same_repo(
6146            "github.com/yukimemi/magi",
6147            "github.example.com/yukimemi/magi",
6148        )
6149        .unwrap_err();
6150        let msg = format!("{err:#}");
6151        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6152        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6153    }
6154
6155    /// No winner decided yet means there is no branch to ask GitHub about at
6156    /// all — `find_external_merge` must return `None` without ever spawning
6157    /// `gh`, which this proves by never providing a real repository to spawn
6158    /// it in.
6159    #[tokio::test]
6160    async fn find_external_merge_returns_none_without_a_winner() {
6161        let state = RunState::new(
6162            PathBuf::from("/no/such/repo"),
6163            "main".to_owned(),
6164            "0000000000000000000000000000000000000000".to_owned(),
6165            "irrelevant".to_owned(),
6166            crate::config::Config::default(),
6167        );
6168        assert_eq!(find_external_merge(&state).await.unwrap(), None);
6169    }
6170
6171    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6172        let mut run = RunState::new(
6173            PathBuf::from(repo),
6174            "main".to_owned(),
6175            "abcdef1234".to_owned(),
6176            "x".to_owned(),
6177            crate::config::Config::default(),
6178        );
6179        run.id = id.to_owned();
6180        run.status = status;
6181        run.pr = Some(crate::run::PrRecord {
6182            number: url.rsplit('/').next().unwrap().parse().unwrap(),
6183            url: url.to_owned(),
6184            state: state.to_owned(),
6185            checks: "red".to_owned(),
6186            round: 0,
6187            rounds: 2,
6188            red_at_merge: Vec::new(),
6189        });
6190        run.save_under(home).unwrap();
6191    }
6192
6193    fn recorded(home: &Path, id: &str) -> String {
6194        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6195        serde_json::from_str::<RunState>(&body)
6196            .unwrap()
6197            .pr
6198            .unwrap()
6199            .state
6200    }
6201
6202    const PR: &str = "https://github.com/o/r/pull/7";
6203
6204    #[test]
6205    fn write_through_updates_predecessors_and_siblings_only() {
6206        let tmp = tempfile::tempdir().unwrap();
6207        let h = tmp.path();
6208        pr_run(
6209            h,
6210            "20261004-100000-aaaa",
6211            "/repo/r",
6212            RunStatus::Superseded,
6213            PR,
6214            "open",
6215        );
6216        pr_run(
6217            h,
6218            "20261004-100100-bbbb",
6219            "/repo/r",
6220            RunStatus::Blocked,
6221            PR,
6222            "open",
6223        );
6224        // Not terminal: a driver may be writing it.
6225        pr_run(
6226            h,
6227            "20261004-100200-cccc",
6228            "/repo/r",
6229            RunStatus::Landing,
6230            PR,
6231            "open",
6232        );
6233        // Another repository's pull request with the same number.
6234        pr_run(
6235            h,
6236            "20261004-100300-dddd",
6237            "/repo/other",
6238            RunStatus::Blocked,
6239            "https://github.com/o/other/pull/7",
6240            "open",
6241        );
6242        // A different pull request of the same repository.
6243        pr_run(
6244            h,
6245            "20261004-100400-eeee",
6246            "/repo/r",
6247            RunStatus::Blocked,
6248            "https://github.com/o/r/pull/8",
6249            "open",
6250        );
6251        pr_run(
6252            h,
6253            "20261004-100500-ffff",
6254            "/repo/r",
6255            RunStatus::Merged,
6256            PR,
6257            "open",
6258        );
6259        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6260
6261        assert_eq!(
6262            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6263            2
6264        );
6265        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6266        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6267        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6268        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6269        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6270        // The source's own record is the caller's to write.
6271        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6272        // Idempotent.
6273        assert_eq!(
6274            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6275            0
6276        );
6277        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6278        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6279    }
6280
6281    #[test]
6282    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6283        let tmp = tempfile::tempdir().unwrap();
6284        let h = tmp.path();
6285        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6286        pr_run(
6287            h,
6288            "20261004-100000-aaaa",
6289            "/repo/r",
6290            RunStatus::Superseded,
6291            &url(1),
6292            "open",
6293        );
6294        pr_run(
6295            h,
6296            "20261004-100100-bbbb",
6297            "/repo/r",
6298            RunStatus::Blocked,
6299            &url(2),
6300            "open",
6301        );
6302        pr_run(
6303            h,
6304            "20261004-100200-cccc",
6305            "/repo/r",
6306            RunStatus::Ready,
6307            &url(3),
6308            "open",
6309        );
6310        pr_run(
6311            h,
6312            "20261004-100300-dddd",
6313            "/repo/r",
6314            RunStatus::Ready,
6315            &url(4),
6316            "open",
6317        );
6318        pr_run(
6319            h,
6320            "20261004-100400-eeee",
6321            "/repo/r",
6322            RunStatus::Implementing,
6323            &url(1),
6324            "open",
6325        );
6326        assert_eq!(stale_open_prs(h).len(), 4);
6327
6328        let mut known = BTreeMap::new();
6329        known.insert(url(1), PrLifecycle::Merged);
6330        known.insert(url(2), PrLifecycle::Closed);
6331        known.insert(url(3), PrLifecycle::Open);
6332        // #4: the forge could not be read, so it has no answer.
6333        assert_eq!(apply_pr_states(h, &known), 2);
6334        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6335        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6336        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6337        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6338        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6339        assert_eq!(apply_pr_states(h, &known), 0);
6340    }
6341
6342    // --- the land loop against a scripted forge -------------------------
6343
6344    use std::collections::VecDeque;
6345    use std::sync::Mutex;
6346
6347    /// Answers views from a script (the last one repeats), merges from a
6348    /// queue, and records every call so a test can assert the order.
6349    struct Scripted {
6350        views: Mutex<VecDeque<Seen>>,
6351        merges: Mutex<VecDeque<(bool, String)>>,
6352        fix: Mutex<Option<Fixed>>,
6353        log: Mutex<Vec<&'static str>>,
6354        argvs: Mutex<Vec<Vec<String>>>,
6355        required: Mutex<Option<BTreeSet<String>>>,
6356        /// Set once a merge answered ok: the forge then reports `merged`,
6357        /// unless `queued` says the merge only entered a queue.
6358        merged: Mutex<bool>,
6359        queued: Mutex<bool>,
6360        /// Views fail once a merge answered ok.
6361        unreadable_after_merge: Mutex<bool>,
6362    }
6363
6364    impl Scripted {
6365        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6366            Self {
6367                views: Mutex::new(views.into()),
6368                merges: Mutex::new(
6369                    merges
6370                        .into_iter()
6371                        .map(|(ok, m)| (ok, m.to_owned()))
6372                        .collect(),
6373                ),
6374                fix: Mutex::new(None),
6375                log: Mutex::new(Vec::new()),
6376                argvs: Mutex::new(Vec::new()),
6377                required: Mutex::new(None),
6378                merged: Mutex::new(false),
6379                queued: Mutex::new(false),
6380                unreadable_after_merge: Mutex::new(false),
6381            }
6382        }
6383        fn argvs(&self) -> Vec<Vec<String>> {
6384            self.argvs.lock().unwrap().clone()
6385        }
6386        fn calls(&self) -> Vec<&'static str> {
6387            self.log.lock().unwrap().clone()
6388        }
6389    }
6390
6391    impl Forge for Scripted {
6392        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6393            self.log.lock().unwrap().push("view");
6394            if *self.unreadable_after_merge.lock().unwrap()
6395                && !self.argvs.lock().unwrap().is_empty()
6396            {
6397                anyhow::bail!("forge unreachable");
6398            }
6399            let mut v = self.views.lock().unwrap();
6400            let mut seen = if v.len() > 1 {
6401                v.pop_front().unwrap()
6402            } else {
6403                v[0].clone()
6404            };
6405            if *self.merged.lock().unwrap() {
6406                seen.pr.state = PrLifecycle::Merged;
6407            }
6408            Ok(seen)
6409        }
6410        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6411            self.log.lock().unwrap().push("merge");
6412            self.argvs.lock().unwrap().push(argv.to_vec());
6413            let out = self
6414                .merges
6415                .lock()
6416                .unwrap()
6417                .pop_front()
6418                .expect("unscripted merge");
6419            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6420            {
6421                *self.merged.lock().unwrap() = true;
6422            }
6423            Ok(out)
6424        }
6425        async fn poll(&self) {
6426            self.log.lock().unwrap().push("poll");
6427        }
6428        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6429            self.required.lock().unwrap().clone()
6430        }
6431        async fn fix(
6432            &self,
6433            _state: &mut RunState,
6434            _pr: &PrState,
6435            _round: usize,
6436            _budget: usize,
6437            _reason: &str,
6438            _logs: &str,
6439        ) -> Result<Fixed> {
6440            self.log.lock().unwrap().push("fix");
6441            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6442        }
6443    }
6444
6445    const REFUSED: &str =
6446        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6447
6448    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6449        let mut pr = green_pr();
6450        pr.checks = checks;
6451        pr.blocking = Blocking::of(merge_state);
6452        if !comments {
6453            pr.review_comments.clear();
6454        }
6455        Seen {
6456            pr,
6457            title: "feat: x".to_owned(),
6458            failing_urls: Vec::new(),
6459            head: head.to_owned(),
6460            rollup_head: head.to_owned(),
6461            merge_state: merge_state.to_owned(),
6462            contexts: Vec::new(),
6463            base: "main".to_owned(),
6464        }
6465    }
6466
6467    fn landing_state() -> RunState {
6468        crate::run::pin_test_home();
6469        let mut state = run_state();
6470        state.config.graph.land_approval = false;
6471        state
6472    }
6473
6474    #[test]
6475    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6476        assert!(!awaiting_new_head(None, "aaa"));
6477        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6478        assert!(awaiting_new_head(Some("abc123"), "def456"));
6479        assert!(awaiting_new_head(Some("abc123"), ""));
6480    }
6481
6482    #[test]
6483    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6484        let open = |c, m: &str| seen("a", c, m, false);
6485        let table = [
6486            (None, false, Refused::Pending),
6487            (
6488                Some(open(Checks::Pending, "BLOCKED")),
6489                false,
6490                Refused::Pending,
6491            ),
6492            (
6493                Some(open(Checks::Unknown, "BLOCKED")),
6494                false,
6495                Refused::Pending,
6496            ),
6497            (
6498                Some(open(Checks::Green, "UNKNOWN")),
6499                false,
6500                Refused::Pending,
6501            ),
6502            (Some(open(Checks::Green, "")), false, Refused::Pending),
6503            (
6504                Some(open(Checks::Green, "BLOCKED")),
6505                false,
6506                Refused::Recheck,
6507            ),
6508            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6509        ];
6510        for (after, rechecked, want) in table {
6511            assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6512        }
6513        let mut closed = open(Checks::Green, "CLEAN");
6514        closed.pr.state = PrLifecycle::Closed;
6515        assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6516    }
6517
6518    #[tokio::test]
6519    async fn a_normal_landing_merges_on_the_first_look() {
6520        let mut state = landing_state();
6521        let forge = Scripted::new(
6522            vec![seen("a", Checks::Green, "CLEAN", false)],
6523            vec![(true, "")],
6524        );
6525        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6526            .await
6527            .unwrap();
6528        // One fresh read, then the head-bound merge.
6529        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6530        assert_eq!(state.status, RunStatus::Merged);
6531    }
6532
6533    #[tokio::test]
6534    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6535        let mut state = landing_state();
6536        let forge = Scripted::new(
6537            vec![seen("a", Checks::Green, "CLEAN", false)],
6538            std::iter::repeat_n((true, ""), 100).collect(),
6539        );
6540        *forge.queued.lock().unwrap() = true;
6541        let task = async {
6542            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6543                .await
6544                .unwrap();
6545        };
6546        // Still open after the command succeeded: it keeps watching and
6547        // never records a merge (it stops at the wait ceiling instead).
6548        task.await;
6549        assert_ne!(state.status, RunStatus::Merged);
6550    }
6551
6552    #[tokio::test]
6553    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6554        let mut state = landing_state();
6555        let forge = Scripted::new(
6556            vec![seen("a", Checks::Green, "CLEAN", false)],
6557            std::iter::repeat_n((true, ""), 100).collect(),
6558        );
6559        *forge.unreadable_after_merge.lock().unwrap() = true;
6560        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6561            .await
6562            .ok();
6563        assert_ne!(state.status, RunStatus::Merged);
6564    }
6565
6566    #[tokio::test]
6567    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6568        let mut state = landing_state();
6569        let forge = Scripted::new(
6570            vec![
6571                seen("old", Checks::Green, "CLEAN", true),
6572                // The forge has not moved to the new head yet: still green.
6573                seen("old", Checks::Green, "CLEAN", true),
6574                seen("new", Checks::Pending, "BLOCKED", true),
6575                seen("new", Checks::Green, "CLEAN", true),
6576            ],
6577            vec![(true, "")],
6578        );
6579        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6580            head: "NEW".to_owned(),
6581        });
6582        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6583            .await
6584            .unwrap();
6585        assert_eq!(
6586            forge.calls(),
6587            [
6588                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
6589                "view"
6590            ]
6591        );
6592        assert_eq!(state.status, RunStatus::Merged);
6593    }
6594
6595    #[tokio::test]
6596    async fn a_head_that_never_arrives_stops_naming_both_commits() {
6597        let mut state = landing_state();
6598        let forge = Scripted::new(
6599            vec![
6600                seen("old", Checks::Green, "CLEAN", true),
6601                seen("someone-elses", Checks::Green, "CLEAN", true),
6602            ],
6603            vec![],
6604        );
6605        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6606            head: "mine".to_owned(),
6607        });
6608        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6609            .await
6610            .unwrap();
6611        assert!(!forge.calls().contains(&"merge"));
6612        let why = state.merge.as_ref().unwrap().detail.clone();
6613        assert!(
6614            why.contains("mine") && why.contains("someone-elses"),
6615            "{why}"
6616        );
6617        assert_eq!(state.status, RunStatus::Blocked);
6618    }
6619
6620    #[tokio::test]
6621    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6622        let mut state = landing_state();
6623        let forge = Scripted::new(
6624            vec![
6625                seen("a", Checks::Green, "CLEAN", false),
6626                seen("a", Checks::Green, "CLEAN", false),
6627                seen("a", Checks::Pending, "BLOCKED", false),
6628                seen("a", Checks::Pending, "BLOCKED", false),
6629                seen("a", Checks::Green, "CLEAN", false),
6630            ],
6631            vec![(false, REFUSED), (true, "")],
6632        );
6633        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6634            .await
6635            .unwrap();
6636        assert_eq!(
6637            forge.calls(),
6638            [
6639                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
6640                "view"
6641            ]
6642        );
6643        assert_eq!(state.status, RunStatus::Merged);
6644    }
6645
6646    #[tokio::test]
6647    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
6648        let mut state = landing_state();
6649        let forge = Scripted::new(
6650            vec![
6651                seen("a", Checks::Green, "CLEAN", false),
6652                seen("a", Checks::Green, "BLOCKED", false),
6653            ],
6654            vec![(false, REFUSED), (false, REFUSED)],
6655        );
6656        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6657            .await
6658            .unwrap();
6659        // One re-look is allowed for the forge's own lag, then it is final.
6660        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
6661        let why = state.merge.as_ref().unwrap().detail.clone();
6662        assert!(
6663            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
6664            "{why}"
6665        );
6666        assert_eq!(state.status, RunStatus::Blocked);
6667    }
6668
6669    #[test]
6670    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
6671        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
6672        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
6673        // The pull request is still on the commit before the push.
6674        assert_eq!(bound_head("old", "old", Some("new")), None);
6675        // The checks are the previous commit's.
6676        assert_eq!(bound_head("new", "old", Some("new")), None);
6677        assert_eq!(bound_head("new", "old", None), None);
6678        // Nothing readable.
6679        assert_eq!(bound_head("", "", None), None);
6680        assert_eq!(bound_head("", "", Some("new")), None);
6681        assert_eq!(bound_head("abc", "", None), None);
6682    }
6683
6684    fn view_json(head: &str) -> String {
6685        format!(
6686            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
6687            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
6688            "reviews":[],"comments":[]}}"#
6689        )
6690    }
6691
6692    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
6693        format!(
6694            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
6695            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
6696            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
6697            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
6698        )
6699    }
6700
6701    #[test]
6702    fn rollup_is_bound_to_the_commit_in_the_same_node() {
6703        // The view already points at the new head, but the node still answers
6704        // for the old commit with its red check: the head stays unbound.
6705        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
6706        assert_eq!(s.rollup_head, "old");
6707        assert_eq!(s.pr.checks, Checks::Red);
6708        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
6709        assert_eq!(s.failing_urls.len(), 1);
6710    }
6711
6712    #[test]
6713    fn checks_come_from_the_node_not_the_view() {
6714        let view = view_json("new").replace(
6715            r#""reviews""#,
6716            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
6717        );
6718        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
6719        assert_eq!(s.pr.checks, Checks::Green);
6720        assert!(s.pr.failing.is_empty());
6721        assert_eq!(
6722            bound_head(&s.head, &s.rollup_head, Some("new")),
6723            Some("new")
6724        );
6725    }
6726
6727    #[test]
6728    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
6729        for node in [
6730            None,
6731            Some("not json".to_owned()),
6732            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
6733            Some(node_json("new", "SUCCESS", true)),
6734        ] {
6735            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
6736            assert!(s.rollup_head.is_empty());
6737            assert_eq!(s.pr.checks, Checks::Unknown);
6738            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
6739        }
6740    }
6741
6742    #[test]
6743    fn the_merge_command_is_pinned_to_the_observed_head() {
6744        let argv = merge_argv_at(7, "feat: x", "deadbeef");
6745        let at = argv
6746            .iter()
6747            .position(|a| a == "--match-head-commit")
6748            .unwrap();
6749        assert_eq!(argv[at + 1], "deadbeef");
6750    }
6751
6752    #[tokio::test]
6753    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
6754        let mut state = landing_state();
6755        // The pull request is on the pushed head but the rollup is still the
6756        // previous commit's red, non-required result.
6757        let mut stale = seen("new", Checks::Red, "CLEAN", false);
6758        stale.rollup_head = "old".to_owned();
6759        let forge = Scripted::new(
6760            vec![seen("old", Checks::Green, "CLEAN", true), stale],
6761            vec![],
6762        );
6763        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6764            head: "new".to_owned(),
6765        });
6766        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6767            .await
6768            .unwrap();
6769        assert!(!forge.calls().contains(&"merge"));
6770        assert_eq!(state.status, RunStatus::Blocked);
6771        let why = state.merge.as_ref().unwrap().detail.clone();
6772        assert!(why.contains("new") && why.contains("old"), "{why}");
6773    }
6774
6775    #[test]
6776    fn a_refusal_read_against_another_commits_checks_is_pending() {
6777        let mut after = seen("a", Checks::Green, "BLOCKED", false);
6778        after.rollup_head = "old".to_owned();
6779        assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
6780    }
6781
6782    #[tokio::test]
6783    async fn a_matching_head_with_red_non_required_checks_still_merges() {
6784        let mut state = landing_state();
6785        let forge = Scripted::new(
6786            vec![seen("a", Checks::Red, "CLEAN", false)],
6787            vec![(true, "")],
6788        );
6789        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6790            .await
6791            .unwrap();
6792        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6793        assert_eq!(state.status, RunStatus::Merged);
6794    }
6795
6796    #[tokio::test]
6797    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
6798        let mut state = landing_state();
6799        let forge = Scripted::new(
6800            vec![
6801                seen("a", Checks::Green, "CLEAN", false),
6802                seen("a", Checks::Green, "CLEAN", false),
6803                // Re-viewed after the refusal: someone pushed.
6804                seen("b", Checks::Green, "BLOCKED", false),
6805                seen("b", Checks::Green, "CLEAN", false),
6806            ],
6807            vec![(false, REFUSED), (true, "")],
6808        );
6809        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6810            .await
6811            .unwrap();
6812        assert_eq!(
6813            forge.calls(),
6814            [
6815                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
6816            ]
6817        );
6818        assert_eq!(state.status, RunStatus::Merged);
6819    }
6820
6821    fn merged_view(head: &str) -> Seen {
6822        let mut m = seen(head, Checks::Green, "CLEAN", false);
6823        m.pr.state = PrLifecycle::Merged;
6824        m
6825    }
6826
6827    fn has(argv: &[String], flag: &str) -> bool {
6828        argv.iter().any(|a| a == flag)
6829    }
6830
6831    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
6832        let at = argv.iter().position(|a| a == flag)?;
6833        argv.get(at + 1).map(String::as_str)
6834    }
6835
6836    const URL: &str = "https://github.com/o/r/pull/42";
6837
6838    #[tokio::test]
6839    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
6840        let mut state = landing_state();
6841        let forge = Scripted::new(
6842            vec![
6843                seen("abc", Checks::Green, "CLEAN", false),
6844                seen("abc", Checks::Green, "CLEAN", false),
6845            ],
6846            vec![(true, "")],
6847        );
6848        land_with(&mut state, URL, &forge).await.unwrap();
6849        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6850        let argv = &forge.argvs()[0];
6851        assert!(has(argv, "--squash") && has(argv, "--subject"));
6852        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
6853        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
6854        assert_eq!(state.status, RunStatus::Merged);
6855        assert!(state.land_armed_head.is_none());
6856    }
6857
6858    #[tokio::test]
6859    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
6860        let mut state = landing_state();
6861        state.land_armed_head = Some("a".to_owned());
6862        let forge = Scripted::new(
6863            vec![seen("a", Checks::Green, "CLEAN", false)],
6864            vec![(true, ""), (true, "")],
6865        );
6866        land_with(&mut state, URL, &forge).await.unwrap();
6867        let argvs = forge.argvs();
6868        assert!(has(&argvs[0], "--disable-auto"));
6869        assert!(!has(&argvs[1], "--auto"));
6870        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
6871        assert_eq!(state.status, RunStatus::Merged);
6872        assert!(state.land_armed_head.is_none());
6873    }
6874
6875    #[tokio::test]
6876    async fn an_approval_never_carries_over_to_a_new_head() {
6877        crate::run::pin_test_home();
6878        let mut state = run_state();
6879        state.config.graph.land_approval = true;
6880        let pr = green_pr();
6881        let store = ask::Questions::open();
6882
6883        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
6884            .await
6885            .unwrap();
6886        let mut q = store
6887            .list()
6888            .into_iter()
6889            .find(|q| q.run == state.id)
6890            .unwrap();
6891        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6892        store.put(&mut q).unwrap();
6893        assert_eq!(
6894            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
6895                .await
6896                .unwrap(),
6897            ApprovalGate::Approved,
6898            "the same head keeps its approval"
6899        );
6900
6901        // A new head is a new question, not the old word.
6902        assert_eq!(
6903            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6904                .await
6905                .unwrap(),
6906            ApprovalGate::Pending
6907        );
6908        let all: Vec<_> = store
6909            .list()
6910            .into_iter()
6911            .filter(|q| q.run == state.id)
6912            .collect();
6913        assert_eq!(all.len(), 2);
6914
6915        // A question recorded before heads were tracked is not reused either.
6916        state.land_approval = None;
6917        assert_eq!(
6918            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6919                .await
6920                .unwrap(),
6921            ApprovalGate::Pending
6922        );
6923        let open = store
6924            .list()
6925            .into_iter()
6926            .filter(|q| q.run == state.id && q.status.open())
6927            .count();
6928        assert_eq!(open, 1, "the superseded question was retired");
6929    }
6930
6931    #[tokio::test]
6932    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
6933        let mut state = landing_state();
6934        let forge = Scripted::new(
6935            vec![
6936                seen("a", Checks::Green, "CLEAN", false),
6937                // The fresh read before the merge: someone pushed.
6938                seen("b", Checks::Green, "CLEAN", false),
6939            ],
6940            vec![(true, "")],
6941        );
6942        land_with(&mut state, URL, &forge).await.unwrap();
6943        let argvs = forge.argvs();
6944        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
6945        assert!(!has(&argvs[0], "--auto"));
6946        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
6947        assert_eq!(state.status, RunStatus::Merged);
6948    }
6949
6950    fn passing(label: &str) -> CheckInfo {
6951        CheckInfo {
6952            label: label.to_owned(),
6953            verdict: Verdict::Pass,
6954            required: Some(false),
6955        }
6956    }
6957
6958    fn names(xs: &[&str]) -> BTreeSet<String> {
6959        xs.iter().map(|x| (*x).to_owned()).collect()
6960    }
6961
6962    #[test]
6963    fn a_required_check_the_rollup_never_listed_is_named() {
6964        let req = names(&["build"]);
6965        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
6966        assert!(why.contains("never reported: build"), "{why}");
6967        assert!(!why.contains("probably waiting for a review"), "{why}");
6968    }
6969
6970    #[test]
6971    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
6972        let why = waiting_on("BLOCKED", &[passing("review")], None);
6973        assert!(why.contains("could not be read"), "{why}");
6974        assert!(!why.contains("probably waiting for a review"), "{why}");
6975    }
6976
6977    #[test]
6978    fn all_required_reported_keeps_the_review_guess() {
6979        let req = names(&["build"]);
6980        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
6981        assert!(why.contains("probably waiting for a review"), "{why}");
6982        assert!(!why.contains("never reported"), "{why}");
6983    }
6984
6985    #[test]
6986    fn required_names_match_the_rollup_ignoring_case_only() {
6987        let req = names(&["Build"]);
6988        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
6989        assert!(!why.contains("never reported"), "{why}");
6990    }
6991
6992    #[test]
6993    fn required_contexts_are_read_from_protection_and_rulesets() {
6994        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
6995        assert_eq!(
6996            parse_classic_required(classic),
6997            Some(names(&["build", "lint"]))
6998        );
6999        let rules = r#"[{"type":"pull_request","parameters":{}},
7000            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7001        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7002        assert_eq!(parse_ruleset_required("nope"), None);
7003        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7004    }
7005
7006    #[test]
7007    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7008        let shown = BTreeSet::new();
7009        let ok = seen("a", Checks::Green, "CLEAN", false);
7010        let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7011        assert!(guard(Some(&ok)));
7012        assert!(!guard(None));
7013        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7014        let mut stale = ok.clone();
7015        stale.rollup_head = "old".to_owned();
7016        assert!(!guard(Some(&stale)));
7017        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7018        assert!(!guard(Some(&merged_view("a"))));
7019    }
7020
7021    #[test]
7022    fn the_rollup_node_carries_whether_each_check_is_required() {
7023        let node = node_json("new", "SUCCESS", false)
7024            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7025        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7026        assert_eq!(s.contexts.len(), 1);
7027        assert_eq!(s.contexts[0].required, Some(true));
7028        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7029        assert_eq!(s.contexts[0].required, None);
7030    }
7031
7032    #[tokio::test]
7033    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7034        let mut state = landing_state();
7035        state.land_armed_head = Some("a".to_owned());
7036        let forge = Scripted::new(
7037            vec![seen("a", Checks::Green, "CLEAN", true)],
7038            vec![(false, "disable exploded")],
7039        );
7040        land_with(&mut state, URL, &forge).await.unwrap();
7041        assert!(!forge.calls().contains(&"fix"));
7042        assert_eq!(state.status, RunStatus::Blocked);
7043        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7044        let why = state.merge.as_ref().unwrap().detail.clone();
7045        assert!(why.contains("disable exploded"), "{why}");
7046    }
7047}