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