Skip to main content

magi/
land.rs

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