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