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                sessions: state.config.graph.sessions,
3964                artifacts: &artifacts,
3965                stem: &stem,
3966                run: &state.id,
3967                node: "land",
3968                cache_dir: state.config.cache_dir().as_deref(),
3969                attachments: &[],
3970                writable: &[],
3971            },
3972        )
3973        .await;
3974        state.seats.insert(seat.key.clone(), seat);
3975        if let Some(next) = ids.get(i + 1)
3976            && agent::chain_advances(&out)
3977        {
3978            let (class, why) = crate::fixer::failure_of(&out);
3979            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
3980            continue;
3981        }
3982        last = Some(out);
3983        break;
3984    }
3985    let out = last.expect("the fixer chain always has an entry");
3986
3987    match out {
3988        Ok(o) if o.quota_exhausted() => {
3989            return Ok(Fixed::Failed(
3990                "rate limited (quota); the fixer could not run".to_owned(),
3991            ));
3992        }
3993        Ok(o) if !o.usable() => {
3994            return Ok(Fixed::Failed(format!(
3995                "the fixer produced nothing usable (exit {:?}, timed out: {})",
3996                o.exit_code, o.timed_out
3997            )));
3998        }
3999        Ok(_) => {}
4000        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4001    }
4002
4003    // An agent that edited files but never committed would otherwise push
4004    // nothing and look like a refusal.
4005    if let Ok(r) = git::rescue_commit(
4006        &winner.worktree,
4007        &format!("magi: land round {round} fixes (uncommitted work)"),
4008    )
4009    .await
4010    {
4011        state.note_withheld("land", &r.withheld);
4012    }
4013    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4014    if after == before {
4015        return Ok(Fixed::Declined);
4016    }
4017
4018    let remote = state.config.merge.remote.clone();
4019    let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
4020    if !push.ok() {
4021        return Ok(Fixed::Failed(format!(
4022            "pushing {} to {remote} failed: {}",
4023            winner.branch, push.stderr
4024        )));
4025    }
4026    state.event(
4027        "land",
4028        format!("round {round}: pushed a fix to {}", winner.branch),
4029    );
4030    Ok(Fixed::Committed { head: after })
4031}
4032
4033/// Fetch or create a seat, keeping its conversation across nodes.
4034pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4035    crate::fixer::seat_for(state, key, agent)
4036}
4037
4038/// What the fixer is told.
4039fn fix_prompt(
4040    state: &RunState,
4041    pr: &PrState,
4042    round: usize,
4043    budget: usize,
4044    reason: &str,
4045    logs: &str,
4046) -> String {
4047    let mut s = format!(
4048        "Your patch is open as a pull request and it is not landing. Land round \
4049         {round} of {budget}.\n\n\
4050         Pull request: {}\n\n\
4051         What is holding it: {reason}\n\n\
4052         # The task\n\n{}\n",
4053        pr.url, state.instruction
4054    );
4055
4056    if pr.failing.is_empty() {
4057        s.push_str("\n# Failing checks\n\n(none)\n");
4058    } else {
4059        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4060        if logs.trim().is_empty() {
4061            s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4062        } else {
4063            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4064        }
4065    }
4066
4067    if pr.review_comments.is_empty() {
4068        s.push_str("\n# Review comments\n\n(none)\n");
4069    } else {
4070        s.push_str("\n# Review comments\n");
4071        for c in &pr.review_comments {
4072            let where_ = match (&c.path, c.line) {
4073                (Some(p), Some(l)) => format!(" ({p}:{l})"),
4074                (Some(p), None) => format!(" ({p})"),
4075                _ => String::new(),
4076            };
4077            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4078        }
4079    }
4080
4081    s.push_str(
4082        "\n# Rules\n\n\
4083         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4084            failing test; do not silence a lint with an allow attribute; do not \
4085            stretch a timeout to hide a race. If the check is right, the code is \
4086            wrong.\n\
4087         2. Change nothing the checks and the comments did not raise. A \
4088            drive-by refactor turns a one-line fix into a pull request that \
4089            needs reviewing again.\n\
4090         3. If a comment is wrong, say so with a checkable argument and change \
4091            nothing for it. A declined comment with a reason is a correct \
4092            outcome; a change made to appease a reviewer is not.\n\
4093         4. Commit in this worktree. magi pushes to the pull request's branch \
4094            for you; do not push, merge, or close anything yourself.\n\
4095         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4096         # Output\n\n\
4097         Say what you changed and why, and what you declined and why.",
4098    );
4099
4100    let language = &state.config.graph.language;
4101    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4102        let _ = write!(s, "\n\nWrite all prose in {language}.");
4103    }
4104    // After the language line, so the exception is the last word on it.
4105    s.push_str(&crate::prompt::github_english(language));
4106    if let Some(overlay) = state.config.prompts.overlay("fix") {
4107        let _ = write!(s, "\n\n{overlay}");
4108    }
4109    s
4110}
4111
4112/// Failing log tails, the way the operator collects them by hand:
4113/// `gh run view --log-failed`.
4114async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4115    let mut out = String::new();
4116    for (name, url) in failing.iter().take(MAX_LOGS) {
4117        let args = match (job_of(url), run_of(url)) {
4118            (Some(job), _) => vec![
4119                "run".to_owned(),
4120                "view".to_owned(),
4121                "--log-failed".to_owned(),
4122                "--job".to_owned(),
4123                job,
4124            ],
4125            (None, Some(run)) => vec![
4126                "run".to_owned(),
4127                "view".to_owned(),
4128                run,
4129                "--log-failed".to_owned(),
4130            ],
4131            // Not a GitHub Actions check - an external status has no log here.
4132            (None, None) => continue,
4133        };
4134        let (ok, body) = match gh(repo, &args).await {
4135            Ok(v) => v,
4136            Err(e) => (false, format!("{e:#}")),
4137        };
4138        if !ok && body.trim().is_empty() {
4139            continue;
4140        }
4141        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4142    }
4143    out
4144}
4145
4146/// Job id out of a check's `detailsUrl`
4147/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
4148fn job_of(details_url: &str) -> Option<String> {
4149    let after = details_url.split("/job/").nth(1)?;
4150    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4151    (!id.is_empty()).then_some(id)
4152}
4153
4154/// Workflow run id out of a check's `detailsUrl`.
4155pub(crate) fn run_of(details_url: &str) -> Option<String> {
4156    let after = details_url.split("/actions/runs/").nth(1)?;
4157    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4158    (!id.is_empty()).then_some(id)
4159}
4160
4161/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
4162/// it lands on GitHub, not in front of the operator. Pure so a test can hold
4163/// it to that.
4164fn stop_comment(run_id: &str, why: &str) -> String {
4165    format!(
4166        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4167         The branch is untouched and the run is `{run_id}`. Nothing was merged."
4168    )
4169}
4170
4171/// Leave the pull request open, say why on it, and mark the run blocked.
4172///
4173/// The comment is what makes an unattended stop actionable: the operator wakes
4174/// up to a pull request that explains itself rather than to a silent queue.
4175async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4176    let body = stop_comment(&state.id, why);
4177    let (_, mut body) = crate::github_text::prepare(state, "", &body);
4178    if !body.contains(MARKER) {
4179        body = format!("{MARKER}\n{body}");
4180    }
4181    let posted = gh(
4182        repo,
4183        &[
4184            "pr".to_owned(),
4185            "comment".to_owned(),
4186            pr.number.to_string(),
4187            "--body".to_owned(),
4188            body,
4189        ],
4190    )
4191    .await;
4192    match posted {
4193        Ok((true, _)) => {}
4194        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4195        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4196    }
4197    state.status = RunStatus::Blocked;
4198    state.merge = Some(MergeOutcome {
4199        mode: MergeMode::Pr,
4200        ok: false,
4201        detail: why.to_owned(),
4202        empty: false,
4203    });
4204    state.event("land", format!("stopped: {why}"));
4205    state.save()?;
4206    Ok(())
4207}
4208
4209/// Run `gh` in `repo`, returning success and the combined output.
4210///
4211/// Combined because `gh` reports a refused merge on stderr and the pull request
4212/// json on stdout, and both are evidence.
4213///
4214/// `GH_REPO` is stripped from the child's environment: every call site here
4215/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
4216/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
4217/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
4218/// `GH_REPO` the operator happens to have exported for an unrelated script
4219/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
4220/// below) to a different repository than the one actually on disk - which
4221/// for the same-repo guard in [`correct_manual_merge`] would mean the check
4222/// could be made to agree with whatever repository a forged `--merged` URL
4223/// claims, defeating it entirely.
4224pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4225    let out = tokio::process::Command::new("gh")
4226        .args(args)
4227        .current_dir(cwd)
4228        .env_remove("GH_REPO")
4229        .quiet()
4230        .stdin(std::process::Stdio::null())
4231        .output()
4232        .await
4233        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4234    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4235    let err = String::from_utf8_lossy(&out.stderr);
4236    if body.trim().is_empty() {
4237        body = err.into_owned();
4238    } else if !err.trim().is_empty() {
4239        body.push_str(&err);
4240    }
4241    Ok((out.status.success(), body.trim().to_owned()))
4242}
4243
4244/// Verdict of one entry in the status rollup.
4245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4246pub(crate) enum Verdict {
4247    Pass,
4248    Fail,
4249    Pending,
4250    Unknown,
4251}
4252
4253#[derive(Debug, Deserialize)]
4254#[serde(rename_all = "camelCase")]
4255struct GhPr {
4256    #[serde(default)]
4257    url: String,
4258    #[serde(default)]
4259    number: u64,
4260    #[serde(default)]
4261    state: String,
4262    #[serde(default)]
4263    title: String,
4264    #[serde(default)]
4265    status_check_rollup: Vec<GhCheck>,
4266    /// GitHub's own verdict on whether the pull request can be merged.
4267    ///
4268    /// Worth asking for because it is the only place the *required* check set
4269    /// is applied: the rollup lists every check equally, so a repository that
4270    /// deliberately does not require `coverage` still looks red here. See
4271    /// [`Blocking`].
4272    #[serde(default)]
4273    merge_state_status: String,
4274    /// The commit the pull request currently points at. Compared with the
4275    /// commit a fix round pushed, it is how the loop knows the forge has moved
4276    /// on and the rollup belongs to the new head.
4277    #[serde(default)]
4278    head_ref_oid: String,
4279    #[serde(default)]
4280    base_ref_name: String,
4281    #[serde(default)]
4282    reviews: Vec<GhReview>,
4283    #[serde(default)]
4284    comments: Vec<GhComment>,
4285}
4286
4287/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
4288/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
4289/// status API, which is how CodeRabbit reports - has `context`/`state` and no
4290/// conclusion at all.
4291#[derive(Debug, Deserialize)]
4292#[serde(rename_all = "camelCase")]
4293struct GhCheck {
4294    #[serde(default)]
4295    name: Option<String>,
4296    #[serde(default)]
4297    context: Option<String>,
4298    #[serde(default)]
4299    status: Option<String>,
4300    #[serde(default)]
4301    conclusion: Option<String>,
4302    #[serde(default)]
4303    state: Option<String>,
4304    #[serde(default)]
4305    details_url: Option<String>,
4306    #[serde(default)]
4307    target_url: Option<String>,
4308    /// Whether the base branch requires this check. Only the GraphQL node
4309    /// carries it; `None` is "not read", never "not required".
4310    #[serde(default)]
4311    is_required: Option<bool>,
4312}
4313
4314impl GhCheck {
4315    /// Name to show a human and hand to the fixer.
4316    fn label(&self) -> String {
4317        self.name
4318            .clone()
4319            .or_else(|| self.context.clone())
4320            .unwrap_or_else(|| "(unnamed check)".to_owned())
4321    }
4322
4323    /// Where this check's logs live, when it has any.
4324    fn url(&self) -> Option<&str> {
4325        self.details_url
4326            .as_deref()
4327            .or(self.target_url.as_deref())
4328            .filter(|u| !u.is_empty())
4329    }
4330
4331    /// Did it pass?
4332    ///
4333    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
4334    /// skips release and bot pull requests by design, and a skip that blocked
4335    /// landing would block exactly the pull requests that need no review.
4336    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
4337    /// merging over one is merging over a check that never ran.
4338    fn verdict(&self) -> Verdict {
4339        if let Some(status) = self.status.as_deref() {
4340            if !status.eq_ignore_ascii_case("COMPLETED") {
4341                return Verdict::Pending;
4342            }
4343        }
4344        let outcome = self
4345            .conclusion
4346            .as_deref()
4347            .or(self.state.as_deref())
4348            .unwrap_or("");
4349        match outcome.to_ascii_uppercase().as_str() {
4350            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4351            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4352            | "ACTION_REQUIRED" => Verdict::Fail,
4353            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4354                Verdict::Pending
4355            }
4356            _ => Verdict::Unknown,
4357        }
4358    }
4359}
4360
4361#[derive(Debug, Deserialize)]
4362struct GhAuthor {
4363    #[serde(default)]
4364    login: String,
4365}
4366
4367#[derive(Debug, Deserialize)]
4368struct GhReview {
4369    #[serde(default)]
4370    author: GhAuthor,
4371    #[serde(default)]
4372    body: String,
4373}
4374
4375#[derive(Debug, Deserialize)]
4376struct GhComment {
4377    #[serde(default)]
4378    author: GhAuthor,
4379    #[serde(default)]
4380    body: String,
4381}
4382
4383#[derive(Debug, Deserialize)]
4384struct GhUser {
4385    #[serde(default)]
4386    login: String,
4387}
4388
4389#[derive(Debug, Deserialize)]
4390struct GhInline {
4391    #[serde(default)]
4392    user: GhUser,
4393    #[serde(default)]
4394    path: Option<String>,
4395    #[serde(default)]
4396    line: Option<u64>,
4397    #[serde(default)]
4398    body: String,
4399}
4400
4401impl Default for GhAuthor {
4402    fn default() -> Self {
4403        Self {
4404            login: "(unknown)".to_owned(),
4405        }
4406    }
4407}
4408
4409impl Default for GhUser {
4410    fn default() -> Self {
4411        Self {
4412            login: "(unknown)".to_owned(),
4413        }
4414    }
4415}
4416
4417#[cfg(test)]
4418mod tests {
4419    use super::*;
4420    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4421
4422    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4423        format!(
4424            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4425        )
4426    }
4427
4428    #[test]
4429    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4430        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4431        assert_eq!(
4432            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4433            Ok(())
4434        );
4435        for (json, why) in [
4436            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4437            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4438            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4439            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4440            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4441            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4442        ] {
4443            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4444                .unwrap_err()
4445                .why;
4446            assert!(err.contains(why), "{json}: {err}");
4447        }
4448        // A head that moved on past what was verified is left alone.
4449        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4450        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4451        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4452        assert!(
4453            !closable(
4454                &head_json("x", "main", "OPEN", false),
4455                "magi/27b2/A",
4456                "main",
4457                &[]
4458            )
4459            .unwrap_err()
4460            .retry
4461        );
4462        assert!(is_forge_url("https://github.com/o/r.git"));
4463        assert!(is_forge_url("git@github.com:o/r.git"));
4464        assert!(!is_forge_url("/tmp/origin.git"));
4465        assert!(!is_forge_url("C:\\work\\origin.git"));
4466        assert!(!is_forge_url("file:///tmp/origin.git"));
4467        assert!(forge_unavailable(
4468            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4469        ));
4470        assert!(!forge_unavailable(
4471            "gh pr list failed: error connecting to api.github.com"
4472        ));
4473        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4474        // A record that does not say whether it is a fork is not trusted.
4475        assert!(
4476            closable(
4477                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4478                "b",
4479                "main",
4480                &["aaa".to_owned()]
4481            )
4482            .is_err()
4483        );
4484    }
4485
4486    #[test]
4487    fn the_close_comment_names_the_commit_on_the_base() {
4488        let e = crate::already::Evidence {
4489            proof: crate::already::Proof::PatchId,
4490            tip: "1234567890".to_owned(),
4491            commits: vec!["0e368de0000".to_owned()],
4492        };
4493        let c = superseded_comment("main", &e);
4494        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4495    }
4496
4497    /// 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.
4498    const GREEN_OPEN: &str = r####"{
4499  "url": "https://github.com/yukimemi/magi/pull/10",
4500  "number": 10,
4501  "state": "OPEN",
4502  "mergeStateStatus": "CLEAN",
4503  "statusCheckRollup": [
4504    {
4505      "__typename": "CheckRun",
4506      "conclusion": "SKIPPED",
4507      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4508      "name": "review",
4509      "status": "COMPLETED",
4510      "workflowName": "claude-review"
4511    },
4512    {
4513      "__typename": "CheckRun",
4514      "conclusion": "SUCCESS",
4515      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4516      "name": "check (ubuntu-latest)",
4517      "status": "COMPLETED",
4518      "workflowName": "CI"
4519    },
4520    {
4521      "__typename": "CheckRun",
4522      "conclusion": "SUCCESS",
4523      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4524      "name": "rustfmt",
4525      "status": "COMPLETED",
4526      "workflowName": "CI"
4527    },
4528    {
4529      "__typename": "StatusContext",
4530      "context": "CodeRabbit",
4531      "state": "SUCCESS",
4532      "targetUrl": ""
4533    }
4534  ],
4535  "reviews": [],
4536  "comments": [
4537    {
4538      "author": {
4539        "login": "coderabbitai"
4540      },
4541      "authorAssociation": "NONE",
4542      "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"
4543    }
4544  ]
4545}"####;
4546
4547    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
4548    const RED_OPEN: &str = r####"{
4549  "url": "https://github.com/yukimemi/magi/pull/9",
4550  "number": 9,
4551  "state": "OPEN",
4552  "mergeStateStatus": "UNSTABLE",
4553  "statusCheckRollup": [
4554    {
4555      "__typename": "CheckRun",
4556      "conclusion": "SUCCESS",
4557      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4558      "name": "check (ubuntu-latest)",
4559      "status": "COMPLETED",
4560      "workflowName": "CI"
4561    },
4562    {
4563      "__typename": "CheckRun",
4564      "conclusion": "SUCCESS",
4565      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4566      "name": "rustfmt",
4567      "status": "COMPLETED",
4568      "workflowName": "CI"
4569    },
4570    {
4571      "__typename": "CheckRun",
4572      "conclusion": "FAILURE",
4573      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4574      "name": "editorconfig",
4575      "status": "COMPLETED",
4576      "workflowName": "CI"
4577    },
4578    {
4579      "__typename": "StatusContext",
4580      "context": "CodeRabbit",
4581      "state": "SUCCESS",
4582      "targetUrl": ""
4583    }
4584  ],
4585  "reviews": [],
4586  "comments": [
4587    {
4588      "author": {
4589        "login": "coderabbitai"
4590      },
4591      "authorAssociation": "NONE",
4592      "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"
4593    }
4594  ]
4595}"####;
4596
4597    /// 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.
4598    const PENDING_OPEN: &str = r####"{
4599  "url": "https://github.com/yukimemi/magi/pull/9",
4600  "number": 9,
4601  "state": "OPEN",
4602  "statusCheckRollup": [
4603    {
4604      "__typename": "CheckRun",
4605      "conclusion": "SUCCESS",
4606      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4607      "name": "check (ubuntu-latest)",
4608      "status": "COMPLETED",
4609      "workflowName": "CI"
4610    },
4611    {
4612      "__typename": "CheckRun",
4613      "conclusion": "SUCCESS",
4614      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4615      "name": "rustfmt",
4616      "status": "COMPLETED",
4617      "workflowName": "CI"
4618    },
4619    {
4620      "__typename": "CheckRun",
4621      "conclusion": null,
4622      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4623      "name": "editorconfig",
4624      "status": "IN_PROGRESS",
4625      "workflowName": "CI"
4626    },
4627    {
4628      "__typename": "StatusContext",
4629      "context": "CodeRabbit",
4630      "state": "SUCCESS",
4631      "targetUrl": ""
4632    }
4633  ],
4634  "reviews": [],
4635  "comments": []
4636}"####;
4637
4638    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
4639    const MERGED: &str = r####"{
4640  "url": "https://github.com/yukimemi/magi/pull/16",
4641  "number": 16,
4642  "state": "MERGED",
4643  "statusCheckRollup": [
4644    {
4645      "__typename": "CheckRun",
4646      "conclusion": "SUCCESS",
4647      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4648      "name": "check (ubuntu-latest)",
4649      "status": "COMPLETED",
4650      "workflowName": "CI"
4651    },
4652    {
4653      "__typename": "CheckRun",
4654      "conclusion": "SUCCESS",
4655      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4656      "name": "review",
4657      "status": "COMPLETED",
4658      "workflowName": "claude-review"
4659    }
4660  ],
4661  "reviews": [],
4662  "comments": []
4663}"####;
4664
4665    /// 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.
4666    const REVIEWED_OPEN: &str = r####"{
4667  "url": "https://github.com/yukimemi/magi/pull/12",
4668  "number": 12,
4669  "state": "OPEN",
4670  "statusCheckRollup": [
4671    {
4672      "__typename": "CheckRun",
4673      "conclusion": "SUCCESS",
4674      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4675      "name": "check (ubuntu-latest)",
4676      "status": "COMPLETED",
4677      "workflowName": "CI"
4678    },
4679    {
4680      "__typename": "CheckRun",
4681      "conclusion": "SUCCESS",
4682      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4683      "name": "review",
4684      "status": "COMPLETED",
4685      "workflowName": "claude-review"
4686    }
4687  ],
4688  "reviews": [
4689    {
4690      "author": {
4691        "login": "claude"
4692      },
4693      "state": "COMMENTED",
4694      "body": ""
4695    }
4696  ],
4697  "comments": [
4698    {
4699      "author": {
4700        "login": "coderabbitai"
4701      },
4702      "authorAssociation": "NONE",
4703      "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"
4704    },
4705    {
4706      "author": {
4707        "login": "claude"
4708      },
4709      "authorAssociation": "NONE",
4710      "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"
4711    }
4712  ]
4713}"####;
4714
4715    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
4716    const INLINE: &str = r####"[
4717  {
4718    "user": {
4719      "login": "claude[bot]"
4720    },
4721    "path": "src/graph.rs",
4722    "line": 231,
4723    "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"
4724  }
4725]"####;
4726
4727    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
4728    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4729<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4730
4731> [!IMPORTANT]
4732> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4733> 
4734> This repository does not receive automatic reviews because it has fewer than 10 stars.
4735> 
4736> <details>
4737> <summary>⚙️ Run configuration</summary>
4738> 
4739> **Configuration used**: defaults
4740> 
4741> **Review profile**: CHILL
4742> 
4743> **Plan**: Team
4744> 
4745> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4746> 
4747> </details>
4748
4749<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4750
4751<!-- tips_start -->
4752
4753---
4754
4755Thanks 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.
4756
4757<details>
4758<summary>❤️ Share</summary>
4759
4760- [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"####;
4761
4762    /// 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.
4763    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
4764
4765---
4766### Reviewing PR #16
4767
4768- [x] Read AGENTS.md conventions
4769- [x] Review `src/daemon.rs` changes
4770- [x] Review `src/main.rs` changes (new `doctor` reporting)
4771- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
4772- [x] Check test coverage for new behavior
4773- [x] Run verification commands (blocked — see note)
4774- [x] Post findings"####;
4775
4776    /// The same job's real comment on pull request #12 once it had something to say.
4777    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
4778
4779---
4780### Review: `magi review <branch>` — cheap-half-only graph
4781
4782Read 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.
4783
4784**Correctness**
4785
4786- 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"####;
4787
4788    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
4789        PrState {
4790            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
4791            number: 16,
4792            state: PrLifecycle::Open,
4793            checks,
4794            // These tests are about red-means-fix, so a red here is one the
4795            // forge gates on. Without saying so they would assert the new
4796            // "merge past a check nobody requires" path by accident.
4797            blocking: if matches!(checks, Checks::Red) {
4798                Blocking::Yes
4799            } else {
4800                Blocking::No
4801            },
4802            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
4803            review_comments: (0..comments)
4804                .map(|i| ReviewComment {
4805                    author: "coderabbitai".to_owned(),
4806                    path: Some("src/graph.rs".to_owned()),
4807                    line: Some(231),
4808                    body: format!("finding {i}"),
4809                })
4810                .collect(),
4811        }
4812    }
4813
4814    #[test]
4815    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
4816        use CiExpectation::{Absent, Expected};
4817        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
4818            let p = pr(checks, &["x"], 0);
4819            for waited in [Duration::ZERO, CHECKS_GRACE] {
4820                assert_eq!(
4821                    decide_with(&p, 0, 4, waited, Expected),
4822                    decide(&p, 0, 4, waited)
4823                );
4824            }
4825        }
4826        // Nothing will ever report: no wait, no give-up, no fix round.
4827        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
4828            let p = pr(checks, &["x"], 0);
4829            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
4830            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
4831        }
4832        // A conflict is not a check: it still wants the rebase.
4833        let mut p = pr(Checks::Unknown, &[], 0);
4834        p.blocking = Blocking::Conflict;
4835        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
4836        // And a pull request that left our hands is still done.
4837        p.state = PrLifecycle::Merged;
4838        assert_eq!(
4839            decide_with(&p, 0, 4, Duration::ZERO, Absent),
4840            Step::Done { merged: true }
4841        );
4842    }
4843
4844    #[test]
4845    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
4846        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
4847        assert_eq!(state.number, 10);
4848        assert_eq!(state.state, PrLifecycle::Open);
4849        assert_eq!(state.checks, Checks::Green);
4850        assert!(state.failing.is_empty());
4851        assert!(
4852            state.review_comments.is_empty(),
4853            "the only comment is CodeRabbit's trigger notice: {:?}",
4854            state.review_comments
4855        );
4856        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
4857    }
4858
4859    #[test]
4860    fn a_failing_check_parses_as_red_and_is_named() {
4861        let state = parse_pr(RED_OPEN).expect("red fixture parses");
4862        assert_eq!(state.checks, Checks::Red);
4863        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
4864        // The captured payload says `UNSTABLE` - mergeable, with a check
4865        // nobody requires red - which is exactly the shape that had to be
4866        // merged by hand. Asserted separately, in
4867        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
4868        // test is about is that a red check is *named*, so the reason a fixer
4869        // is handed says which one; so it asks the blocking question here.
4870        let mut blocking = state.clone();
4871        blocking.blocking = Blocking::Yes;
4872        match decide(&blocking, 0, 4, Duration::ZERO) {
4873            Step::Fix { reason } => {
4874                assert!(reason.contains("editorconfig"), "reason: {reason}");
4875                assert!(reason.contains("failing"), "reason: {reason}");
4876            }
4877            other => panic!("expected a fix round, got {other:?}"),
4878        }
4879    }
4880
4881    #[test]
4882    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
4883        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
4884        assert_eq!(state.checks, Checks::Pending);
4885        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
4886    }
4887
4888    #[test]
4889    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
4890        let state = parse_pr(MERGED).expect("merged fixture parses");
4891        assert_eq!(state.state, PrLifecycle::Merged);
4892        assert_eq!(
4893            decide(&state, 0, 4, Duration::ZERO),
4894            Step::Done { merged: true }
4895        );
4896    }
4897
4898    #[test]
4899    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
4900        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
4901        assert_eq!(state.checks, Checks::Green);
4902        let authors: Vec<&str> = state
4903            .review_comments
4904            .iter()
4905            .map(|c| c.author.as_str())
4906            .collect();
4907        assert_eq!(
4908            authors,
4909            vec!["claude"],
4910            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
4911        );
4912        match decide(&state, 0, 4, Duration::ZERO) {
4913            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
4914            other => panic!("expected a fix round, got {other:?}"),
4915        }
4916    }
4917
4918    #[test]
4919    fn inline_review_comments_keep_their_file_and_line() {
4920        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
4921        assert_eq!(comments.len(), 1);
4922        assert_eq!(comments[0].author, "claude[bot]");
4923        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
4924        assert_eq!(comments[0].line, Some(231));
4925        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
4926    }
4927
4928    #[test]
4929    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
4930        assert!(
4931            is_noise(CODERABBIT_TRIGGER),
4932            "CodeRabbit's trigger notice declares itself not a review"
4933        );
4934        assert!(
4935            is_noise(CLAUDE_CHECKLIST),
4936            "a progress checklist asks for nothing"
4937        );
4938        assert!(
4939            !is_noise(CLAUDE_FINDING),
4940            "a review that names a bug is input, not noise"
4941        );
4942
4943        let mut clean = pr(Checks::Green, &[], 0);
4944        clean.review_comments.push(ReviewComment {
4945            author: "coderabbitai".to_owned(),
4946            path: None,
4947            line: None,
4948            body: CODERABBIT_TRIGGER.to_owned(),
4949        });
4950        clean.review_comments.retain(|c| !is_noise(&c.body));
4951        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
4952
4953        let mut found = pr(Checks::Green, &[], 0);
4954        found.review_comments.push(ReviewComment {
4955            author: "claude".to_owned(),
4956            path: None,
4957            line: None,
4958            body: CLAUDE_FINDING.to_owned(),
4959        });
4960        found.review_comments.retain(|c| !is_noise(&c.body));
4961        assert!(matches!(
4962            decide(&found, 0, 4, Duration::ZERO),
4963            Step::Fix { .. }
4964        ));
4965    }
4966
4967    #[test]
4968    fn the_policy_table_holds_for_every_combination_that_matters() {
4969        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
4970            (
4971                "pending checks are waited for, even on the last round",
4972                pr(Checks::Pending, &[], 0),
4973                4,
4974                4,
4975                Duration::ZERO,
4976                Step::Wait,
4977            ),
4978            (
4979                "red checks are fixed",
4980                pr(Checks::Red, &["editorconfig"], 0),
4981                0,
4982                4,
4983                Duration::ZERO,
4984                Step::Fix {
4985                    reason: "1 check(s) failing: editorconfig".to_owned(),
4986                },
4987            ),
4988            (
4989                "green with comments is fixed, not merged",
4990                pr(Checks::Green, &[], 2),
4991                1,
4992                4,
4993                Duration::ZERO,
4994                Step::Fix {
4995                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
4996                        .to_owned(),
4997                },
4998            ),
4999            (
5000                "green and clean merges",
5001                pr(Checks::Green, &[], 0),
5002                3,
5003                4,
5004                Duration::ZERO,
5005                Step::Merge,
5006            ),
5007            (
5008                "an unreadable rollup is waited on while the grace lasts",
5009                pr(Checks::Unknown, &[], 0),
5010                0,
5011                4,
5012                Duration::ZERO,
5013                Step::Wait,
5014            ),
5015            (
5016                "an unreadable rollup is never merged once the grace is spent",
5017                pr(Checks::Unknown, &[], 0),
5018                0,
5019                4,
5020                CHECKS_GRACE,
5021                Step::GiveUp {
5022                    reason: "no check status is readable on the pull request after 3 minute(s); \
5023                             refusing to merge on a guess"
5024                        .to_owned(),
5025                },
5026            ),
5027        ];
5028        for (what, state, round, budget, waited, want) in cases {
5029            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5030        }
5031    }
5032
5033    #[test]
5034    fn the_forge_verdict_survives_the_round_trip_from_gh() {
5035        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
5036        // requested but never parsed is the kind of thing that looks wired up
5037        // and answers `Unsaid` forever.
5038        let green = parse_pr(GREEN_OPEN).expect("parse");
5039        assert_eq!(green.blocking, Blocking::No);
5040        let red = parse_pr(RED_OPEN).expect("parse");
5041        assert_eq!(
5042            red.blocking,
5043            Blocking::No,
5044            "`UNSTABLE` is mergeable: the red check is one nobody requires"
5045        );
5046        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5047        // A payload from an older `gh` has no such field at all.
5048        let quiet =
5049            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5050        assert_eq!(quiet.blocking, Blocking::Unsaid);
5051    }
5052
5053    #[test]
5054    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5055        // Pull request 37's only red check was `editorconfig`, failing
5056        // because the action could not fetch its own binary after
5057        // editorconfig-checker v4 renamed its release assets. The repository
5058        // does not require it. magi answered by asking a fixer to repair a
5059        // change that was fine, and the pull request had to be merged by hand.
5060        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5061        nonblocking.blocking = Blocking::No;
5062        assert_eq!(
5063            decide(&nonblocking, 0, 4, Duration::ZERO),
5064            Step::Merge,
5065            "the forge says nothing is in the way, so nothing is"
5066        );
5067
5068        // The same red, gated on: that is a fix round, as before.
5069        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5070        blocking.blocking = Blocking::Yes;
5071        assert!(matches!(
5072            decide(&blocking, 0, 4, Duration::ZERO),
5073            Step::Fix { .. }
5074        ));
5075
5076        // A review comment still outranks green-enough: a non-required red
5077        // must not become a way to merge past an unanswered reviewer.
5078        let mut commented = pr(Checks::Red, &["coverage"], 1);
5079        commented.blocking = Blocking::No;
5080        assert!(matches!(
5081            decide(&commented, 0, 4, Duration::ZERO),
5082            Step::Fix { .. }
5083        ));
5084
5085        // And silence from the forge is not consent.
5086        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5087        unsaid.blocking = Blocking::Unsaid;
5088        assert!(matches!(
5089            decide(&unsaid, 0, 4, Duration::ZERO),
5090            Step::Fix { .. }
5091        ));
5092    }
5093
5094    #[test]
5095    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5096        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5097        red.blocking = Blocking::No;
5098        assert_eq!(
5099            decide(&red, 0, 4, Duration::ZERO),
5100            Step::Merge,
5101            "announcing must not change the decision"
5102        );
5103        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5104        assert!(said.contains("yukimemi/magi"), "{said}");
5105        assert!(said.contains("#16"), "{said}");
5106        assert!(
5107            said.contains("https://github.com/yukimemi/magi/pull/16"),
5108            "{said}"
5109        );
5110        assert!(
5111            said.contains("test (windows-latest)") && said.contains("coverage"),
5112            "{said}"
5113        );
5114
5115        // `failing` can be left over on a green observation; only `checks` counts.
5116        let green = pr(Checks::Green, &["stale"], 0);
5117        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5118    }
5119
5120    #[test]
5121    fn the_repo_label_comes_from_the_pull_request_url() {
5122        let p = Path::new("/tmp/checkout");
5123        assert_eq!(
5124            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5125            "yukimemi/magi"
5126        );
5127        assert_eq!(repo_label(p, "not a url"), "checkout");
5128    }
5129
5130    #[test]
5131    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5132        // Pull requests 35 and 37 were both rebased by hand: a competition
5133        // that runs for two hours against a repository merging pull requests
5134        // all day conflicts on the way in, and that is arithmetic rather
5135        // than a defect in the change.
5136        let mut conflicted = pr(Checks::Green, &[], 0);
5137        conflicted.blocking = Blocking::Conflict;
5138        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5139
5140        // Decided before the checks, and even with the rounds spent: every
5141        // check on a branch that cannot land is an answer about a state that
5142        // cannot land, and a conflict is not the change's fault.
5143        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5144        red.blocking = Blocking::Conflict;
5145        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5146
5147        // The lifecycle still wins over everything, conflict included.
5148        let mut merged = pr(Checks::Red, &[], 0);
5149        merged.blocking = Blocking::Conflict;
5150        merged.state = PrLifecycle::Merged;
5151        assert_eq!(
5152            decide(&merged, 0, 4, Duration::ZERO),
5153            Step::Done { merged: true }
5154        );
5155    }
5156
5157    #[test]
5158    fn the_forge_verdict_is_read_off_merge_state_status() {
5159        // The spellings that mean "mergeable". `UNSTABLE` is the one that
5160        // matters: mergeable, with a non-required check red or still running.
5161        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5162            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5163            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5164        }
5165        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5166        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5167        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5168        // An older `gh`, or a token without the scope, says nothing - and
5169        // refusing to guess is the rule everywhere else in this module.
5170        for quiet in ["", "UNKNOWN"] {
5171            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5172            assert!(Blocking::of(quiet).stops_a_merge());
5173        }
5174    }
5175
5176    #[test]
5177    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5178        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5179        // The exact stderr from run ec12, in a jj-colocated repository.
5180        let jj = "could not determine current branch: failed to run git: not on any branch";
5181
5182        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5183            .expect("the forge says merged, so it merged");
5184        assert!(landed.ok);
5185        assert!(
5186            landed.detail.contains("but the pull request is merged"),
5187            "the record must not read as a clean success: {}",
5188            landed.detail
5189        );
5190        assert!(
5191            landed.detail.contains("not on any branch"),
5192            "and it must keep what the command actually said: {}",
5193            landed.detail
5194        );
5195
5196        // A pull request still open means the merge really failed.
5197        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5198        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5199        // And an unreadable answer is not evidence of success.
5200        assert!(merged_after_all(&argv, jj, None).is_none());
5201    }
5202
5203    #[test]
5204    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5205        let mut state = pr(Checks::Red, &["editorconfig"], 3);
5206        state.state = PrLifecycle::Closed;
5207        assert_eq!(
5208            decide(&state, 0, 4, Duration::ZERO),
5209            Step::Done { merged: false },
5210            "a human closing the pull request ends the loop, whatever CI says"
5211        );
5212    }
5213
5214    #[test]
5215    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5216        let red = decide(
5217            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5218            4,
5219            4,
5220            Duration::ZERO,
5221        );
5222        match red {
5223            Step::GiveUp { reason } => {
5224                assert!(reason.contains("editorconfig"), "reason: {reason}");
5225                assert!(reason.contains("test (macos)"), "reason: {reason}");
5226                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5227            }
5228            other => panic!("expected a give-up, got {other:?}"),
5229        }
5230
5231        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5232        match commented {
5233            Step::GiveUp { reason } => {
5234                assert!(reason.contains("unresolved"), "reason: {reason}");
5235                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5236            }
5237            other => panic!("expected a give-up, got {other:?}"),
5238        }
5239    }
5240
5241    #[test]
5242    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5243        let candidate_commit = "magi: candidate A (uncommitted work)";
5244        let subject = merge_subject(candidate_commit, "add retries to the uploader");
5245        let argv = merge_argv(16, &subject);
5246
5247        assert!(argv.contains(&"--squash".to_owned()));
5248        assert!(argv.contains(&"--delete-branch".to_owned()));
5249        assert!(argv.contains(&"--subject".to_owned()));
5250        assert_eq!(
5251            argv.last().map(String::as_str),
5252            Some("add retries to the uploader"),
5253            "the subject must not be the candidate commit message"
5254        );
5255        assert_ne!(subject, candidate_commit);
5256    }
5257
5258    #[test]
5259    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5260        assert_eq!(
5261            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5262            "feat: a queue, an unattended loop, and a phone UI"
5263        );
5264        assert_eq!(
5265            merge_subject("", "# port the retry logic\n\ndetails"),
5266            "port the retry logic",
5267            "an empty title falls back to the task's first line, heading marks stripped"
5268        );
5269    }
5270
5271    #[test]
5272    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5273        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5274        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5275        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5276        assert_eq!(job_of("https://coderabbit.ai/status"), None);
5277        assert_eq!(run_of(""), None);
5278    }
5279
5280    #[test]
5281    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5282        let mut out = Vec::new();
5283        push_if_outstanding(
5284            &mut out,
5285            ReviewComment {
5286                author: "yukimemi".to_owned(),
5287                path: None,
5288                line: None,
5289                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5290            },
5291        );
5292        assert!(out.is_empty());
5293    }
5294
5295    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
5296    /// falls back to the repository - which keeps these tests free of a
5297    /// worktree, a `git` invocation and a network.
5298    fn run_state() -> RunState {
5299        let mut state = RunState::new(
5300            std::path::PathBuf::from("/repo/magi"),
5301            "main".to_owned(),
5302            "abcdef1234".to_owned(),
5303            "add retries to the uploader".to_owned(),
5304            crate::config::Config::default(),
5305        );
5306        // Tests run in parallel against one persistent home and the ids
5307        // `RunState::new` draws from the clock can repeat, so two tests would
5308        // share a run's questions. The home also outlives the process, so the
5309        // counter alone would reuse an earlier run's ids.
5310        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5311        let nanos = std::time::SystemTime::now()
5312            .duration_since(std::time::UNIX_EPOCH)
5313            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5314        state.id = format!(
5315            "20261004-{nanos:06}-{:04x}",
5316            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5317        );
5318        state
5319    }
5320
5321    fn green_pr() -> PrState {
5322        PrState {
5323            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5324            number: 42,
5325            state: PrLifecycle::Open,
5326            checks: Checks::Green,
5327            // The forge sees nothing in the way unless a test says otherwise.
5328            blocking: Blocking::No,
5329            failing: Vec::new(),
5330            review_comments: vec![ReviewComment {
5331                author: "coderabbitai".to_owned(),
5332                path: Some("src/land.rs".to_owned()),
5333                line: Some(212),
5334                body: "this branch never checks the exit code".to_owned(),
5335            }],
5336        }
5337    }
5338
5339    #[test]
5340    fn github_facing_land_text_is_english_whatever_the_language() {
5341        let mut state = run_state();
5342        state.config.graph.language = "ja".to_owned();
5343        let comment = stop_comment(&state.id, "checks are still red");
5344        assert!(crate::github_text::check("", &comment).is_empty());
5345        assert!(comment.is_ascii(), "{comment}");
5346        assert!(comment.starts_with(MARKER));
5347
5348        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5349        let ja_at = p.find("Write all prose in ja").unwrap();
5350        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5351        assert!(ja_at < rule_at, "{p}");
5352        assert!(p.contains("stays in Japanese"), "{p}");
5353
5354        state.config.graph.language = "en".to_owned();
5355        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5356        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5357        assert!(!p.contains("does not apply"), "{p}");
5358    }
5359
5360    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5361
5362    fn panel() -> String {
5363        approval_panel(
5364            &run_state(),
5365            &green_pr(),
5366            NUMSTAT,
5367            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5368            &[
5369                "land: ask before merging".to_owned(),
5370                "land: colour the diff".to_owned(),
5371            ],
5372            "feat: merge approval from the phone",
5373        )
5374    }
5375
5376    #[test]
5377    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5378        let html = panel();
5379        for needle in [
5380            "42",
5381            "main",
5382            "src/land.rs",
5383            "src/web.rs",
5384            "assets/logo.png",
5385            "feat: merge approval from the phone",
5386            "land: ask before merging",
5387            "land: colour the diff",
5388            "coderabbitai",
5389            "this branch never checks the exit code",
5390            "green",
5391        ] {
5392            assert!(html.contains(needle), "the panel must state `{needle}`");
5393        }
5394    }
5395
5396    /// A candidate whose label is `A` and has won, so [`RunState::winner`]
5397    /// resolves to it.
5398    fn winning_candidate(summary: &str) -> Candidate {
5399        Candidate {
5400            index: 0,
5401            label: 'A',
5402            agent: "opus".to_owned(),
5403            branch: "magi/x/A".to_owned(),
5404            worktree: PathBuf::from("/wt/A"),
5405            summary: summary.to_owned(),
5406            stat: String::new(),
5407            files: 1,
5408            commits: 1,
5409            empty: false,
5410            failed: None,
5411            verified_noop: None,
5412            duration_ms: 0,
5413            folded: false,
5414        }
5415    }
5416
5417    fn uncontested_tally() -> Tally {
5418        Tally {
5419            first_choice: BTreeMap::from([('A', 1)]),
5420            borda: BTreeMap::new(),
5421            winner: 'A',
5422            rankings: 1,
5423            unanimous_initial: true,
5424            deliberated: false,
5425            changed_votes: 0,
5426            unanimous_final: true,
5427            tie_break: None,
5428            judges: 1,
5429            present: 1,
5430            quorum: 1,
5431            met_quorum: true,
5432            uncontested: None,
5433        }
5434    }
5435
5436    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5437        ReviewRecord {
5438            attempts: 0,
5439            reviewer,
5440            agent: agent.to_owned(),
5441            summary: summary.to_owned(),
5442            findings: Vec::new(),
5443            vote: None,
5444            failed: None,
5445            duration_ms: 0,
5446        }
5447    }
5448
5449    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5450        let answered = reviews.len();
5451        ReviewRound {
5452            round,
5453            head: "abc1234".to_owned(),
5454            verified_head: None,
5455            verified_at: None,
5456            reviews,
5457            e2e: Vec::new(),
5458            verify_retried: false,
5459            e2e_deferred: false,
5460            e2e_defer_reason: None,
5461            fix: None,
5462            blocking: 0,
5463            answered,
5464            expected: answered,
5465            clean: true,
5466            progressed: false,
5467            vote_split: false,
5468            reconsideration: Vec::new(),
5469            verdict: None,
5470        }
5471    }
5472
5473    #[test]
5474    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5475        let en = panel();
5476        assert!(en.contains("Task"), "{en}");
5477        assert!(en.contains("add retries to the uploader"), "{en}");
5478
5479        let mut state = run_state();
5480        state.config.graph.language = "ja".to_owned();
5481        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5482        assert!(ja.contains("タスク"), "{ja}");
5483        assert!(
5484            ja.contains("add retries to the uploader"),
5485            "the task itself is not translated: {ja}"
5486        );
5487    }
5488
5489    #[test]
5490    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5491        // `run_state()` has no candidates, no tally and no reviews - exactly
5492        // the shape a run has before anything has judged or reviewed it, and
5493        // the panel must not print an empty box for either.
5494        let html = panel();
5495        assert!(!html.contains("What changed"), "{html}");
5496        assert!(!html.contains("Review verdict"), "{html}");
5497    }
5498
5499    #[test]
5500    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5501        let mut state = run_state();
5502        state.candidates = vec![winning_candidate("")];
5503        state.tally = Some(uncontested_tally());
5504        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5505        assert!(
5506            !html.contains("What changed"),
5507            "an empty summary must not render an empty box: {html}"
5508        );
5509    }
5510
5511    #[test]
5512    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5513        let mut state = run_state();
5514        state.candidates = vec![winning_candidate(
5515            "Added a retry loop around the uploader PUT call.",
5516        )];
5517        state.tally = Some(uncontested_tally());
5518        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5519        assert!(en.contains("What changed"), "{en}");
5520        assert!(
5521            en.contains("Added a retry loop around the uploader PUT call."),
5522            "{en}"
5523        );
5524
5525        state.config.graph.language = "ja".to_owned();
5526        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5527        assert!(ja.contains("変更内容"), "{ja}");
5528        assert!(
5529            ja.contains("Added a retry loop around the uploader PUT call."),
5530            "{ja}"
5531        );
5532    }
5533
5534    #[test]
5535    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5536        let mut state = run_state();
5537        state.reviews = vec![
5538            review_round(
5539                1,
5540                vec![review_record(1, "alpha", "found a race, sent back")],
5541            ),
5542            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5543        ];
5544        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5545        assert!(en.contains("Review verdict"), "{en}");
5546        assert!(en.contains("race is fixed, clean"), "{en}");
5547        assert!(
5548            !en.contains("found a race, sent back"),
5549            "only the round that actually cleared the merge should show: {en}"
5550        );
5551
5552        state.config.graph.language = "ja".to_owned();
5553        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5554        assert!(ja.contains("レビューの結論"), "{ja}");
5555        assert!(ja.contains("レビュアー"), "{ja}");
5556        assert!(ja.contains("race is fixed, clean"), "{ja}");
5557    }
5558
5559    /// The `incomplete_review = "warn"` policy (see
5560    /// `graph::Runner::review_loop`) can push a `clean` round to
5561    /// `state.reviews` while one seat's own record still has `failed: Some`
5562    /// and an empty `summary` - a seat that never answered, not one that
5563    /// answered with nothing to say.
5564    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5565        ReviewRecord {
5566            attempts: 0,
5567            reviewer,
5568            agent: agent.to_owned(),
5569            summary: String::new(),
5570            findings: Vec::new(),
5571            vote: None,
5572            failed: Some(reason.to_owned()),
5573            duration_ms: 0,
5574        }
5575    }
5576
5577    #[test]
5578    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5579        let mut state = run_state();
5580        state.reviews = vec![review_round(
5581            1,
5582            vec![
5583                review_record(1, "alpha", "clean, nothing to add"),
5584                unanswered_review_record(2, "beta", "timed out"),
5585            ],
5586        )];
5587        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5588        assert!(en.contains("clean, nothing to add"), "{en}");
5589        assert!(
5590            en.contains("produced no answer: timed out"),
5591            "a seat that never answered must say so, not render a blank box: {en}"
5592        );
5593        assert!(
5594            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5595            "no reviewer box may be left empty: {en}"
5596        );
5597
5598        state.config.graph.language = "ja".to_owned();
5599        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5600        assert!(ja.contains("回答なし: timed out"), "{ja}");
5601    }
5602
5603    #[test]
5604    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5605        let html = panel();
5606        assert!(!html.contains("<script"), "no script survives the csp");
5607        assert!(!html.contains("<form"), "form-action is 'none'");
5608        let pr = green_pr();
5609        assert_eq!(
5610            html.matches("http").count(),
5611            html.matches(pr.url.as_str()).count(),
5612            "the only http url in the panel is the pull request's own link"
5613        );
5614    }
5615
5616    #[test]
5617    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5618        let html = panel();
5619        assert!(
5620            html.contains(">+</span>"),
5621            "an added line carries a `+` in the gutter, not only a background"
5622        );
5623        assert!(
5624            html.contains(">-</span>"),
5625            "a removed line carries a `-` in the gutter, not only a background"
5626        );
5627        assert!(
5628            html.contains(">new line</span>"),
5629            "the marker is moved to the gutter, so the body is printed once without it"
5630        );
5631    }
5632
5633    #[test]
5634    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5635        let total = DIFF_MAX_LINES + 100;
5636        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5637        let html = approval_panel(
5638            &run_state(),
5639            &green_pr(),
5640            NUMSTAT,
5641            &diff,
5642            &[],
5643            "feat: something long",
5644        );
5645        assert!(
5646            html.contains(&format!("100 of {total} diff lines omitted")),
5647            "the note must say exactly how much was cut"
5648        );
5649        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5650        assert!(
5651            !html.contains(&format!("line {DIFF_MAX_LINES}")),
5652            "nothing past the threshold is rendered"
5653        );
5654        assert!(
5655            html.contains("/repo/magi"),
5656            "the note says where the rest is"
5657        );
5658    }
5659
5660    #[test]
5661    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5662        let html = approval_panel(
5663            &run_state(),
5664            &green_pr(),
5665            "1\t2\tsrc/<b>&\"x\"'.rs",
5666            "",
5667            &[],
5668            "subject",
5669        );
5670        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
5671        assert!(
5672            !html.contains("<b>"),
5673            "an agent-influenced path must never become markup"
5674        );
5675    }
5676
5677    #[tokio::test]
5678    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5679        let a = std::path::PathBuf::from("/repo/a");
5680        let b = std::path::PathBuf::from("/repo/b");
5681
5682        let held = repo_merge_lock(&a).lock_owned().await;
5683
5684        // A second, concurrent land run against the *same* repository must
5685        // wait - `try_lock` fails while `held` is alive.
5686        assert!(
5687            repo_merge_lock(&a).try_lock().is_err(),
5688            "a second merge into the same repository must not proceed concurrently"
5689        );
5690
5691        // A run against a *different* repository must not be blocked by it -
5692        // this is what keeps a slow rebase or `gh pr merge` in one
5693        // repository from also stalling a land-approval resume in another.
5694        assert!(
5695            repo_merge_lock(&b).try_lock().is_ok(),
5696            "a different repository's merge lock must be independent"
5697        );
5698
5699        drop(held);
5700        assert!(
5701            repo_merge_lock(&a).try_lock().is_ok(),
5702            "the lock is released once the holder is done"
5703        );
5704    }
5705
5706    #[test]
5707    fn only_the_merge_choice_merges_and_silence_holds() {
5708        let table = [
5709            (None, Approval::Hold),
5710            (Some("merge"), Approval::Merge),
5711            (Some(" merge\n"), Approval::Merge),
5712            (Some("hold"), Approval::Hold),
5713            (Some(""), Approval::Hold),
5714            (Some("yes"), Approval::Hold),
5715        ];
5716        for (answer, want) in table {
5717            assert_eq!(
5718                approval(answer),
5719                want,
5720                "answer {answer:?} must resolve to {want:?}"
5721            );
5722        }
5723    }
5724
5725    #[tokio::test]
5726    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5727        let mut state = landing_state();
5728        state.config.graph.land_approval = true;
5729        let pr = green_pr();
5730
5731        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5732            .await
5733            .unwrap();
5734        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5735        assert!(
5736            !state.parked,
5737            "approval_gate itself never sets `parked`; only its caller does"
5738        );
5739
5740        let store = ask::Questions::open();
5741        let filed: Vec<_> = store
5742            .list()
5743            .into_iter()
5744            .filter(|q| q.run == state.id)
5745            .collect();
5746        assert_eq!(filed.len(), 1, "exactly one question is filed");
5747        assert_eq!(filed[0].node, APPROVAL_NODE);
5748        assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
5749        assert!(filed[0].status.open());
5750
5751        // A second visit - standing in for a resumed run whose slot the
5752        // daemon handed to something else while nobody had answered - must
5753        // find the same question rather than filing a second one.
5754        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5755            .await
5756            .unwrap();
5757        assert_eq!(again, ApprovalGate::Pending);
5758        let still_one = store
5759            .list()
5760            .into_iter()
5761            .filter(|q| q.run == state.id)
5762            .count();
5763        assert_eq!(
5764            still_one, 1,
5765            "asking twice must not double-file the question"
5766        );
5767    }
5768
5769    #[tokio::test]
5770    async fn approving_the_existing_question_is_read_back_as_approved() {
5771        crate::run::pin_test_home();
5772        let mut state = run_state();
5773        state.config.graph.land_approval = true;
5774        let pr = green_pr();
5775        assert_eq!(
5776            approval_gate(&mut state, &pr, "feat: x", None, "abc")
5777                .await
5778                .unwrap(),
5779            ApprovalGate::Pending
5780        );
5781
5782        let store = ask::Questions::open();
5783        let mut q = store
5784            .list()
5785            .into_iter()
5786            .find(|q| q.run == state.id)
5787            .expect("filed above");
5788        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5789        store.put(&mut q).unwrap();
5790
5791        assert_eq!(
5792            approval_gate(&mut state, &pr, "feat: x", None, "abc")
5793                .await
5794                .unwrap(),
5795            ApprovalGate::Approved
5796        );
5797    }
5798
5799    #[tokio::test]
5800    async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
5801        crate::run::pin_test_home();
5802        let store = ask::Questions::open();
5803
5804        let mut held_state = run_state();
5805        held_state.config.graph.land_approval = true;
5806        let pr = green_pr();
5807        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5808            .await
5809            .unwrap();
5810        let mut q = store
5811            .list()
5812            .into_iter()
5813            .find(|q| q.run == held_state.id)
5814            .expect("filed above");
5815        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
5816        store.put(&mut q).unwrap();
5817        assert_eq!(
5818            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5819                .await
5820                .unwrap(),
5821            ApprovalGate::Held
5822        );
5823
5824        let mut abandoned_state = run_state();
5825        abandoned_state.config.graph.land_approval = true;
5826        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5827            .await
5828            .unwrap();
5829        let mut q = store
5830            .list()
5831            .into_iter()
5832            .find(|q| q.run == abandoned_state.id)
5833            .expect("filed above");
5834        q.abandon("no answer within the timeout");
5835        store.put(&mut q).unwrap();
5836        assert_eq!(
5837            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5838                .await
5839                .unwrap(),
5840            ApprovalGate::Held,
5841            "silence must never merge"
5842        );
5843    }
5844
5845    fn contested() -> ContestedHandoff {
5846        let finding = |id: &str, n: u32| crate::verdict::Finding {
5847            id: id.to_owned(),
5848            severity: crate::verdict::Severity::Major,
5849            file: Some("src/a.rs".to_owned()),
5850            line: Some(n),
5851            title: format!("problem {id}"),
5852            detail: String::new(),
5853        };
5854        ContestedHandoff {
5855            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
5856            rejecters: vec![(1, "alpha".to_owned())],
5857        }
5858    }
5859
5860    #[test]
5861    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
5862        let mut state = run_state();
5863        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
5864        state.contested_handoff = Some(contested());
5865        assert!(contested_to_ask(&state).is_some());
5866        state.config.graph.hold_contested_merge = false;
5867        assert!(
5868            contested_to_ask(&state).is_none(),
5869            "the switch restores today"
5870        );
5871    }
5872
5873    #[test]
5874    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
5875        let q = ask::Question::new(
5876            "run-1".to_owned(),
5877            APPROVAL_NODE.to_owned(),
5878            "land".to_owned(),
5879            "Merge?".to_owned(),
5880            String::new(),
5881            vec![APPROVE.to_owned(), HOLD.to_owned()],
5882        );
5883        let none = deputy_brief(&q, None);
5884        assert!(none.contains("could not be read"), "{none}");
5885        assert!(none.contains("Silence is a hold"), "{none}");
5886
5887        let mut state = run_state();
5888        state.pr = Some(crate::run::PrRecord {
5889            url: "https://example.test/pull/7".to_owned(),
5890            number: 7,
5891            state: "open".to_owned(),
5892            checks: "green".to_owned(),
5893            round: 0,
5894            rounds: 3,
5895            red_at_merge: Vec::new(),
5896        });
5897        state.contested_handoff = Some(contested());
5898        let b = deputy_brief(&q, Some(&state));
5899        assert!(b.contains("https://example.test/pull/7"), "{b}");
5900        assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
5901        assert!(b.contains("#1"), "the rejecting seat: {b}");
5902        state.contested_handoff = None;
5903        assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
5904    }
5905
5906    #[test]
5907    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
5908        for lang in ["en", "ja"] {
5909            let mut cfg = crate::config::Config::default();
5910            cfg.graph.language = lang.to_owned();
5911            let w = words(&cfg.graph.language);
5912            let text = w.approval_detail(
5913                "https://github.com/yukimemi/magi/pull/42",
5914                "main",
5915                "feat: x",
5916                Some(&contested()),
5917            );
5918            assert!(text.contains("pull/42"), "{text}");
5919            assert!(
5920                text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
5921                "{text}"
5922            );
5923            assert!(text.contains("R3-1-5"), "{text}");
5924            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
5925            assert!(text.contains("2"), "the rest are counted: {text}");
5926            assert!(text.contains("#1 (alpha)"), "{text}");
5927        }
5928        let plain = words("en").approval_detail("u", "main", "s", None);
5929        assert!(!plain.contains("reject"), "{plain}");
5930    }
5931
5932    #[tokio::test]
5933    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
5934        crate::run::pin_test_home();
5935        let mut state = run_state();
5936        state.config.graph.land_approval = false;
5937        state.contested_handoff = Some(contested());
5938        let pr = green_pr();
5939        let c = contested_to_ask(&state);
5940        assert_eq!(
5941            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5942                .await
5943                .unwrap(),
5944            ApprovalGate::Pending,
5945            "silence is a hold"
5946        );
5947        let store = ask::Questions::open();
5948        let filed: Vec<_> = store
5949            .list()
5950            .into_iter()
5951            .filter(|q| q.run == state.id)
5952            .collect();
5953        assert_eq!(filed.len(), 1);
5954        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
5955
5956        assert_eq!(
5957            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5958                .await
5959                .unwrap(),
5960            ApprovalGate::Pending
5961        );
5962        let mut q = store
5963            .list()
5964            .into_iter()
5965            .find(|q| q.run == state.id)
5966            .unwrap();
5967        assert_eq!(q.id, filed[0].id, "the same question after a resume");
5968        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5969        store.put(&mut q).unwrap();
5970        assert_eq!(
5971            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5972                .await
5973                .unwrap(),
5974            ApprovalGate::Approved
5975        );
5976    }
5977
5978    #[test]
5979    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
5980        let rows = parse_numstat(NUMSTAT);
5981        assert_eq!(
5982            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
5983            ["src/web.rs", "src/land.rs", "assets/logo.png"]
5984        );
5985        assert_eq!(rows[2].added, None, "a binary file has no line counts");
5986    }
5987    #[test]
5988    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
5989        // Reported from a real run: the merge question arrived in English on a
5990        // repository with `language = "ja"`. magi's own strings have to follow
5991        // that setting too - "it is a literal in Rust" is not an answer.
5992        let mut state = run_state();
5993        state.config.graph.language = "ja".to_owned();
5994        let pr = green_pr();
5995        let commits = ["c1".to_owned()];
5996
5997        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
5998        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
5999        assert!(ja.contains("squash されるコミット"), "{ja}");
6000        assert!(ja.contains("レビューコメント"), "{ja}");
6001        assert!(ja.contains("差分"), "{ja}");
6002        assert!(
6003            !ja.contains("Commits being squashed"),
6004            "no English left over"
6005        );
6006
6007        let w = words("ja");
6008        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6009        assert!(
6010            w.approval_detail("http://x/1", "main", "feat: x", None)
6011                .contains("パネル")
6012        );
6013
6014        // The evidence itself is language-neutral and must survive either way.
6015        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6016        assert!(ja.contains("feat: x"), "nor is the merge subject");
6017
6018        // English stays the default, and a language magi cannot check falls
6019        // back to it rather than shipping a guess.
6020        state.config.graph.language = "en".to_owned();
6021        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6022        assert!(en.contains("Commits being squashed"), "{en}");
6023        assert_eq!(words("Klingon").html_lang, "en");
6024    }
6025
6026    /// A `gh pr list` result naming exactly one pull request whose base and
6027    /// merge time both fit the run is exactly the case
6028    /// [`find_external_merge`] exists to act on.
6029    #[test]
6030    fn pick_open_pr_classifies_by_count_and_base() {
6031        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6032        assert_eq!(
6033            pick_open_pr(one, "main").unwrap(),
6034            OpenPr::One {
6035                url: "https://x/pull/58".into(),
6036                title: "t".into()
6037            }
6038        );
6039        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6040        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6041        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6042                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6043        assert_eq!(
6044            pick_open_pr(two, "main").unwrap(),
6045            OpenPr::Many(vec!["u1".into(), "u2".into()])
6046        );
6047        assert!(pick_open_pr("not json", "main").is_err());
6048        // An incomplete record is an error, never "nothing open".
6049        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6050        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6051    }
6052
6053    #[test]
6054    fn pick_merged_pr_picks_the_unique_match() {
6055        let json = r#"[
6056            {"url": "https://github.com/o/r/pull/42", "number": 42,
6057             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6058        ]"#;
6059        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6060        let found = pick_merged_pr(json, "main", created_at)
6061            .expect("valid json")
6062            .expect("one unambiguous match");
6063        assert_eq!(found.url, "https://github.com/o/r/pull/42");
6064        assert_eq!(found.number, 42);
6065    }
6066
6067    /// Two candidates surviving the filter is exactly as uninformative as
6068    /// zero — a branch name can be reused across runs — so neither is
6069    /// preferred over the other and nothing is recorded automatically.
6070    #[test]
6071    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6072        let json = r#"[
6073            {"url": "https://github.com/o/r/pull/42", "number": 42,
6074             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6075            {"url": "https://github.com/o/r/pull/43", "number": 43,
6076             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6077        ]"#;
6078        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6079        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6080    }
6081
6082    /// A pull request that targets a different base branch cannot be this
6083    /// run's, whatever its head branch is named — a reused branch name from
6084    /// an unrelated task must not be recorded as this run's merge.
6085    #[test]
6086    fn pick_merged_pr_ignores_a_different_base_branch() {
6087        let json = r#"[
6088            {"url": "https://github.com/o/r/pull/42", "number": 42,
6089             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6090        ]"#;
6091        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6092        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6093    }
6094
6095    /// A pull request merged before this run was even created cannot be this
6096    /// run's winner, no matter how its head branch is spelled.
6097    #[test]
6098    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6099        let json = r#"[
6100            {"url": "https://github.com/o/r/pull/42", "number": 42,
6101             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6102        ]"#;
6103        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6104        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6105    }
6106
6107    #[test]
6108    fn slug_of_pr_url_reads_host_owner_and_repo() {
6109        assert_eq!(
6110            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6111            Some("github.com/yukimemi/shun")
6112        );
6113    }
6114
6115    #[test]
6116    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6117        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6118        assert_eq!(slug_of_pr_url("not a url at all"), None);
6119        assert_eq!(slug_of_pr_url("https://github.com"), None);
6120    }
6121
6122    #[test]
6123    fn slug_of_repo_url_reads_host_owner_and_repo() {
6124        assert_eq!(
6125            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6126            Some("github.com/yukimemi/magi")
6127        );
6128        assert_eq!(slug_of_repo_url("https://github.com"), None);
6129    }
6130
6131    #[test]
6132    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6133        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6134            .expect("same repo, different case");
6135    }
6136
6137    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
6138    /// own in-progress run and rewrote its status from a pull request in a
6139    /// completely different repository. This is the guard that must catch
6140    /// that even when an explicit (but wrong) id is given.
6141    #[test]
6142    fn ensure_same_repo_refuses_a_different_repo() {
6143        let err =
6144            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6145        let msg = format!("{err:#}");
6146        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6147        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6148    }
6149
6150    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
6151    /// instance mirroring a `github.com` repository's name, say) must not be
6152    /// treated as the same repository just because the trailing path
6153    /// matches.
6154    #[test]
6155    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6156        let err = ensure_same_repo(
6157            "github.com/yukimemi/magi",
6158            "github.example.com/yukimemi/magi",
6159        )
6160        .unwrap_err();
6161        let msg = format!("{err:#}");
6162        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6163        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6164    }
6165
6166    /// No winner decided yet means there is no branch to ask GitHub about at
6167    /// all — `find_external_merge` must return `None` without ever spawning
6168    /// `gh`, which this proves by never providing a real repository to spawn
6169    /// it in.
6170    #[tokio::test]
6171    async fn find_external_merge_returns_none_without_a_winner() {
6172        let state = RunState::new(
6173            PathBuf::from("/no/such/repo"),
6174            "main".to_owned(),
6175            "0000000000000000000000000000000000000000".to_owned(),
6176            "irrelevant".to_owned(),
6177            crate::config::Config::default(),
6178        );
6179        assert_eq!(find_external_merge(&state).await.unwrap(), None);
6180    }
6181
6182    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6183        let mut run = RunState::new(
6184            PathBuf::from(repo),
6185            "main".to_owned(),
6186            "abcdef1234".to_owned(),
6187            "x".to_owned(),
6188            crate::config::Config::default(),
6189        );
6190        run.id = id.to_owned();
6191        run.status = status;
6192        run.pr = Some(crate::run::PrRecord {
6193            number: url.rsplit('/').next().unwrap().parse().unwrap(),
6194            url: url.to_owned(),
6195            state: state.to_owned(),
6196            checks: "red".to_owned(),
6197            round: 0,
6198            rounds: 2,
6199            red_at_merge: Vec::new(),
6200        });
6201        run.save_under(home).unwrap();
6202    }
6203
6204    fn recorded(home: &Path, id: &str) -> String {
6205        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6206        serde_json::from_str::<RunState>(&body)
6207            .unwrap()
6208            .pr
6209            .unwrap()
6210            .state
6211    }
6212
6213    const PR: &str = "https://github.com/o/r/pull/7";
6214
6215    #[test]
6216    fn write_through_updates_predecessors_and_siblings_only() {
6217        let tmp = tempfile::tempdir().unwrap();
6218        let h = tmp.path();
6219        pr_run(
6220            h,
6221            "20261004-100000-aaaa",
6222            "/repo/r",
6223            RunStatus::Superseded,
6224            PR,
6225            "open",
6226        );
6227        pr_run(
6228            h,
6229            "20261004-100100-bbbb",
6230            "/repo/r",
6231            RunStatus::Blocked,
6232            PR,
6233            "open",
6234        );
6235        // Not terminal: a driver may be writing it.
6236        pr_run(
6237            h,
6238            "20261004-100200-cccc",
6239            "/repo/r",
6240            RunStatus::Landing,
6241            PR,
6242            "open",
6243        );
6244        // Another repository's pull request with the same number.
6245        pr_run(
6246            h,
6247            "20261004-100300-dddd",
6248            "/repo/other",
6249            RunStatus::Blocked,
6250            "https://github.com/o/other/pull/7",
6251            "open",
6252        );
6253        // A different pull request of the same repository.
6254        pr_run(
6255            h,
6256            "20261004-100400-eeee",
6257            "/repo/r",
6258            RunStatus::Blocked,
6259            "https://github.com/o/r/pull/8",
6260            "open",
6261        );
6262        pr_run(
6263            h,
6264            "20261004-100500-ffff",
6265            "/repo/r",
6266            RunStatus::Merged,
6267            PR,
6268            "open",
6269        );
6270        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6271
6272        assert_eq!(
6273            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6274            2
6275        );
6276        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6277        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6278        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6279        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6280        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6281        // The source's own record is the caller's to write.
6282        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6283        // Idempotent.
6284        assert_eq!(
6285            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6286            0
6287        );
6288        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6289        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6290    }
6291
6292    #[test]
6293    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6294        let tmp = tempfile::tempdir().unwrap();
6295        let h = tmp.path();
6296        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6297        pr_run(
6298            h,
6299            "20261004-100000-aaaa",
6300            "/repo/r",
6301            RunStatus::Superseded,
6302            &url(1),
6303            "open",
6304        );
6305        pr_run(
6306            h,
6307            "20261004-100100-bbbb",
6308            "/repo/r",
6309            RunStatus::Blocked,
6310            &url(2),
6311            "open",
6312        );
6313        pr_run(
6314            h,
6315            "20261004-100200-cccc",
6316            "/repo/r",
6317            RunStatus::Ready,
6318            &url(3),
6319            "open",
6320        );
6321        pr_run(
6322            h,
6323            "20261004-100300-dddd",
6324            "/repo/r",
6325            RunStatus::Ready,
6326            &url(4),
6327            "open",
6328        );
6329        pr_run(
6330            h,
6331            "20261004-100400-eeee",
6332            "/repo/r",
6333            RunStatus::Implementing,
6334            &url(1),
6335            "open",
6336        );
6337        assert_eq!(stale_open_prs(h).len(), 4);
6338
6339        let mut known = BTreeMap::new();
6340        known.insert(url(1), PrLifecycle::Merged);
6341        known.insert(url(2), PrLifecycle::Closed);
6342        known.insert(url(3), PrLifecycle::Open);
6343        // #4: the forge could not be read, so it has no answer.
6344        assert_eq!(apply_pr_states(h, &known), 2);
6345        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6346        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6347        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6348        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6349        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6350        assert_eq!(apply_pr_states(h, &known), 0);
6351    }
6352
6353    // --- the land loop against a scripted forge -------------------------
6354
6355    use std::collections::VecDeque;
6356    use std::sync::Mutex;
6357
6358    /// Answers views from a script (the last one repeats), merges from a
6359    /// queue, and records every call so a test can assert the order.
6360    struct Scripted {
6361        views: Mutex<VecDeque<Seen>>,
6362        merges: Mutex<VecDeque<(bool, String)>>,
6363        fix: Mutex<Option<Fixed>>,
6364        log: Mutex<Vec<&'static str>>,
6365        argvs: Mutex<Vec<Vec<String>>>,
6366        required: Mutex<Option<BTreeSet<String>>>,
6367        /// Set once a merge answered ok: the forge then reports `merged`,
6368        /// unless `queued` says the merge only entered a queue.
6369        merged: Mutex<bool>,
6370        queued: Mutex<bool>,
6371        /// Views fail once a merge answered ok.
6372        unreadable_after_merge: Mutex<bool>,
6373    }
6374
6375    impl Scripted {
6376        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6377            Self {
6378                views: Mutex::new(views.into()),
6379                merges: Mutex::new(
6380                    merges
6381                        .into_iter()
6382                        .map(|(ok, m)| (ok, m.to_owned()))
6383                        .collect(),
6384                ),
6385                fix: Mutex::new(None),
6386                log: Mutex::new(Vec::new()),
6387                argvs: Mutex::new(Vec::new()),
6388                required: Mutex::new(None),
6389                merged: Mutex::new(false),
6390                queued: Mutex::new(false),
6391                unreadable_after_merge: Mutex::new(false),
6392            }
6393        }
6394        fn argvs(&self) -> Vec<Vec<String>> {
6395            self.argvs.lock().unwrap().clone()
6396        }
6397        fn calls(&self) -> Vec<&'static str> {
6398            self.log.lock().unwrap().clone()
6399        }
6400    }
6401
6402    impl Forge for Scripted {
6403        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6404            self.log.lock().unwrap().push("view");
6405            if *self.unreadable_after_merge.lock().unwrap()
6406                && !self.argvs.lock().unwrap().is_empty()
6407            {
6408                anyhow::bail!("forge unreachable");
6409            }
6410            let mut v = self.views.lock().unwrap();
6411            let mut seen = if v.len() > 1 {
6412                v.pop_front().unwrap()
6413            } else {
6414                v[0].clone()
6415            };
6416            if *self.merged.lock().unwrap() {
6417                seen.pr.state = PrLifecycle::Merged;
6418            }
6419            Ok(seen)
6420        }
6421        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6422            self.log.lock().unwrap().push("merge");
6423            self.argvs.lock().unwrap().push(argv.to_vec());
6424            let out = self
6425                .merges
6426                .lock()
6427                .unwrap()
6428                .pop_front()
6429                .expect("unscripted merge");
6430            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6431            {
6432                *self.merged.lock().unwrap() = true;
6433            }
6434            Ok(out)
6435        }
6436        async fn poll(&self) {
6437            self.log.lock().unwrap().push("poll");
6438        }
6439        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6440            self.required.lock().unwrap().clone()
6441        }
6442        async fn fix(
6443            &self,
6444            _state: &mut RunState,
6445            _pr: &PrState,
6446            _round: usize,
6447            _budget: usize,
6448            _reason: &str,
6449            _logs: &str,
6450        ) -> Result<Fixed> {
6451            self.log.lock().unwrap().push("fix");
6452            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6453        }
6454    }
6455
6456    const REFUSED: &str =
6457        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6458
6459    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6460        let mut pr = green_pr();
6461        pr.checks = checks;
6462        pr.blocking = Blocking::of(merge_state);
6463        if !comments {
6464            pr.review_comments.clear();
6465        }
6466        Seen {
6467            pr,
6468            title: "feat: x".to_owned(),
6469            failing_urls: Vec::new(),
6470            head: head.to_owned(),
6471            rollup_head: head.to_owned(),
6472            merge_state: merge_state.to_owned(),
6473            contexts: Vec::new(),
6474            base: "main".to_owned(),
6475        }
6476    }
6477
6478    fn landing_state() -> RunState {
6479        crate::run::pin_test_home();
6480        let mut state = run_state();
6481        state.config.graph.land_approval = false;
6482        state
6483    }
6484
6485    #[test]
6486    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6487        assert!(!awaiting_new_head(None, "aaa"));
6488        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6489        assert!(awaiting_new_head(Some("abc123"), "def456"));
6490        assert!(awaiting_new_head(Some("abc123"), ""));
6491    }
6492
6493    #[test]
6494    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6495        let open = |c, m: &str| seen("a", c, m, false);
6496        let table = [
6497            (None, false, Refused::Pending),
6498            (
6499                Some(open(Checks::Pending, "BLOCKED")),
6500                false,
6501                Refused::Pending,
6502            ),
6503            (
6504                Some(open(Checks::Unknown, "BLOCKED")),
6505                false,
6506                Refused::Pending,
6507            ),
6508            (
6509                Some(open(Checks::Green, "UNKNOWN")),
6510                false,
6511                Refused::Pending,
6512            ),
6513            (Some(open(Checks::Green, "")), false, Refused::Pending),
6514            (
6515                Some(open(Checks::Green, "BLOCKED")),
6516                false,
6517                Refused::Recheck,
6518            ),
6519            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6520        ];
6521        for (after, rechecked, want) in table {
6522            assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6523        }
6524        let mut closed = open(Checks::Green, "CLEAN");
6525        closed.pr.state = PrLifecycle::Closed;
6526        assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6527    }
6528
6529    #[tokio::test]
6530    async fn a_normal_landing_merges_on_the_first_look() {
6531        let mut state = landing_state();
6532        let forge = Scripted::new(
6533            vec![seen("a", Checks::Green, "CLEAN", false)],
6534            vec![(true, "")],
6535        );
6536        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6537            .await
6538            .unwrap();
6539        // One fresh read, then the head-bound merge.
6540        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6541        assert_eq!(state.status, RunStatus::Merged);
6542    }
6543
6544    #[tokio::test]
6545    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6546        let mut state = landing_state();
6547        let forge = Scripted::new(
6548            vec![seen("a", Checks::Green, "CLEAN", false)],
6549            std::iter::repeat_n((true, ""), 100).collect(),
6550        );
6551        *forge.queued.lock().unwrap() = true;
6552        let task = async {
6553            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6554                .await
6555                .unwrap();
6556        };
6557        // Still open after the command succeeded: it keeps watching and
6558        // never records a merge (it stops at the wait ceiling instead).
6559        task.await;
6560        assert_ne!(state.status, RunStatus::Merged);
6561    }
6562
6563    #[tokio::test]
6564    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6565        let mut state = landing_state();
6566        let forge = Scripted::new(
6567            vec![seen("a", Checks::Green, "CLEAN", false)],
6568            std::iter::repeat_n((true, ""), 100).collect(),
6569        );
6570        *forge.unreadable_after_merge.lock().unwrap() = true;
6571        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6572            .await
6573            .ok();
6574        assert_ne!(state.status, RunStatus::Merged);
6575    }
6576
6577    #[tokio::test]
6578    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6579        let mut state = landing_state();
6580        let forge = Scripted::new(
6581            vec![
6582                seen("old", Checks::Green, "CLEAN", true),
6583                // The forge has not moved to the new head yet: still green.
6584                seen("old", Checks::Green, "CLEAN", true),
6585                seen("new", Checks::Pending, "BLOCKED", true),
6586                seen("new", Checks::Green, "CLEAN", true),
6587            ],
6588            vec![(true, "")],
6589        );
6590        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6591            head: "NEW".to_owned(),
6592        });
6593        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6594            .await
6595            .unwrap();
6596        assert_eq!(
6597            forge.calls(),
6598            [
6599                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
6600                "view"
6601            ]
6602        );
6603        assert_eq!(state.status, RunStatus::Merged);
6604    }
6605
6606    #[tokio::test]
6607    async fn a_head_that_never_arrives_stops_naming_both_commits() {
6608        let mut state = landing_state();
6609        let forge = Scripted::new(
6610            vec![
6611                seen("old", Checks::Green, "CLEAN", true),
6612                seen("someone-elses", Checks::Green, "CLEAN", true),
6613            ],
6614            vec![],
6615        );
6616        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6617            head: "mine".to_owned(),
6618        });
6619        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6620            .await
6621            .unwrap();
6622        assert!(!forge.calls().contains(&"merge"));
6623        let why = state.merge.as_ref().unwrap().detail.clone();
6624        assert!(
6625            why.contains("mine") && why.contains("someone-elses"),
6626            "{why}"
6627        );
6628        assert_eq!(state.status, RunStatus::Blocked);
6629    }
6630
6631    #[tokio::test]
6632    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6633        let mut state = landing_state();
6634        let forge = Scripted::new(
6635            vec![
6636                seen("a", Checks::Green, "CLEAN", false),
6637                seen("a", Checks::Green, "CLEAN", false),
6638                seen("a", Checks::Pending, "BLOCKED", false),
6639                seen("a", Checks::Pending, "BLOCKED", false),
6640                seen("a", Checks::Green, "CLEAN", false),
6641            ],
6642            vec![(false, REFUSED), (true, "")],
6643        );
6644        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6645            .await
6646            .unwrap();
6647        assert_eq!(
6648            forge.calls(),
6649            [
6650                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
6651                "view"
6652            ]
6653        );
6654        assert_eq!(state.status, RunStatus::Merged);
6655    }
6656
6657    #[tokio::test]
6658    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
6659        let mut state = landing_state();
6660        let forge = Scripted::new(
6661            vec![
6662                seen("a", Checks::Green, "CLEAN", false),
6663                seen("a", Checks::Green, "BLOCKED", false),
6664            ],
6665            vec![(false, REFUSED), (false, REFUSED)],
6666        );
6667        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6668            .await
6669            .unwrap();
6670        // One re-look is allowed for the forge's own lag, then it is final.
6671        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
6672        let why = state.merge.as_ref().unwrap().detail.clone();
6673        assert!(
6674            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
6675            "{why}"
6676        );
6677        assert_eq!(state.status, RunStatus::Blocked);
6678    }
6679
6680    #[test]
6681    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
6682        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
6683        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
6684        // The pull request is still on the commit before the push.
6685        assert_eq!(bound_head("old", "old", Some("new")), None);
6686        // The checks are the previous commit's.
6687        assert_eq!(bound_head("new", "old", Some("new")), None);
6688        assert_eq!(bound_head("new", "old", None), None);
6689        // Nothing readable.
6690        assert_eq!(bound_head("", "", None), None);
6691        assert_eq!(bound_head("", "", Some("new")), None);
6692        assert_eq!(bound_head("abc", "", None), None);
6693    }
6694
6695    fn view_json(head: &str) -> String {
6696        format!(
6697            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
6698            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
6699            "reviews":[],"comments":[]}}"#
6700        )
6701    }
6702
6703    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
6704        format!(
6705            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
6706            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
6707            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
6708            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
6709        )
6710    }
6711
6712    #[test]
6713    fn rollup_is_bound_to_the_commit_in_the_same_node() {
6714        // The view already points at the new head, but the node still answers
6715        // for the old commit with its red check: the head stays unbound.
6716        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
6717        assert_eq!(s.rollup_head, "old");
6718        assert_eq!(s.pr.checks, Checks::Red);
6719        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
6720        assert_eq!(s.failing_urls.len(), 1);
6721    }
6722
6723    #[test]
6724    fn checks_come_from_the_node_not_the_view() {
6725        let view = view_json("new").replace(
6726            r#""reviews""#,
6727            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
6728        );
6729        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
6730        assert_eq!(s.pr.checks, Checks::Green);
6731        assert!(s.pr.failing.is_empty());
6732        assert_eq!(
6733            bound_head(&s.head, &s.rollup_head, Some("new")),
6734            Some("new")
6735        );
6736    }
6737
6738    #[test]
6739    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
6740        for node in [
6741            None,
6742            Some("not json".to_owned()),
6743            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
6744            Some(node_json("new", "SUCCESS", true)),
6745        ] {
6746            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
6747            assert!(s.rollup_head.is_empty());
6748            assert_eq!(s.pr.checks, Checks::Unknown);
6749            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
6750        }
6751    }
6752
6753    #[test]
6754    fn the_merge_command_is_pinned_to_the_observed_head() {
6755        let argv = merge_argv_at(7, "feat: x", "deadbeef");
6756        let at = argv
6757            .iter()
6758            .position(|a| a == "--match-head-commit")
6759            .unwrap();
6760        assert_eq!(argv[at + 1], "deadbeef");
6761    }
6762
6763    #[tokio::test]
6764    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
6765        let mut state = landing_state();
6766        // The pull request is on the pushed head but the rollup is still the
6767        // previous commit's red, non-required result.
6768        let mut stale = seen("new", Checks::Red, "CLEAN", false);
6769        stale.rollup_head = "old".to_owned();
6770        let forge = Scripted::new(
6771            vec![seen("old", Checks::Green, "CLEAN", true), stale],
6772            vec![],
6773        );
6774        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6775            head: "new".to_owned(),
6776        });
6777        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6778            .await
6779            .unwrap();
6780        assert!(!forge.calls().contains(&"merge"));
6781        assert_eq!(state.status, RunStatus::Blocked);
6782        let why = state.merge.as_ref().unwrap().detail.clone();
6783        assert!(why.contains("new") && why.contains("old"), "{why}");
6784    }
6785
6786    #[test]
6787    fn a_refusal_read_against_another_commits_checks_is_pending() {
6788        let mut after = seen("a", Checks::Green, "BLOCKED", false);
6789        after.rollup_head = "old".to_owned();
6790        assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
6791    }
6792
6793    #[tokio::test]
6794    async fn a_matching_head_with_red_non_required_checks_still_merges() {
6795        let mut state = landing_state();
6796        let forge = Scripted::new(
6797            vec![seen("a", Checks::Red, "CLEAN", false)],
6798            vec![(true, "")],
6799        );
6800        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6801            .await
6802            .unwrap();
6803        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6804        assert_eq!(state.status, RunStatus::Merged);
6805    }
6806
6807    #[tokio::test]
6808    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
6809        let mut state = landing_state();
6810        let forge = Scripted::new(
6811            vec![
6812                seen("a", Checks::Green, "CLEAN", false),
6813                seen("a", Checks::Green, "CLEAN", false),
6814                // Re-viewed after the refusal: someone pushed.
6815                seen("b", Checks::Green, "BLOCKED", false),
6816                seen("b", Checks::Green, "CLEAN", false),
6817            ],
6818            vec![(false, REFUSED), (true, "")],
6819        );
6820        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6821            .await
6822            .unwrap();
6823        assert_eq!(
6824            forge.calls(),
6825            [
6826                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
6827            ]
6828        );
6829        assert_eq!(state.status, RunStatus::Merged);
6830    }
6831
6832    fn merged_view(head: &str) -> Seen {
6833        let mut m = seen(head, Checks::Green, "CLEAN", false);
6834        m.pr.state = PrLifecycle::Merged;
6835        m
6836    }
6837
6838    fn has(argv: &[String], flag: &str) -> bool {
6839        argv.iter().any(|a| a == flag)
6840    }
6841
6842    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
6843        let at = argv.iter().position(|a| a == flag)?;
6844        argv.get(at + 1).map(String::as_str)
6845    }
6846
6847    const URL: &str = "https://github.com/o/r/pull/42";
6848
6849    #[tokio::test]
6850    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
6851        let mut state = landing_state();
6852        let forge = Scripted::new(
6853            vec![
6854                seen("abc", Checks::Green, "CLEAN", false),
6855                seen("abc", Checks::Green, "CLEAN", false),
6856            ],
6857            vec![(true, "")],
6858        );
6859        land_with(&mut state, URL, &forge).await.unwrap();
6860        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6861        let argv = &forge.argvs()[0];
6862        assert!(has(argv, "--squash") && has(argv, "--subject"));
6863        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
6864        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
6865        assert_eq!(state.status, RunStatus::Merged);
6866        assert!(state.land_armed_head.is_none());
6867    }
6868
6869    #[tokio::test]
6870    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
6871        let mut state = landing_state();
6872        state.land_armed_head = Some("a".to_owned());
6873        let forge = Scripted::new(
6874            vec![seen("a", Checks::Green, "CLEAN", false)],
6875            vec![(true, ""), (true, "")],
6876        );
6877        land_with(&mut state, URL, &forge).await.unwrap();
6878        let argvs = forge.argvs();
6879        assert!(has(&argvs[0], "--disable-auto"));
6880        assert!(!has(&argvs[1], "--auto"));
6881        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
6882        assert_eq!(state.status, RunStatus::Merged);
6883        assert!(state.land_armed_head.is_none());
6884    }
6885
6886    #[tokio::test]
6887    async fn an_approval_never_carries_over_to_a_new_head() {
6888        crate::run::pin_test_home();
6889        let mut state = run_state();
6890        state.config.graph.land_approval = true;
6891        let pr = green_pr();
6892        let store = ask::Questions::open();
6893
6894        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
6895            .await
6896            .unwrap();
6897        let mut q = store
6898            .list()
6899            .into_iter()
6900            .find(|q| q.run == state.id)
6901            .unwrap();
6902        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6903        store.put(&mut q).unwrap();
6904        assert_eq!(
6905            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
6906                .await
6907                .unwrap(),
6908            ApprovalGate::Approved,
6909            "the same head keeps its approval"
6910        );
6911
6912        // A new head is a new question, not the old word.
6913        assert_eq!(
6914            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6915                .await
6916                .unwrap(),
6917            ApprovalGate::Pending
6918        );
6919        let all: Vec<_> = store
6920            .list()
6921            .into_iter()
6922            .filter(|q| q.run == state.id)
6923            .collect();
6924        assert_eq!(all.len(), 2);
6925
6926        // A question recorded before heads were tracked is not reused either.
6927        state.land_approval = None;
6928        assert_eq!(
6929            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6930                .await
6931                .unwrap(),
6932            ApprovalGate::Pending
6933        );
6934        let open = store
6935            .list()
6936            .into_iter()
6937            .filter(|q| q.run == state.id && q.status.open())
6938            .count();
6939        assert_eq!(open, 1, "the superseded question was retired");
6940    }
6941
6942    #[tokio::test]
6943    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
6944        let mut state = landing_state();
6945        let forge = Scripted::new(
6946            vec![
6947                seen("a", Checks::Green, "CLEAN", false),
6948                // The fresh read before the merge: someone pushed.
6949                seen("b", Checks::Green, "CLEAN", false),
6950            ],
6951            vec![(true, "")],
6952        );
6953        land_with(&mut state, URL, &forge).await.unwrap();
6954        let argvs = forge.argvs();
6955        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
6956        assert!(!has(&argvs[0], "--auto"));
6957        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
6958        assert_eq!(state.status, RunStatus::Merged);
6959    }
6960
6961    fn passing(label: &str) -> CheckInfo {
6962        CheckInfo {
6963            label: label.to_owned(),
6964            verdict: Verdict::Pass,
6965            required: Some(false),
6966        }
6967    }
6968
6969    fn names(xs: &[&str]) -> BTreeSet<String> {
6970        xs.iter().map(|x| (*x).to_owned()).collect()
6971    }
6972
6973    #[test]
6974    fn a_required_check_the_rollup_never_listed_is_named() {
6975        let req = names(&["build"]);
6976        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
6977        assert!(why.contains("never reported: build"), "{why}");
6978        assert!(!why.contains("probably waiting for a review"), "{why}");
6979    }
6980
6981    #[test]
6982    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
6983        let why = waiting_on("BLOCKED", &[passing("review")], None);
6984        assert!(why.contains("could not be read"), "{why}");
6985        assert!(!why.contains("probably waiting for a review"), "{why}");
6986    }
6987
6988    #[test]
6989    fn all_required_reported_keeps_the_review_guess() {
6990        let req = names(&["build"]);
6991        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
6992        assert!(why.contains("probably waiting for a review"), "{why}");
6993        assert!(!why.contains("never reported"), "{why}");
6994    }
6995
6996    #[test]
6997    fn required_names_match_the_rollup_ignoring_case_only() {
6998        let req = names(&["Build"]);
6999        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7000        assert!(!why.contains("never reported"), "{why}");
7001    }
7002
7003    #[test]
7004    fn required_contexts_are_read_from_protection_and_rulesets() {
7005        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7006        assert_eq!(
7007            parse_classic_required(classic),
7008            Some(names(&["build", "lint"]))
7009        );
7010        let rules = r#"[{"type":"pull_request","parameters":{}},
7011            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7012        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7013        assert_eq!(parse_ruleset_required("nope"), None);
7014        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7015    }
7016
7017    #[test]
7018    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7019        let shown = BTreeSet::new();
7020        let ok = seen("a", Checks::Green, "CLEAN", false);
7021        let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7022        assert!(guard(Some(&ok)));
7023        assert!(!guard(None));
7024        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7025        let mut stale = ok.clone();
7026        stale.rollup_head = "old".to_owned();
7027        assert!(!guard(Some(&stale)));
7028        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7029        assert!(!guard(Some(&merged_view("a"))));
7030    }
7031
7032    #[test]
7033    fn the_rollup_node_carries_whether_each_check_is_required() {
7034        let node = node_json("new", "SUCCESS", false)
7035            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7036        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7037        assert_eq!(s.contexts.len(), 1);
7038        assert_eq!(s.contexts[0].required, Some(true));
7039        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7040        assert_eq!(s.contexts[0].required, None);
7041    }
7042
7043    #[tokio::test]
7044    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7045        let mut state = landing_state();
7046        state.land_armed_head = Some("a".to_owned());
7047        let forge = Scripted::new(
7048            vec![seen("a", Checks::Green, "CLEAN", true)],
7049            vec![(false, "disable exploded")],
7050        );
7051        land_with(&mut state, URL, &forge).await.unwrap();
7052        assert!(!forge.calls().contains(&"fix"));
7053        assert_eq!(state.status, RunStatus::Blocked);
7054        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7055        let why = state.merge.as_ref().unwrap().detail.clone();
7056        assert!(why.contains("disable exploded"), "{why}");
7057    }
7058}