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_rule(
4349        language,
4350        &state.config.graph.github_language,
4351    ));
4352    if let Some(overlay) = state.config.prompts.overlay("fix") {
4353        let _ = write!(s, "\n\n{overlay}");
4354    }
4355    s
4356}
4357
4358/// Failing log tails, the way the operator collects them by hand:
4359/// `gh run view --log-failed`.
4360async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4361    let mut out = String::new();
4362    for (name, url) in failing.iter().take(MAX_LOGS) {
4363        let args = match (job_of(url), run_of(url)) {
4364            (Some(job), _) => vec![
4365                "run".to_owned(),
4366                "view".to_owned(),
4367                "--log-failed".to_owned(),
4368                "--job".to_owned(),
4369                job,
4370            ],
4371            (None, Some(run)) => vec![
4372                "run".to_owned(),
4373                "view".to_owned(),
4374                run,
4375                "--log-failed".to_owned(),
4376            ],
4377            // Not a GitHub Actions check - an external status has no log here.
4378            (None, None) => continue,
4379        };
4380        let (ok, body) = match gh(repo, &args).await {
4381            Ok(v) => v,
4382            Err(e) => (false, format!("{e:#}")),
4383        };
4384        if !ok && body.trim().is_empty() {
4385            continue;
4386        }
4387        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4388    }
4389    out
4390}
4391
4392/// Job id out of a check's `detailsUrl`
4393/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
4394fn job_of(details_url: &str) -> Option<String> {
4395    let after = details_url.split("/job/").nth(1)?;
4396    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4397    (!id.is_empty()).then_some(id)
4398}
4399
4400/// Workflow run id out of a check's `detailsUrl`.
4401pub(crate) fn run_of(details_url: &str) -> Option<String> {
4402    let after = details_url.split("/actions/runs/").nth(1)?;
4403    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4404    (!id.is_empty()).then_some(id)
4405}
4406
4407/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
4408/// it lands on GitHub, not in front of the operator. Pure so a test can hold
4409/// it to that.
4410fn stop_comment(run_id: &str, why: &str) -> String {
4411    format!(
4412        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4413         The branch is untouched and the run is `{run_id}`. Nothing was merged."
4414    )
4415}
4416
4417/// Leave the pull request open, say why on it, and mark the run blocked.
4418///
4419/// The comment is what makes an unattended stop actionable: the operator wakes
4420/// up to a pull request that explains itself rather than to a silent queue.
4421async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4422    let body = stop_comment(&state.id, why);
4423    let (_, mut body) = crate::github_text::prepare(state, "", &body);
4424    if !body.contains(MARKER) {
4425        body = format!("{MARKER}\n{body}");
4426    }
4427    let posted = gh(
4428        repo,
4429        &[
4430            "pr".to_owned(),
4431            "comment".to_owned(),
4432            pr.number.to_string(),
4433            "--body".to_owned(),
4434            body,
4435        ],
4436    )
4437    .await;
4438    match posted {
4439        Ok((true, _)) => {}
4440        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4441        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4442    }
4443    state.status = RunStatus::Blocked;
4444    state.merge = Some(MergeOutcome {
4445        mode: MergeMode::Pr,
4446        ok: false,
4447        detail: why.to_owned(),
4448        empty: false,
4449    });
4450    state.event("land", format!("stopped: {why}"));
4451    state.save()?;
4452    Ok(())
4453}
4454
4455/// Run `gh` in `repo`, returning success and the combined output.
4456///
4457/// Combined because `gh` reports a refused merge on stderr and the pull request
4458/// json on stdout, and both are evidence.
4459///
4460/// `GH_REPO` is stripped from the child's environment: every call site here
4461/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
4462/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
4463/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
4464/// `GH_REPO` the operator happens to have exported for an unrelated script
4465/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
4466/// below) to a different repository than the one actually on disk - which
4467/// for the same-repo guard in [`correct_manual_merge`] would mean the check
4468/// could be made to agree with whatever repository a forged `--merged` URL
4469/// claims, defeating it entirely.
4470pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4471    let out = tokio::process::Command::new("gh")
4472        .args(args)
4473        .current_dir(cwd)
4474        .env_remove("GH_REPO")
4475        .quiet()
4476        .stdin(std::process::Stdio::null())
4477        .output()
4478        .await
4479        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4480    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4481    let err = String::from_utf8_lossy(&out.stderr);
4482    if body.trim().is_empty() {
4483        body = err.into_owned();
4484    } else if !err.trim().is_empty() {
4485        body.push_str(&err);
4486    }
4487    Ok((out.status.success(), body.trim().to_owned()))
4488}
4489
4490/// Verdict of one entry in the status rollup.
4491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4492pub(crate) enum Verdict {
4493    Pass,
4494    Fail,
4495    Pending,
4496    Unknown,
4497}
4498
4499#[derive(Debug, Deserialize)]
4500#[serde(rename_all = "camelCase")]
4501struct GhPr {
4502    #[serde(default)]
4503    url: String,
4504    #[serde(default)]
4505    number: u64,
4506    #[serde(default)]
4507    state: String,
4508    #[serde(default)]
4509    title: String,
4510    #[serde(default)]
4511    status_check_rollup: Vec<GhCheck>,
4512    /// GitHub's own verdict on whether the pull request can be merged.
4513    ///
4514    /// Worth asking for because it is the only place the *required* check set
4515    /// is applied: the rollup lists every check equally, so a repository that
4516    /// deliberately does not require `coverage` still looks red here. See
4517    /// [`Blocking`].
4518    #[serde(default)]
4519    merge_state_status: String,
4520    /// The commit the pull request currently points at. Compared with the
4521    /// commit a fix round pushed, it is how the loop knows the forge has moved
4522    /// on and the rollup belongs to the new head.
4523    #[serde(default)]
4524    head_ref_oid: String,
4525    #[serde(default)]
4526    base_ref_name: String,
4527    #[serde(default)]
4528    reviews: Vec<GhReview>,
4529    #[serde(default)]
4530    comments: Vec<GhComment>,
4531}
4532
4533/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
4534/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
4535/// status API, which is how CodeRabbit reports - has `context`/`state` and no
4536/// conclusion at all.
4537#[derive(Debug, Deserialize)]
4538#[serde(rename_all = "camelCase")]
4539struct GhCheck {
4540    #[serde(default)]
4541    name: Option<String>,
4542    #[serde(default)]
4543    context: Option<String>,
4544    #[serde(default)]
4545    status: Option<String>,
4546    #[serde(default)]
4547    conclusion: Option<String>,
4548    #[serde(default)]
4549    state: Option<String>,
4550    #[serde(default)]
4551    details_url: Option<String>,
4552    #[serde(default)]
4553    target_url: Option<String>,
4554    /// Whether the base branch requires this check. Only the GraphQL node
4555    /// carries it; `None` is "not read", never "not required".
4556    #[serde(default)]
4557    is_required: Option<bool>,
4558}
4559
4560impl GhCheck {
4561    /// Name to show a human and hand to the fixer.
4562    fn label(&self) -> String {
4563        self.name
4564            .clone()
4565            .or_else(|| self.context.clone())
4566            .unwrap_or_else(|| "(unnamed check)".to_owned())
4567    }
4568
4569    /// Where this check's logs live, when it has any.
4570    fn url(&self) -> Option<&str> {
4571        self.details_url
4572            .as_deref()
4573            .or(self.target_url.as_deref())
4574            .filter(|u| !u.is_empty())
4575    }
4576
4577    /// Did it pass?
4578    ///
4579    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
4580    /// skips release and bot pull requests by design, and a skip that blocked
4581    /// landing would block exactly the pull requests that need no review.
4582    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
4583    /// merging over one is merging over a check that never ran.
4584    fn verdict(&self) -> Verdict {
4585        if let Some(status) = self.status.as_deref() {
4586            if !status.eq_ignore_ascii_case("COMPLETED") {
4587                return Verdict::Pending;
4588            }
4589        }
4590        let outcome = self
4591            .conclusion
4592            .as_deref()
4593            .or(self.state.as_deref())
4594            .unwrap_or("");
4595        match outcome.to_ascii_uppercase().as_str() {
4596            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4597            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4598            | "ACTION_REQUIRED" => Verdict::Fail,
4599            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4600                Verdict::Pending
4601            }
4602            _ => Verdict::Unknown,
4603        }
4604    }
4605}
4606
4607#[derive(Debug, Deserialize)]
4608struct GhAuthor {
4609    #[serde(default)]
4610    login: String,
4611}
4612
4613#[derive(Debug, Deserialize)]
4614struct GhReview {
4615    #[serde(default)]
4616    author: GhAuthor,
4617    #[serde(default)]
4618    body: String,
4619}
4620
4621#[derive(Debug, Deserialize)]
4622struct GhComment {
4623    #[serde(default)]
4624    author: GhAuthor,
4625    #[serde(default)]
4626    body: String,
4627}
4628
4629#[derive(Debug, Deserialize)]
4630struct GhUser {
4631    #[serde(default)]
4632    login: String,
4633}
4634
4635#[derive(Debug, Deserialize)]
4636struct GhInline {
4637    #[serde(default)]
4638    user: GhUser,
4639    #[serde(default)]
4640    path: Option<String>,
4641    #[serde(default)]
4642    line: Option<u64>,
4643    #[serde(default)]
4644    body: String,
4645}
4646
4647impl Default for GhAuthor {
4648    fn default() -> Self {
4649        Self {
4650            login: "(unknown)".to_owned(),
4651        }
4652    }
4653}
4654
4655impl Default for GhUser {
4656    fn default() -> Self {
4657        Self {
4658            login: "(unknown)".to_owned(),
4659        }
4660    }
4661}
4662
4663#[cfg(test)]
4664mod tests {
4665    use super::*;
4666    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4667
4668    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4669        format!(
4670            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4671        )
4672    }
4673
4674    #[test]
4675    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4676        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4677        assert_eq!(
4678            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4679            Ok(())
4680        );
4681        for (json, why) in [
4682            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4683            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4684            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4685            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4686            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4687            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4688        ] {
4689            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4690                .unwrap_err()
4691                .why;
4692            assert!(err.contains(why), "{json}: {err}");
4693        }
4694        // A head that moved on past what was verified is left alone.
4695        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4696        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4697        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4698        assert!(
4699            !closable(
4700                &head_json("x", "main", "OPEN", false),
4701                "magi/27b2/A",
4702                "main",
4703                &[]
4704            )
4705            .unwrap_err()
4706            .retry
4707        );
4708        assert!(is_forge_url("https://github.com/o/r.git"));
4709        assert!(is_forge_url("git@github.com:o/r.git"));
4710        assert!(!is_forge_url("/tmp/origin.git"));
4711        assert!(!is_forge_url("C:\\work\\origin.git"));
4712        assert!(!is_forge_url("file:///tmp/origin.git"));
4713        assert!(forge_unavailable(
4714            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4715        ));
4716        assert!(!forge_unavailable(
4717            "gh pr list failed: error connecting to api.github.com"
4718        ));
4719        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4720        // A record that does not say whether it is a fork is not trusted.
4721        assert!(
4722            closable(
4723                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4724                "b",
4725                "main",
4726                &["aaa".to_owned()]
4727            )
4728            .is_err()
4729        );
4730    }
4731
4732    #[test]
4733    fn the_close_comment_names_the_commit_on_the_base() {
4734        let e = crate::already::Evidence {
4735            proof: crate::already::Proof::PatchId,
4736            tip: "1234567890".to_owned(),
4737            commits: vec!["0e368de0000".to_owned()],
4738        };
4739        let c = superseded_comment("main", &e);
4740        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4741    }
4742
4743    /// 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.
4744    const GREEN_OPEN: &str = r####"{
4745  "url": "https://github.com/yukimemi/magi/pull/10",
4746  "number": 10,
4747  "state": "OPEN",
4748  "mergeStateStatus": "CLEAN",
4749  "statusCheckRollup": [
4750    {
4751      "__typename": "CheckRun",
4752      "conclusion": "SKIPPED",
4753      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4754      "name": "review",
4755      "status": "COMPLETED",
4756      "workflowName": "claude-review"
4757    },
4758    {
4759      "__typename": "CheckRun",
4760      "conclusion": "SUCCESS",
4761      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4762      "name": "check (ubuntu-latest)",
4763      "status": "COMPLETED",
4764      "workflowName": "CI"
4765    },
4766    {
4767      "__typename": "CheckRun",
4768      "conclusion": "SUCCESS",
4769      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4770      "name": "rustfmt",
4771      "status": "COMPLETED",
4772      "workflowName": "CI"
4773    },
4774    {
4775      "__typename": "StatusContext",
4776      "context": "CodeRabbit",
4777      "state": "SUCCESS",
4778      "targetUrl": ""
4779    }
4780  ],
4781  "reviews": [],
4782  "comments": [
4783    {
4784      "author": {
4785        "login": "coderabbitai"
4786      },
4787      "authorAssociation": "NONE",
4788      "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"
4789    }
4790  ]
4791}"####;
4792
4793    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
4794    const RED_OPEN: &str = r####"{
4795  "url": "https://github.com/yukimemi/magi/pull/9",
4796  "number": 9,
4797  "state": "OPEN",
4798  "mergeStateStatus": "UNSTABLE",
4799  "statusCheckRollup": [
4800    {
4801      "__typename": "CheckRun",
4802      "conclusion": "SUCCESS",
4803      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4804      "name": "check (ubuntu-latest)",
4805      "status": "COMPLETED",
4806      "workflowName": "CI"
4807    },
4808    {
4809      "__typename": "CheckRun",
4810      "conclusion": "SUCCESS",
4811      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4812      "name": "rustfmt",
4813      "status": "COMPLETED",
4814      "workflowName": "CI"
4815    },
4816    {
4817      "__typename": "CheckRun",
4818      "conclusion": "FAILURE",
4819      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4820      "name": "editorconfig",
4821      "status": "COMPLETED",
4822      "workflowName": "CI"
4823    },
4824    {
4825      "__typename": "StatusContext",
4826      "context": "CodeRabbit",
4827      "state": "SUCCESS",
4828      "targetUrl": ""
4829    }
4830  ],
4831  "reviews": [],
4832  "comments": [
4833    {
4834      "author": {
4835        "login": "coderabbitai"
4836      },
4837      "authorAssociation": "NONE",
4838      "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"
4839    }
4840  ]
4841}"####;
4842
4843    /// 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.
4844    const PENDING_OPEN: &str = r####"{
4845  "url": "https://github.com/yukimemi/magi/pull/9",
4846  "number": 9,
4847  "state": "OPEN",
4848  "statusCheckRollup": [
4849    {
4850      "__typename": "CheckRun",
4851      "conclusion": "SUCCESS",
4852      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4853      "name": "check (ubuntu-latest)",
4854      "status": "COMPLETED",
4855      "workflowName": "CI"
4856    },
4857    {
4858      "__typename": "CheckRun",
4859      "conclusion": "SUCCESS",
4860      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4861      "name": "rustfmt",
4862      "status": "COMPLETED",
4863      "workflowName": "CI"
4864    },
4865    {
4866      "__typename": "CheckRun",
4867      "conclusion": null,
4868      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4869      "name": "editorconfig",
4870      "status": "IN_PROGRESS",
4871      "workflowName": "CI"
4872    },
4873    {
4874      "__typename": "StatusContext",
4875      "context": "CodeRabbit",
4876      "state": "SUCCESS",
4877      "targetUrl": ""
4878    }
4879  ],
4880  "reviews": [],
4881  "comments": []
4882}"####;
4883
4884    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
4885    const MERGED: &str = r####"{
4886  "url": "https://github.com/yukimemi/magi/pull/16",
4887  "number": 16,
4888  "state": "MERGED",
4889  "statusCheckRollup": [
4890    {
4891      "__typename": "CheckRun",
4892      "conclusion": "SUCCESS",
4893      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4894      "name": "check (ubuntu-latest)",
4895      "status": "COMPLETED",
4896      "workflowName": "CI"
4897    },
4898    {
4899      "__typename": "CheckRun",
4900      "conclusion": "SUCCESS",
4901      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4902      "name": "review",
4903      "status": "COMPLETED",
4904      "workflowName": "claude-review"
4905    }
4906  ],
4907  "reviews": [],
4908  "comments": []
4909}"####;
4910
4911    /// 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.
4912    const REVIEWED_OPEN: &str = r####"{
4913  "url": "https://github.com/yukimemi/magi/pull/12",
4914  "number": 12,
4915  "state": "OPEN",
4916  "statusCheckRollup": [
4917    {
4918      "__typename": "CheckRun",
4919      "conclusion": "SUCCESS",
4920      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4921      "name": "check (ubuntu-latest)",
4922      "status": "COMPLETED",
4923      "workflowName": "CI"
4924    },
4925    {
4926      "__typename": "CheckRun",
4927      "conclusion": "SUCCESS",
4928      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4929      "name": "review",
4930      "status": "COMPLETED",
4931      "workflowName": "claude-review"
4932    }
4933  ],
4934  "reviews": [
4935    {
4936      "author": {
4937        "login": "claude"
4938      },
4939      "state": "COMMENTED",
4940      "body": ""
4941    }
4942  ],
4943  "comments": [
4944    {
4945      "author": {
4946        "login": "coderabbitai"
4947      },
4948      "authorAssociation": "NONE",
4949      "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"
4950    },
4951    {
4952      "author": {
4953        "login": "claude"
4954      },
4955      "authorAssociation": "NONE",
4956      "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"
4957    }
4958  ]
4959}"####;
4960
4961    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
4962    const INLINE: &str = r####"[
4963  {
4964    "user": {
4965      "login": "claude[bot]"
4966    },
4967    "path": "src/graph.rs",
4968    "line": 231,
4969    "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"
4970  }
4971]"####;
4972
4973    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
4974    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4975<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4976
4977> [!IMPORTANT]
4978> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4979> 
4980> This repository does not receive automatic reviews because it has fewer than 10 stars.
4981> 
4982> <details>
4983> <summary>⚙️ Run configuration</summary>
4984> 
4985> **Configuration used**: defaults
4986> 
4987> **Review profile**: CHILL
4988> 
4989> **Plan**: Team
4990> 
4991> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4992> 
4993> </details>
4994
4995<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4996
4997<!-- tips_start -->
4998
4999---
5000
5001Thanks 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.
5002
5003<details>
5004<summary>❤️ Share</summary>
5005
5006- [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"####;
5007
5008    /// 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.
5009    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5010
5011---
5012### Reviewing PR #16
5013
5014- [x] Read AGENTS.md conventions
5015- [x] Review `src/daemon.rs` changes
5016- [x] Review `src/main.rs` changes (new `doctor` reporting)
5017- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5018- [x] Check test coverage for new behavior
5019- [x] Run verification commands (blocked — see note)
5020- [x] Post findings"####;
5021
5022    /// The same job's real comment on pull request #12 once it had something to say.
5023    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5024
5025---
5026### Review: `magi review <branch>` — cheap-half-only graph
5027
5028Read 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.
5029
5030**Correctness**
5031
5032- 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"####;
5033
5034    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5035        PrState {
5036            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5037            number: 16,
5038            state: PrLifecycle::Open,
5039            checks,
5040            // These tests are about red-means-fix, so a red here is one the
5041            // forge gates on. Without saying so they would assert the new
5042            // "merge past a check nobody requires" path by accident.
5043            blocking: if matches!(checks, Checks::Red) {
5044                Blocking::Yes
5045            } else {
5046                Blocking::No
5047            },
5048            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5049            review_comments: (0..comments)
5050                .map(|i| ReviewComment {
5051                    author: "coderabbitai".to_owned(),
5052                    path: Some("src/graph.rs".to_owned()),
5053                    line: Some(231),
5054                    body: format!("finding {i}"),
5055                })
5056                .collect(),
5057        }
5058    }
5059
5060    #[test]
5061    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5062        use CiExpectation::{Absent, Expected};
5063        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5064            let p = pr(checks, &["x"], 0);
5065            for waited in [Duration::ZERO, CHECKS_GRACE] {
5066                assert_eq!(
5067                    decide_with(&p, 0, 4, waited, Expected),
5068                    decide(&p, 0, 4, waited)
5069                );
5070            }
5071        }
5072        // Nothing will ever report: no wait, no give-up, no fix round.
5073        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5074            let p = pr(checks, &["x"], 0);
5075            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5076            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5077        }
5078        // A conflict is not a check: it still wants the rebase.
5079        let mut p = pr(Checks::Unknown, &[], 0);
5080        p.blocking = Blocking::Conflict;
5081        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5082        // And a pull request that left our hands is still done.
5083        p.state = PrLifecycle::Merged;
5084        assert_eq!(
5085            decide_with(&p, 0, 4, Duration::ZERO, Absent),
5086            Step::Done { merged: true }
5087        );
5088    }
5089
5090    #[test]
5091    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5092        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5093        assert_eq!(state.number, 10);
5094        assert_eq!(state.state, PrLifecycle::Open);
5095        assert_eq!(state.checks, Checks::Green);
5096        assert!(state.failing.is_empty());
5097        assert!(
5098            state.review_comments.is_empty(),
5099            "the only comment is CodeRabbit's trigger notice: {:?}",
5100            state.review_comments
5101        );
5102        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5103    }
5104
5105    #[test]
5106    fn a_failing_check_parses_as_red_and_is_named() {
5107        let state = parse_pr(RED_OPEN).expect("red fixture parses");
5108        assert_eq!(state.checks, Checks::Red);
5109        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5110        // The captured payload says `UNSTABLE` - mergeable, with a check
5111        // nobody requires red - which is exactly the shape that had to be
5112        // merged by hand. Asserted separately, in
5113        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
5114        // test is about is that a red check is *named*, so the reason a fixer
5115        // is handed says which one; so it asks the blocking question here.
5116        let mut blocking = state.clone();
5117        blocking.blocking = Blocking::Yes;
5118        match decide(&blocking, 0, 4, Duration::ZERO) {
5119            Step::Fix { reason } => {
5120                assert!(reason.contains("editorconfig"), "reason: {reason}");
5121                assert!(reason.contains("failing"), "reason: {reason}");
5122            }
5123            other => panic!("expected a fix round, got {other:?}"),
5124        }
5125    }
5126
5127    #[test]
5128    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5129        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5130        assert_eq!(state.checks, Checks::Pending);
5131        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5132    }
5133
5134    #[test]
5135    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5136        let state = parse_pr(MERGED).expect("merged fixture parses");
5137        assert_eq!(state.state, PrLifecycle::Merged);
5138        assert_eq!(
5139            decide(&state, 0, 4, Duration::ZERO),
5140            Step::Done { merged: true }
5141        );
5142    }
5143
5144    #[test]
5145    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5146        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5147        assert_eq!(state.checks, Checks::Green);
5148        let authors: Vec<&str> = state
5149            .review_comments
5150            .iter()
5151            .map(|c| c.author.as_str())
5152            .collect();
5153        assert_eq!(
5154            authors,
5155            vec!["claude"],
5156            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5157        );
5158        match decide(&state, 0, 4, Duration::ZERO) {
5159            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5160            other => panic!("expected a fix round, got {other:?}"),
5161        }
5162    }
5163
5164    #[test]
5165    fn inline_review_comments_keep_their_file_and_line() {
5166        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5167        assert_eq!(comments.len(), 1);
5168        assert_eq!(comments[0].author, "claude[bot]");
5169        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5170        assert_eq!(comments[0].line, Some(231));
5171        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5172    }
5173
5174    #[test]
5175    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5176        assert!(
5177            is_noise(CODERABBIT_TRIGGER),
5178            "CodeRabbit's trigger notice declares itself not a review"
5179        );
5180        assert!(
5181            is_noise(CLAUDE_CHECKLIST),
5182            "a progress checklist asks for nothing"
5183        );
5184        assert!(
5185            !is_noise(CLAUDE_FINDING),
5186            "a review that names a bug is input, not noise"
5187        );
5188
5189        let mut clean = pr(Checks::Green, &[], 0);
5190        clean.review_comments.push(ReviewComment {
5191            author: "coderabbitai".to_owned(),
5192            path: None,
5193            line: None,
5194            body: CODERABBIT_TRIGGER.to_owned(),
5195        });
5196        clean.review_comments.retain(|c| !is_noise(&c.body));
5197        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
5198
5199        let mut found = pr(Checks::Green, &[], 0);
5200        found.review_comments.push(ReviewComment {
5201            author: "claude".to_owned(),
5202            path: None,
5203            line: None,
5204            body: CLAUDE_FINDING.to_owned(),
5205        });
5206        found.review_comments.retain(|c| !is_noise(&c.body));
5207        assert!(matches!(
5208            decide(&found, 0, 4, Duration::ZERO),
5209            Step::Fix { .. }
5210        ));
5211    }
5212
5213    #[test]
5214    fn the_policy_table_holds_for_every_combination_that_matters() {
5215        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
5216            (
5217                "pending checks are waited for, even on the last round",
5218                pr(Checks::Pending, &[], 0),
5219                4,
5220                4,
5221                Duration::ZERO,
5222                Step::Wait,
5223            ),
5224            (
5225                "red checks are fixed",
5226                pr(Checks::Red, &["editorconfig"], 0),
5227                0,
5228                4,
5229                Duration::ZERO,
5230                Step::Fix {
5231                    reason: "1 check(s) failing: editorconfig".to_owned(),
5232                },
5233            ),
5234            (
5235                "green with comments is fixed, not merged",
5236                pr(Checks::Green, &[], 2),
5237                1,
5238                4,
5239                Duration::ZERO,
5240                Step::Fix {
5241                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5242                        .to_owned(),
5243                },
5244            ),
5245            (
5246                "green and clean merges",
5247                pr(Checks::Green, &[], 0),
5248                3,
5249                4,
5250                Duration::ZERO,
5251                Step::Merge,
5252            ),
5253            (
5254                "an unreadable rollup is waited on while the grace lasts",
5255                pr(Checks::Unknown, &[], 0),
5256                0,
5257                4,
5258                Duration::ZERO,
5259                Step::Wait,
5260            ),
5261            (
5262                "an unreadable rollup is never merged once the grace is spent",
5263                pr(Checks::Unknown, &[], 0),
5264                0,
5265                4,
5266                CHECKS_GRACE,
5267                Step::GiveUp {
5268                    reason: "no check status is readable on the pull request after 3 minute(s); \
5269                             refusing to merge on a guess"
5270                        .to_owned(),
5271                },
5272            ),
5273        ];
5274        for (what, state, round, budget, waited, want) in cases {
5275            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5276        }
5277    }
5278
5279    #[test]
5280    fn the_forge_verdict_survives_the_round_trip_from_gh() {
5281        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
5282        // requested but never parsed is the kind of thing that looks wired up
5283        // and answers `Unsaid` forever.
5284        let green = parse_pr(GREEN_OPEN).expect("parse");
5285        assert_eq!(green.blocking, Blocking::No);
5286        let red = parse_pr(RED_OPEN).expect("parse");
5287        assert_eq!(
5288            red.blocking,
5289            Blocking::No,
5290            "`UNSTABLE` is mergeable: the red check is one nobody requires"
5291        );
5292        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5293        // A payload from an older `gh` has no such field at all.
5294        let quiet =
5295            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5296        assert_eq!(quiet.blocking, Blocking::Unsaid);
5297    }
5298
5299    #[test]
5300    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5301        // Pull request 37's only red check was `editorconfig`, failing
5302        // because the action could not fetch its own binary after
5303        // editorconfig-checker v4 renamed its release assets. The repository
5304        // does not require it. magi answered by asking a fixer to repair a
5305        // change that was fine, and the pull request had to be merged by hand.
5306        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5307        nonblocking.blocking = Blocking::No;
5308        assert_eq!(
5309            decide(&nonblocking, 0, 4, Duration::ZERO),
5310            Step::Merge,
5311            "the forge says nothing is in the way, so nothing is"
5312        );
5313
5314        // The same red, gated on: that is a fix round, as before.
5315        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5316        blocking.blocking = Blocking::Yes;
5317        assert!(matches!(
5318            decide(&blocking, 0, 4, Duration::ZERO),
5319            Step::Fix { .. }
5320        ));
5321
5322        // A review comment still outranks green-enough: a non-required red
5323        // must not become a way to merge past an unanswered reviewer.
5324        let mut commented = pr(Checks::Red, &["coverage"], 1);
5325        commented.blocking = Blocking::No;
5326        assert!(matches!(
5327            decide(&commented, 0, 4, Duration::ZERO),
5328            Step::Fix { .. }
5329        ));
5330
5331        // And silence from the forge is not consent.
5332        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5333        unsaid.blocking = Blocking::Unsaid;
5334        assert!(matches!(
5335            decide(&unsaid, 0, 4, Duration::ZERO),
5336            Step::Fix { .. }
5337        ));
5338    }
5339
5340    #[test]
5341    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5342        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5343        red.blocking = Blocking::No;
5344        assert_eq!(
5345            decide(&red, 0, 4, Duration::ZERO),
5346            Step::Merge,
5347            "announcing must not change the decision"
5348        );
5349        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5350        assert!(said.contains("yukimemi/magi"), "{said}");
5351        assert!(said.contains("#16"), "{said}");
5352        assert!(
5353            said.contains("https://github.com/yukimemi/magi/pull/16"),
5354            "{said}"
5355        );
5356        assert!(
5357            said.contains("test (windows-latest)") && said.contains("coverage"),
5358            "{said}"
5359        );
5360
5361        // `failing` can be left over on a green observation; only `checks` counts.
5362        let green = pr(Checks::Green, &["stale"], 0);
5363        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5364    }
5365
5366    #[test]
5367    fn the_repo_label_comes_from_the_pull_request_url() {
5368        let p = Path::new("/tmp/checkout");
5369        assert_eq!(
5370            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5371            "yukimemi/magi"
5372        );
5373        assert_eq!(repo_label(p, "not a url"), "checkout");
5374    }
5375
5376    #[test]
5377    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5378        // Pull requests 35 and 37 were both rebased by hand: a competition
5379        // that runs for two hours against a repository merging pull requests
5380        // all day conflicts on the way in, and that is arithmetic rather
5381        // than a defect in the change.
5382        let mut conflicted = pr(Checks::Green, &[], 0);
5383        conflicted.blocking = Blocking::Conflict;
5384        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5385
5386        // Decided before the checks, and even with the rounds spent: every
5387        // check on a branch that cannot land is an answer about a state that
5388        // cannot land, and a conflict is not the change's fault.
5389        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5390        red.blocking = Blocking::Conflict;
5391        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5392
5393        // The lifecycle still wins over everything, conflict included.
5394        let mut merged = pr(Checks::Red, &[], 0);
5395        merged.blocking = Blocking::Conflict;
5396        merged.state = PrLifecycle::Merged;
5397        assert_eq!(
5398            decide(&merged, 0, 4, Duration::ZERO),
5399            Step::Done { merged: true }
5400        );
5401    }
5402
5403    #[test]
5404    fn the_forge_verdict_is_read_off_merge_state_status() {
5405        // The spellings that mean "mergeable". `UNSTABLE` is the one that
5406        // matters: mergeable, with a non-required check red or still running.
5407        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5408            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5409            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5410        }
5411        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5412        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5413        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5414        // An older `gh`, or a token without the scope, says nothing - and
5415        // refusing to guess is the rule everywhere else in this module.
5416        for quiet in ["", "UNKNOWN"] {
5417            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5418            assert!(Blocking::of(quiet).stops_a_merge());
5419        }
5420    }
5421
5422    #[test]
5423    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5424        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5425        // The exact stderr from run ec12, in a jj-colocated repository.
5426        let jj = "could not determine current branch: failed to run git: not on any branch";
5427
5428        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5429            .expect("the forge says merged, so it merged");
5430        assert!(landed.ok);
5431        assert!(
5432            landed.detail.contains("but the pull request is merged"),
5433            "the record must not read as a clean success: {}",
5434            landed.detail
5435        );
5436        assert!(
5437            landed.detail.contains("not on any branch"),
5438            "and it must keep what the command actually said: {}",
5439            landed.detail
5440        );
5441
5442        // A pull request still open means the merge really failed.
5443        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5444        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5445        // And an unreadable answer is not evidence of success.
5446        assert!(merged_after_all(&argv, jj, None).is_none());
5447    }
5448
5449    #[test]
5450    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5451        let mut state = pr(Checks::Red, &["editorconfig"], 3);
5452        state.state = PrLifecycle::Closed;
5453        assert_eq!(
5454            decide(&state, 0, 4, Duration::ZERO),
5455            Step::Done { merged: false },
5456            "a human closing the pull request ends the loop, whatever CI says"
5457        );
5458    }
5459
5460    #[test]
5461    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5462        let red = decide(
5463            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5464            4,
5465            4,
5466            Duration::ZERO,
5467        );
5468        match red {
5469            Step::GiveUp { reason } => {
5470                assert!(reason.contains("editorconfig"), "reason: {reason}");
5471                assert!(reason.contains("test (macos)"), "reason: {reason}");
5472                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5473            }
5474            other => panic!("expected a give-up, got {other:?}"),
5475        }
5476
5477        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5478        match commented {
5479            Step::GiveUp { reason } => {
5480                assert!(reason.contains("unresolved"), "reason: {reason}");
5481                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5482            }
5483            other => panic!("expected a give-up, got {other:?}"),
5484        }
5485    }
5486
5487    #[test]
5488    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5489        let candidate_commit = "magi: candidate A (uncommitted work)";
5490        let subject = merge_subject(candidate_commit, "add retries to the uploader");
5491        let argv = merge_argv(16, &subject);
5492
5493        assert!(argv.contains(&"--squash".to_owned()));
5494        assert!(argv.contains(&"--delete-branch".to_owned()));
5495        assert!(argv.contains(&"--subject".to_owned()));
5496        assert_eq!(
5497            argv.last().map(String::as_str),
5498            Some("add retries to the uploader"),
5499            "the subject must not be the candidate commit message"
5500        );
5501        assert_ne!(subject, candidate_commit);
5502    }
5503
5504    #[test]
5505    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5506        assert_eq!(
5507            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5508            "feat: a queue, an unattended loop, and a phone UI"
5509        );
5510        assert_eq!(
5511            merge_subject("", "# port the retry logic\n\ndetails"),
5512            "port the retry logic",
5513            "an empty title falls back to the task's first line, heading marks stripped"
5514        );
5515    }
5516
5517    #[test]
5518    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5519        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5520        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5521        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5522        assert_eq!(job_of("https://coderabbit.ai/status"), None);
5523        assert_eq!(run_of(""), None);
5524    }
5525
5526    #[test]
5527    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5528        let mut out = Vec::new();
5529        push_if_outstanding(
5530            &mut out,
5531            ReviewComment {
5532                author: "yukimemi".to_owned(),
5533                path: None,
5534                line: None,
5535                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5536            },
5537        );
5538        assert!(out.is_empty());
5539    }
5540
5541    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
5542    /// falls back to the repository - which keeps these tests free of a
5543    /// worktree, a `git` invocation and a network.
5544    fn run_state() -> RunState {
5545        let mut state = RunState::new(
5546            std::path::PathBuf::from("/repo/magi"),
5547            "main".to_owned(),
5548            "abcdef1234".to_owned(),
5549            "add retries to the uploader".to_owned(),
5550            crate::config::Config::default(),
5551        );
5552        // Tests run in parallel against one persistent home and the ids
5553        // `RunState::new` draws from the clock can repeat, so two tests would
5554        // share a run's questions. The home also outlives the process, so the
5555        // counter alone would reuse an earlier run's ids.
5556        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5557        let nanos = std::time::SystemTime::now()
5558            .duration_since(std::time::UNIX_EPOCH)
5559            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5560        state.id = format!(
5561            "20261004-{nanos:06}-{:04x}",
5562            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5563        );
5564        state
5565    }
5566
5567    fn green_pr() -> PrState {
5568        PrState {
5569            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5570            number: 42,
5571            state: PrLifecycle::Open,
5572            checks: Checks::Green,
5573            // The forge sees nothing in the way unless a test says otherwise.
5574            blocking: Blocking::No,
5575            failing: Vec::new(),
5576            review_comments: vec![ReviewComment {
5577                author: "coderabbitai".to_owned(),
5578                path: Some("src/land.rs".to_owned()),
5579                line: Some(212),
5580                body: "this branch never checks the exit code".to_owned(),
5581            }],
5582        }
5583    }
5584
5585    #[test]
5586    fn github_facing_land_text_is_english_whatever_the_language() {
5587        let mut state = run_state();
5588        state.config.graph.language = "ja".to_owned();
5589        let comment = stop_comment(&state.id, "checks are still red");
5590        assert!(crate::github_text::check("", &comment).is_empty());
5591        assert!(comment.is_ascii(), "{comment}");
5592        assert!(comment.starts_with(MARKER));
5593
5594        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5595        let ja_at = p.find("Write all prose in ja").unwrap();
5596        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5597        assert!(ja_at < rule_at, "{p}");
5598        assert!(p.contains("stays in Japanese"), "{p}");
5599
5600        state.config.graph.language = "en".to_owned();
5601        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5602        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5603        assert!(!p.contains("does not apply"), "{p}");
5604    }
5605
5606    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5607
5608    fn panel() -> String {
5609        approval_panel(
5610            &run_state(),
5611            &green_pr(),
5612            NUMSTAT,
5613            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5614            &[
5615                "land: ask before merging".to_owned(),
5616                "land: colour the diff".to_owned(),
5617            ],
5618            "feat: merge approval from the phone",
5619        )
5620    }
5621
5622    #[test]
5623    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5624        let html = panel();
5625        for needle in [
5626            "42",
5627            "main",
5628            "src/land.rs",
5629            "src/web.rs",
5630            "assets/logo.png",
5631            "feat: merge approval from the phone",
5632            "land: ask before merging",
5633            "land: colour the diff",
5634            "coderabbitai",
5635            "this branch never checks the exit code",
5636            "green",
5637        ] {
5638            assert!(html.contains(needle), "the panel must state `{needle}`");
5639        }
5640    }
5641
5642    /// A candidate whose label is `A` and has won, so [`RunState::winner`]
5643    /// resolves to it.
5644    fn winning_candidate(summary: &str) -> Candidate {
5645        Candidate {
5646            index: 0,
5647            label: 'A',
5648            agent: "opus".to_owned(),
5649            branch: "magi/x/A".to_owned(),
5650            worktree: PathBuf::from("/wt/A"),
5651            summary: summary.to_owned(),
5652            stat: String::new(),
5653            files: 1,
5654            commits: 1,
5655            empty: false,
5656            failed: None,
5657            verified_noop: None,
5658            duration_ms: 0,
5659            folded: false,
5660        }
5661    }
5662
5663    fn uncontested_tally() -> Tally {
5664        Tally {
5665            first_choice: BTreeMap::from([('A', 1)]),
5666            borda: BTreeMap::new(),
5667            winner: 'A',
5668            rankings: 1,
5669            unanimous_initial: true,
5670            deliberated: false,
5671            changed_votes: 0,
5672            unanimous_final: true,
5673            tie_break: None,
5674            judges: 1,
5675            present: 1,
5676            quorum: 1,
5677            met_quorum: true,
5678            uncontested: None,
5679        }
5680    }
5681
5682    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5683        ReviewRecord {
5684            attempts: 0,
5685            reviewer,
5686            agent: agent.to_owned(),
5687            summary: summary.to_owned(),
5688            findings: Vec::new(),
5689            vote: None,
5690            failed: None,
5691            duration_ms: 0,
5692        }
5693    }
5694
5695    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5696        let answered = reviews.len();
5697        ReviewRound {
5698            round,
5699            head: "abc1234".to_owned(),
5700            verified_head: None,
5701            verified_at: None,
5702            reviews,
5703            e2e: Vec::new(),
5704            verify_retried: false,
5705            e2e_deferred: false,
5706            e2e_defer_reason: None,
5707            fix: None,
5708            blocking: 0,
5709            answered,
5710            expected: answered,
5711            clean: true,
5712            progressed: false,
5713            vote_split: false,
5714            reconsideration: Vec::new(),
5715            verdict: None,
5716        }
5717    }
5718
5719    #[test]
5720    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5721        let en = panel();
5722        assert!(en.contains("Task"), "{en}");
5723        assert!(en.contains("add retries to the uploader"), "{en}");
5724
5725        let mut state = run_state();
5726        state.config.graph.language = "ja".to_owned();
5727        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5728        assert!(ja.contains("タスク"), "{ja}");
5729        assert!(
5730            ja.contains("add retries to the uploader"),
5731            "the task itself is not translated: {ja}"
5732        );
5733    }
5734
5735    #[test]
5736    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5737        // `run_state()` has no candidates, no tally and no reviews - exactly
5738        // the shape a run has before anything has judged or reviewed it, and
5739        // the panel must not print an empty box for either.
5740        let html = panel();
5741        assert!(!html.contains("What changed"), "{html}");
5742        assert!(!html.contains("Review verdict"), "{html}");
5743    }
5744
5745    #[test]
5746    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5747        let mut state = run_state();
5748        state.candidates = vec![winning_candidate("")];
5749        state.tally = Some(uncontested_tally());
5750        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5751        assert!(
5752            !html.contains("What changed"),
5753            "an empty summary must not render an empty box: {html}"
5754        );
5755    }
5756
5757    #[test]
5758    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5759        let mut state = run_state();
5760        state.candidates = vec![winning_candidate(
5761            "Added a retry loop around the uploader PUT call.",
5762        )];
5763        state.tally = Some(uncontested_tally());
5764        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5765        assert!(en.contains("What changed"), "{en}");
5766        assert!(
5767            en.contains("Added a retry loop around the uploader PUT call."),
5768            "{en}"
5769        );
5770
5771        state.config.graph.language = "ja".to_owned();
5772        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5773        assert!(ja.contains("変更内容"), "{ja}");
5774        assert!(
5775            ja.contains("Added a retry loop around the uploader PUT call."),
5776            "{ja}"
5777        );
5778    }
5779
5780    #[test]
5781    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5782        let mut state = run_state();
5783        state.reviews = vec![
5784            review_round(
5785                1,
5786                vec![review_record(1, "alpha", "found a race, sent back")],
5787            ),
5788            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5789        ];
5790        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5791        assert!(en.contains("Review verdict"), "{en}");
5792        assert!(en.contains("race is fixed, clean"), "{en}");
5793        assert!(
5794            !en.contains("found a race, sent back"),
5795            "only the round that actually cleared the merge should show: {en}"
5796        );
5797
5798        state.config.graph.language = "ja".to_owned();
5799        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5800        assert!(ja.contains("レビューの結論"), "{ja}");
5801        assert!(ja.contains("レビュアー"), "{ja}");
5802        assert!(ja.contains("race is fixed, clean"), "{ja}");
5803    }
5804
5805    /// The `incomplete_review = "warn"` policy (see
5806    /// `graph::Runner::review_loop`) can push a `clean` round to
5807    /// `state.reviews` while one seat's own record still has `failed: Some`
5808    /// and an empty `summary` - a seat that never answered, not one that
5809    /// answered with nothing to say.
5810    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5811        ReviewRecord {
5812            attempts: 0,
5813            reviewer,
5814            agent: agent.to_owned(),
5815            summary: String::new(),
5816            findings: Vec::new(),
5817            vote: None,
5818            failed: Some(reason.to_owned()),
5819            duration_ms: 0,
5820        }
5821    }
5822
5823    #[test]
5824    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5825        let mut state = run_state();
5826        state.reviews = vec![review_round(
5827            1,
5828            vec![
5829                review_record(1, "alpha", "clean, nothing to add"),
5830                unanswered_review_record(2, "beta", "timed out"),
5831            ],
5832        )];
5833        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5834        assert!(en.contains("clean, nothing to add"), "{en}");
5835        assert!(
5836            en.contains("produced no answer: timed out"),
5837            "a seat that never answered must say so, not render a blank box: {en}"
5838        );
5839        assert!(
5840            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5841            "no reviewer box may be left empty: {en}"
5842        );
5843
5844        state.config.graph.language = "ja".to_owned();
5845        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5846        assert!(ja.contains("回答なし: timed out"), "{ja}");
5847    }
5848
5849    #[test]
5850    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5851        let html = panel();
5852        assert!(!html.contains("<script"), "no script survives the csp");
5853        assert!(!html.contains("<form"), "form-action is 'none'");
5854        let pr = green_pr();
5855        assert_eq!(
5856            html.matches("http").count(),
5857            html.matches(pr.url.as_str()).count(),
5858            "the only http url in the panel is the pull request's own link"
5859        );
5860        assert!(
5861            html.contains("target=\"_blank\""),
5862            "the PR link must open in a new tab so it escapes the iframe"
5863        );
5864        assert!(
5865            html.contains("rel=\"noopener noreferrer\""),
5866            "external links opening in new tabs must carry noopener noreferrer"
5867        );
5868    }
5869
5870    #[test]
5871    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5872        let html = panel();
5873        assert!(
5874            html.contains(">+</span>"),
5875            "an added line carries a `+` in the gutter, not only a background"
5876        );
5877        assert!(
5878            html.contains(">-</span>"),
5879            "a removed line carries a `-` in the gutter, not only a background"
5880        );
5881        assert!(
5882            html.contains(">new line</span>"),
5883            "the marker is moved to the gutter, so the body is printed once without it"
5884        );
5885    }
5886
5887    #[test]
5888    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5889        let total = DIFF_MAX_LINES + 100;
5890        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5891        let html = approval_panel(
5892            &run_state(),
5893            &green_pr(),
5894            NUMSTAT,
5895            &diff,
5896            &[],
5897            "feat: something long",
5898        );
5899        assert!(
5900            html.contains(&format!("100 of {total} diff lines omitted")),
5901            "the note must say exactly how much was cut"
5902        );
5903        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5904        assert!(
5905            !html.contains(&format!("line {DIFF_MAX_LINES}")),
5906            "nothing past the threshold is rendered"
5907        );
5908        assert!(
5909            html.contains("/repo/magi"),
5910            "the note says where the rest is"
5911        );
5912    }
5913
5914    #[test]
5915    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5916        let html = approval_panel(
5917            &run_state(),
5918            &green_pr(),
5919            "1\t2\tsrc/<b>&\"x\"'.rs",
5920            "",
5921            &[],
5922            "subject",
5923        );
5924        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
5925        assert!(
5926            !html.contains("<b>"),
5927            "an agent-influenced path must never become markup"
5928        );
5929    }
5930
5931    #[tokio::test]
5932    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5933        let a = std::path::PathBuf::from("/repo/a");
5934        let b = std::path::PathBuf::from("/repo/b");
5935
5936        let held = repo_merge_lock(&a).lock_owned().await;
5937
5938        // A second, concurrent land run against the *same* repository must
5939        // wait - `try_lock` fails while `held` is alive.
5940        assert!(
5941            repo_merge_lock(&a).try_lock().is_err(),
5942            "a second merge into the same repository must not proceed concurrently"
5943        );
5944
5945        // A run against a *different* repository must not be blocked by it -
5946        // this is what keeps a slow rebase or `gh pr merge` in one
5947        // repository from also stalling a land-approval resume in another.
5948        assert!(
5949            repo_merge_lock(&b).try_lock().is_ok(),
5950            "a different repository's merge lock must be independent"
5951        );
5952
5953        drop(held);
5954        assert!(
5955            repo_merge_lock(&a).try_lock().is_ok(),
5956            "the lock is released once the holder is done"
5957        );
5958    }
5959
5960    #[test]
5961    fn only_the_merge_choice_merges_and_silence_holds() {
5962        let table = [
5963            (None, Approval::Hold),
5964            (Some("merge"), Approval::Merge),
5965            (Some(" merge\n"), Approval::Merge),
5966            (Some("hold"), Approval::Hold),
5967            (Some(""), Approval::Hold),
5968            (Some("yes"), Approval::Hold),
5969        ];
5970        for (answer, want) in table {
5971            assert_eq!(
5972                approval(answer),
5973                want,
5974                "answer {answer:?} must resolve to {want:?}"
5975            );
5976        }
5977    }
5978
5979    #[tokio::test]
5980    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5981        let mut state = landing_state();
5982        state.config.graph.land_approval = true;
5983        let pr = green_pr();
5984
5985        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5986            .await
5987            .unwrap();
5988        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5989        assert!(
5990            !state.parked,
5991            "approval_gate itself never sets `parked`; only its caller does"
5992        );
5993
5994        let store = ask::Questions::open();
5995        let filed: Vec<_> = store
5996            .list()
5997            .into_iter()
5998            .filter(|q| q.run == state.id)
5999            .collect();
6000        assert_eq!(filed.len(), 1, "exactly one question is filed");
6001        assert_eq!(filed[0].node, APPROVAL_NODE);
6002        assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
6003        assert!(filed[0].status.open());
6004
6005        // A second visit - standing in for a resumed run whose slot the
6006        // daemon handed to something else while nobody had answered - must
6007        // find the same question rather than filing a second one.
6008        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6009            .await
6010            .unwrap();
6011        assert_eq!(again, ApprovalGate::Pending);
6012        let still_one = store
6013            .list()
6014            .into_iter()
6015            .filter(|q| q.run == state.id)
6016            .count();
6017        assert_eq!(
6018            still_one, 1,
6019            "asking twice must not double-file the question"
6020        );
6021    }
6022
6023    #[tokio::test]
6024    async fn approving_the_existing_question_is_read_back_as_approved() {
6025        crate::run::pin_test_home();
6026        let mut state = run_state();
6027        state.config.graph.land_approval = true;
6028        let pr = green_pr();
6029        assert_eq!(
6030            approval_gate(&mut state, &pr, "feat: x", None, "abc")
6031                .await
6032                .unwrap(),
6033            ApprovalGate::Pending
6034        );
6035
6036        let store = ask::Questions::open();
6037        let mut q = store
6038            .list()
6039            .into_iter()
6040            .find(|q| q.run == state.id)
6041            .expect("filed above");
6042        let talks = crate::talk::Talks::open();
6043        let cfg = crate::config::Config {
6044            agents: vec![crate::config::AgentSpec {
6045                id: "mock".into(),
6046                kind: crate::config::AgentKind::Command,
6047                model: None,
6048                command: vec!["true".into()],
6049                extra_args: Vec::new(),
6050                env: Default::default(),
6051                prompt_delivery: None,
6052            }],
6053            ..crate::config::Config::default()
6054        };
6055        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
6056        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
6057        crate::talk::drain(&mut talk, &talks).unwrap();
6058        assert_eq!(
6059            approval_gate(&mut state, &pr, "feat: x", None, "abc")
6060                .await
6061                .unwrap(),
6062            ApprovalGate::Pending,
6063            "consultation cannot approve or hold the merge"
6064        );
6065        q = store.get(&q.id).unwrap();
6066        assert!(q.answer.is_none());
6067        talk.turns.push(crate::talk::Turn {
6068            breaks: Some(Vec::new()),
6069            messages: Vec::new(),
6070            posted_by: None,
6071            who: crate::talk::Who::Operator,
6072            body: "Merge this pull request now".into(),
6073            at: Timestamp::now(),
6074            attachments: Vec::new(),
6075            usage: None,
6076        });
6077        talks.put(&mut talk).unwrap();
6078        crate::consult::validate_answer(
6079            &q,
6080            &talks,
6081            &talk.id,
6082            crate::queue::CHAT_NODE,
6083            APPROVE,
6084            Some("Merge this pull request now"),
6085        )
6086        .unwrap();
6087        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6088        store.put(&mut q).unwrap();
6089
6090        assert_eq!(
6091            approval_gate(&mut state, &pr, "feat: x", None, "abc")
6092                .await
6093                .unwrap(),
6094            ApprovalGate::Approved
6095        );
6096    }
6097
6098    #[tokio::test]
6099    async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
6100        crate::run::pin_test_home();
6101        let store = ask::Questions::open();
6102
6103        let mut held_state = run_state();
6104        held_state.config.graph.land_approval = true;
6105        let pr = green_pr();
6106        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6107            .await
6108            .unwrap();
6109        let mut q = store
6110            .list()
6111            .into_iter()
6112            .find(|q| q.run == held_state.id)
6113            .expect("filed above");
6114        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
6115        store.put(&mut q).unwrap();
6116        assert_eq!(
6117            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6118                .await
6119                .unwrap(),
6120            ApprovalGate::Held
6121        );
6122
6123        let mut abandoned_state = run_state();
6124        abandoned_state.config.graph.land_approval = true;
6125        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6126            .await
6127            .unwrap();
6128        let mut q = store
6129            .list()
6130            .into_iter()
6131            .find(|q| q.run == abandoned_state.id)
6132            .expect("filed above");
6133        q.abandon("no answer within the timeout");
6134        store.put(&mut q).unwrap();
6135        assert_eq!(
6136            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6137                .await
6138                .unwrap(),
6139            ApprovalGate::Held,
6140            "silence must never merge"
6141        );
6142    }
6143
6144    fn contested() -> ContestedHandoff {
6145        let finding = |id: &str, n: u32| crate::verdict::Finding {
6146            id: id.to_owned(),
6147            severity: crate::verdict::Severity::Major,
6148            file: Some("src/a.rs".to_owned()),
6149            line: Some(n),
6150            title: format!("problem {id}"),
6151            detail: String::new(),
6152        };
6153        ContestedHandoff {
6154            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
6155            rejecters: vec![(1, "alpha".to_owned())],
6156        }
6157    }
6158
6159    #[test]
6160    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
6161        let mut state = run_state();
6162        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
6163        state.contested_handoff = Some(contested());
6164        assert!(contested_to_ask(&state).is_some());
6165        state.config.graph.hold_contested_merge = false;
6166        assert!(
6167            contested_to_ask(&state).is_none(),
6168            "the switch restores today"
6169        );
6170    }
6171
6172    #[test]
6173    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
6174        let q = ask::Question::new(
6175            "run-1".to_owned(),
6176            APPROVAL_NODE.to_owned(),
6177            "land".to_owned(),
6178            "Merge?".to_owned(),
6179            String::new(),
6180            vec![APPROVE.to_owned(), HOLD.to_owned()],
6181        );
6182        let none = deputy_brief(&q, None);
6183        assert!(none.contains("could not be read"), "{none}");
6184        assert!(none.contains("Silence is a hold"), "{none}");
6185
6186        let mut state = run_state();
6187        state.pr = Some(crate::run::PrRecord {
6188            url: "https://example.test/pull/7".to_owned(),
6189            number: 7,
6190            state: "open".to_owned(),
6191            checks: "green".to_owned(),
6192            round: 0,
6193            rounds: 3,
6194            red_at_merge: Vec::new(),
6195        });
6196        state.contested_handoff = Some(contested());
6197        let b = deputy_brief(&q, Some(&state));
6198        assert!(b.contains("https://example.test/pull/7"), "{b}");
6199        assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
6200        assert!(b.contains("#1"), "the rejecting seat: {b}");
6201        state.contested_handoff = None;
6202        assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
6203    }
6204
6205    #[test]
6206    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
6207        for lang in ["en", "ja"] {
6208            let mut cfg = crate::config::Config::default();
6209            cfg.graph.language = lang.to_owned();
6210            let w = words(&cfg.graph.language);
6211            let text = w.approval_detail(
6212                "https://github.com/yukimemi/magi/pull/42",
6213                "main",
6214                "feat: x",
6215                Some(&contested()),
6216            );
6217            assert!(text.contains("pull/42"), "{text}");
6218            assert!(
6219                text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
6220                "{text}"
6221            );
6222            assert!(text.contains("R3-1-5"), "{text}");
6223            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
6224            assert!(text.contains("2"), "the rest are counted: {text}");
6225            assert!(text.contains("#1 (alpha)"), "{text}");
6226        }
6227        let plain = words("en").approval_detail("u", "main", "s", None);
6228        assert!(!plain.contains("reject"), "{plain}");
6229    }
6230
6231    #[tokio::test]
6232    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
6233        crate::run::pin_test_home();
6234        let mut state = run_state();
6235        state.config.graph.land_approval = false;
6236        state.contested_handoff = Some(contested());
6237        let pr = green_pr();
6238        let c = contested_to_ask(&state);
6239        assert_eq!(
6240            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6241                .await
6242                .unwrap(),
6243            ApprovalGate::Pending,
6244            "silence is a hold"
6245        );
6246        let store = ask::Questions::open();
6247        let filed: Vec<_> = store
6248            .list()
6249            .into_iter()
6250            .filter(|q| q.run == state.id)
6251            .collect();
6252        assert_eq!(filed.len(), 1);
6253        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
6254
6255        assert_eq!(
6256            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6257                .await
6258                .unwrap(),
6259            ApprovalGate::Pending
6260        );
6261        let mut q = store
6262            .list()
6263            .into_iter()
6264            .find(|q| q.run == state.id)
6265            .unwrap();
6266        assert_eq!(q.id, filed[0].id, "the same question after a resume");
6267        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6268        store.put(&mut q).unwrap();
6269        assert_eq!(
6270            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6271                .await
6272                .unwrap(),
6273            ApprovalGate::Approved
6274        );
6275    }
6276
6277    #[test]
6278    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
6279        let rows = parse_numstat(NUMSTAT);
6280        assert_eq!(
6281            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
6282            ["src/web.rs", "src/land.rs", "assets/logo.png"]
6283        );
6284        assert_eq!(rows[2].added, None, "a binary file has no line counts");
6285    }
6286    #[test]
6287    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
6288        // Reported from a real run: the merge question arrived in English on a
6289        // repository with `language = "ja"`. magi's own strings have to follow
6290        // that setting too - "it is a literal in Rust" is not an answer.
6291        let mut state = run_state();
6292        state.config.graph.language = "ja".to_owned();
6293        let pr = green_pr();
6294        let commits = ["c1".to_owned()];
6295
6296        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6297        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
6298        assert!(ja.contains("squash されるコミット"), "{ja}");
6299        assert!(ja.contains("レビューコメント"), "{ja}");
6300        assert!(ja.contains("差分"), "{ja}");
6301        assert!(
6302            !ja.contains("Commits being squashed"),
6303            "no English left over"
6304        );
6305
6306        let w = words("ja");
6307        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6308        assert!(
6309            w.approval_detail("http://x/1", "main", "feat: x", None)
6310                .contains("パネル")
6311        );
6312
6313        // The evidence itself is language-neutral and must survive either way.
6314        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6315        assert!(ja.contains("feat: x"), "nor is the merge subject");
6316
6317        // English stays the default, and a language magi cannot check falls
6318        // back to it rather than shipping a guess.
6319        state.config.graph.language = "en".to_owned();
6320        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6321        assert!(en.contains("Commits being squashed"), "{en}");
6322        assert_eq!(words("Klingon").html_lang, "en");
6323    }
6324
6325    /// A `gh pr list` result naming exactly one pull request whose base and
6326    /// merge time both fit the run is exactly the case
6327    /// [`find_external_merge`] exists to act on.
6328    #[test]
6329    fn pick_open_pr_classifies_by_count_and_base() {
6330        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6331        assert_eq!(
6332            pick_open_pr(one, "main").unwrap(),
6333            OpenPr::One {
6334                url: "https://x/pull/58".into(),
6335                title: "t".into()
6336            }
6337        );
6338        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6339        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6340        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6341                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6342        assert_eq!(
6343            pick_open_pr(two, "main").unwrap(),
6344            OpenPr::Many(vec!["u1".into(), "u2".into()])
6345        );
6346        // A fork's same-named branch is not a pull request for this head.
6347        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
6348        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
6349        assert!(pick_open_pr("not json", "main").is_err());
6350        // An incomplete record is an error, never "nothing open".
6351        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6352        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6353    }
6354
6355    #[test]
6356    fn pick_merged_pr_picks_the_unique_match() {
6357        let json = r#"[
6358            {"url": "https://github.com/o/r/pull/42", "number": 42,
6359             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6360        ]"#;
6361        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6362        let found = pick_merged_pr(json, "main", created_at)
6363            .expect("valid json")
6364            .expect("one unambiguous match");
6365        assert_eq!(found.url, "https://github.com/o/r/pull/42");
6366        assert_eq!(found.number, 42);
6367    }
6368
6369    /// Two candidates surviving the filter is exactly as uninformative as
6370    /// zero — a branch name can be reused across runs — so neither is
6371    /// preferred over the other and nothing is recorded automatically.
6372    #[test]
6373    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6374        let json = r#"[
6375            {"url": "https://github.com/o/r/pull/42", "number": 42,
6376             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6377            {"url": "https://github.com/o/r/pull/43", "number": 43,
6378             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6379        ]"#;
6380        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6381        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6382    }
6383
6384    /// A pull request that targets a different base branch cannot be this
6385    /// run's, whatever its head branch is named — a reused branch name from
6386    /// an unrelated task must not be recorded as this run's merge.
6387    #[test]
6388    fn pick_merged_pr_ignores_a_different_base_branch() {
6389        let json = r#"[
6390            {"url": "https://github.com/o/r/pull/42", "number": 42,
6391             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6392        ]"#;
6393        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6394        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6395    }
6396
6397    /// A pull request merged before this run was even created cannot be this
6398    /// run's winner, no matter how its head branch is spelled.
6399    #[test]
6400    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6401        let json = r#"[
6402            {"url": "https://github.com/o/r/pull/42", "number": 42,
6403             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6404        ]"#;
6405        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6406        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6407    }
6408
6409    #[test]
6410    fn slug_of_pr_url_reads_host_owner_and_repo() {
6411        assert_eq!(
6412            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6413            Some("github.com/yukimemi/shun")
6414        );
6415    }
6416
6417    #[test]
6418    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6419        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6420        assert_eq!(slug_of_pr_url("not a url at all"), None);
6421        assert_eq!(slug_of_pr_url("https://github.com"), None);
6422    }
6423
6424    #[test]
6425    fn slug_of_repo_url_reads_host_owner_and_repo() {
6426        assert_eq!(
6427            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6428            Some("github.com/yukimemi/magi")
6429        );
6430        assert_eq!(slug_of_repo_url("https://github.com"), None);
6431    }
6432
6433    #[test]
6434    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6435        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6436            .expect("same repo, different case");
6437    }
6438
6439    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
6440    /// own in-progress run and rewrote its status from a pull request in a
6441    /// completely different repository. This is the guard that must catch
6442    /// that even when an explicit (but wrong) id is given.
6443    #[test]
6444    fn ensure_same_repo_refuses_a_different_repo() {
6445        let err =
6446            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6447        let msg = format!("{err:#}");
6448        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6449        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6450    }
6451
6452    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
6453    /// instance mirroring a `github.com` repository's name, say) must not be
6454    /// treated as the same repository just because the trailing path
6455    /// matches.
6456    #[test]
6457    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6458        let err = ensure_same_repo(
6459            "github.com/yukimemi/magi",
6460            "github.example.com/yukimemi/magi",
6461        )
6462        .unwrap_err();
6463        let msg = format!("{err:#}");
6464        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6465        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6466    }
6467
6468    /// No winner decided yet means there is no branch to ask GitHub about at
6469    /// all — `find_external_merge` must return `None` without ever spawning
6470    /// `gh`, which this proves by never providing a real repository to spawn
6471    /// it in.
6472    #[tokio::test]
6473    async fn find_external_merge_returns_none_without_a_winner() {
6474        let state = RunState::new(
6475            PathBuf::from("/no/such/repo"),
6476            "main".to_owned(),
6477            "0000000000000000000000000000000000000000".to_owned(),
6478            "irrelevant".to_owned(),
6479            crate::config::Config::default(),
6480        );
6481        assert_eq!(find_external_merge(&state).await.unwrap(), None);
6482    }
6483
6484    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6485        let mut run = RunState::new(
6486            PathBuf::from(repo),
6487            "main".to_owned(),
6488            "abcdef1234".to_owned(),
6489            "x".to_owned(),
6490            crate::config::Config::default(),
6491        );
6492        run.id = id.to_owned();
6493        run.status = status;
6494        run.pr = Some(crate::run::PrRecord {
6495            number: url.rsplit('/').next().unwrap().parse().unwrap(),
6496            url: url.to_owned(),
6497            state: state.to_owned(),
6498            checks: "red".to_owned(),
6499            round: 0,
6500            rounds: 2,
6501            red_at_merge: Vec::new(),
6502        });
6503        run.save_under(home).unwrap();
6504    }
6505
6506    fn recorded(home: &Path, id: &str) -> String {
6507        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6508        serde_json::from_str::<RunState>(&body)
6509            .unwrap()
6510            .pr
6511            .unwrap()
6512            .state
6513    }
6514
6515    const PR: &str = "https://github.com/o/r/pull/7";
6516
6517    #[test]
6518    fn write_through_updates_predecessors_and_siblings_only() {
6519        let tmp = tempfile::tempdir().unwrap();
6520        let h = tmp.path();
6521        pr_run(
6522            h,
6523            "20261004-100000-aaaa",
6524            "/repo/r",
6525            RunStatus::Superseded,
6526            PR,
6527            "open",
6528        );
6529        pr_run(
6530            h,
6531            "20261004-100100-bbbb",
6532            "/repo/r",
6533            RunStatus::Blocked,
6534            PR,
6535            "open",
6536        );
6537        // Not terminal: a driver may be writing it.
6538        pr_run(
6539            h,
6540            "20261004-100200-cccc",
6541            "/repo/r",
6542            RunStatus::Landing,
6543            PR,
6544            "open",
6545        );
6546        // Another repository's pull request with the same number.
6547        pr_run(
6548            h,
6549            "20261004-100300-dddd",
6550            "/repo/other",
6551            RunStatus::Blocked,
6552            "https://github.com/o/other/pull/7",
6553            "open",
6554        );
6555        // A different pull request of the same repository.
6556        pr_run(
6557            h,
6558            "20261004-100400-eeee",
6559            "/repo/r",
6560            RunStatus::Blocked,
6561            "https://github.com/o/r/pull/8",
6562            "open",
6563        );
6564        pr_run(
6565            h,
6566            "20261004-100500-ffff",
6567            "/repo/r",
6568            RunStatus::Merged,
6569            PR,
6570            "open",
6571        );
6572        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6573
6574        assert_eq!(
6575            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6576            2
6577        );
6578        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6579        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6580        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6581        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6582        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6583        // The source's own record is the caller's to write.
6584        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6585        // Idempotent.
6586        assert_eq!(
6587            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6588            0
6589        );
6590        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6591        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6592    }
6593
6594    #[test]
6595    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6596        let tmp = tempfile::tempdir().unwrap();
6597        let h = tmp.path();
6598        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6599        pr_run(
6600            h,
6601            "20261004-100000-aaaa",
6602            "/repo/r",
6603            RunStatus::Superseded,
6604            &url(1),
6605            "open",
6606        );
6607        pr_run(
6608            h,
6609            "20261004-100100-bbbb",
6610            "/repo/r",
6611            RunStatus::Blocked,
6612            &url(2),
6613            "open",
6614        );
6615        pr_run(
6616            h,
6617            "20261004-100200-cccc",
6618            "/repo/r",
6619            RunStatus::Ready,
6620            &url(3),
6621            "open",
6622        );
6623        pr_run(
6624            h,
6625            "20261004-100300-dddd",
6626            "/repo/r",
6627            RunStatus::Ready,
6628            &url(4),
6629            "open",
6630        );
6631        pr_run(
6632            h,
6633            "20261004-100400-eeee",
6634            "/repo/r",
6635            RunStatus::Implementing,
6636            &url(1),
6637            "open",
6638        );
6639        assert_eq!(stale_open_prs(h).len(), 4);
6640
6641        let mut known = BTreeMap::new();
6642        known.insert(url(1), PrLifecycle::Merged);
6643        known.insert(url(2), PrLifecycle::Closed);
6644        known.insert(url(3), PrLifecycle::Open);
6645        // #4: the forge could not be read, so it has no answer.
6646        assert_eq!(apply_pr_states(h, &known), 2);
6647        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6648        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6649        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6650        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6651        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6652        assert_eq!(apply_pr_states(h, &known), 0);
6653    }
6654
6655    // --- the land loop against a scripted forge -------------------------
6656
6657    use std::collections::VecDeque;
6658    use std::sync::Mutex;
6659
6660    /// Answers views from a script (the last one repeats), merges from a
6661    /// queue, and records every call so a test can assert the order.
6662    struct Scripted {
6663        views: Mutex<VecDeque<Seen>>,
6664        merges: Mutex<VecDeque<(bool, String)>>,
6665        fix: Mutex<Option<Fixed>>,
6666        log: Mutex<Vec<&'static str>>,
6667        argvs: Mutex<Vec<Vec<String>>>,
6668        required: Mutex<Option<BTreeSet<String>>>,
6669        /// Set once a merge answered ok: the forge then reports `merged`,
6670        /// unless `queued` says the merge only entered a queue.
6671        merged: Mutex<bool>,
6672        queued: Mutex<bool>,
6673        /// Views fail once a merge answered ok.
6674        unreadable_after_merge: Mutex<bool>,
6675        /// Workflow runs asked to rerun, and whether that call fails.
6676        reruns: Mutex<Vec<String>>,
6677        rerun_fails: Mutex<bool>,
6678        /// Unix seconds; every poll moves it on by `POLL`.
6679        clock: Mutex<i64>,
6680    }
6681
6682    impl Scripted {
6683        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6684            Self {
6685                views: Mutex::new(views.into()),
6686                merges: Mutex::new(
6687                    merges
6688                        .into_iter()
6689                        .map(|(ok, m)| (ok, m.to_owned()))
6690                        .collect(),
6691                ),
6692                fix: Mutex::new(None),
6693                log: Mutex::new(Vec::new()),
6694                argvs: Mutex::new(Vec::new()),
6695                required: Mutex::new(None),
6696                merged: Mutex::new(false),
6697                queued: Mutex::new(false),
6698                unreadable_after_merge: Mutex::new(false),
6699                reruns: Mutex::new(Vec::new()),
6700                rerun_fails: Mutex::new(false),
6701                clock: Mutex::new(1_000),
6702            }
6703        }
6704        fn argvs(&self) -> Vec<Vec<String>> {
6705            self.argvs.lock().unwrap().clone()
6706        }
6707        fn calls(&self) -> Vec<&'static str> {
6708            self.log.lock().unwrap().clone()
6709        }
6710    }
6711
6712    impl Forge for Scripted {
6713        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6714            self.log.lock().unwrap().push("view");
6715            if *self.unreadable_after_merge.lock().unwrap()
6716                && !self.argvs.lock().unwrap().is_empty()
6717            {
6718                anyhow::bail!("forge unreachable");
6719            }
6720            let mut v = self.views.lock().unwrap();
6721            let mut seen = if v.len() > 1 {
6722                v.pop_front().unwrap()
6723            } else {
6724                v[0].clone()
6725            };
6726            if *self.merged.lock().unwrap() {
6727                seen.pr.state = PrLifecycle::Merged;
6728            }
6729            Ok(seen)
6730        }
6731        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6732            self.log.lock().unwrap().push("merge");
6733            self.argvs.lock().unwrap().push(argv.to_vec());
6734            let out = self
6735                .merges
6736                .lock()
6737                .unwrap()
6738                .pop_front()
6739                .expect("unscripted merge");
6740            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6741            {
6742                *self.merged.lock().unwrap() = true;
6743            }
6744            Ok(out)
6745        }
6746        async fn poll(&self) {
6747            self.log.lock().unwrap().push("poll");
6748            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
6749        }
6750        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
6751            self.log.lock().unwrap().push("rerun");
6752            self.reruns.lock().unwrap().push(run.to_owned());
6753            if *self.rerun_fails.lock().unwrap() {
6754                anyhow::bail!("gh run rerun: no");
6755            }
6756            Ok(())
6757        }
6758        fn now(&self) -> i64 {
6759            *self.clock.lock().unwrap()
6760        }
6761        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6762            self.required.lock().unwrap().clone()
6763        }
6764        async fn fix(
6765            &self,
6766            _state: &mut RunState,
6767            _pr: &PrState,
6768            _round: usize,
6769            _budget: usize,
6770            _reason: &str,
6771            _logs: &str,
6772        ) -> Result<Fixed> {
6773            self.log.lock().unwrap().push("fix");
6774            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6775        }
6776    }
6777
6778    const REFUSED: &str =
6779        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6780
6781    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6782        let mut pr = green_pr();
6783        pr.checks = checks;
6784        pr.blocking = Blocking::of(merge_state);
6785        if !comments {
6786            pr.review_comments.clear();
6787        }
6788        Seen {
6789            pr,
6790            title: "feat: x".to_owned(),
6791            failing_urls: Vec::new(),
6792            head: head.to_owned(),
6793            rollup_head: head.to_owned(),
6794            merge_state: merge_state.to_owned(),
6795            contexts: Vec::new(),
6796            base: "main".to_owned(),
6797        }
6798    }
6799
6800    fn landing_state() -> RunState {
6801        crate::run::pin_test_home();
6802        let mut state = run_state();
6803        state.config.graph.land_approval = false;
6804        state
6805    }
6806
6807    #[test]
6808    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6809        assert!(!awaiting_new_head(None, "aaa"));
6810        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6811        assert!(awaiting_new_head(Some("abc123"), "def456"));
6812        assert!(awaiting_new_head(Some("abc123"), ""));
6813    }
6814
6815    #[test]
6816    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6817        let open = |c, m: &str| seen("a", c, m, false);
6818        let table = [
6819            (None, false, Refused::Pending),
6820            (
6821                Some(open(Checks::Pending, "BLOCKED")),
6822                false,
6823                Refused::Pending,
6824            ),
6825            (
6826                Some(open(Checks::Unknown, "BLOCKED")),
6827                false,
6828                Refused::Pending,
6829            ),
6830            (
6831                Some(open(Checks::Green, "UNKNOWN")),
6832                false,
6833                Refused::Pending,
6834            ),
6835            (Some(open(Checks::Green, "")), false, Refused::Pending),
6836            (
6837                Some(open(Checks::Green, "BLOCKED")),
6838                false,
6839                Refused::Recheck,
6840            ),
6841            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6842        ];
6843        for (after, rechecked, want) in table {
6844            assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6845        }
6846        let mut closed = open(Checks::Green, "CLEAN");
6847        closed.pr.state = PrLifecycle::Closed;
6848        assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6849    }
6850
6851    #[tokio::test]
6852    async fn a_normal_landing_merges_on_the_first_look() {
6853        let mut state = landing_state();
6854        let forge = Scripted::new(
6855            vec![seen("a", Checks::Green, "CLEAN", false)],
6856            vec![(true, "")],
6857        );
6858        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6859            .await
6860            .unwrap();
6861        // One fresh read, then the head-bound merge.
6862        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6863        assert_eq!(state.status, RunStatus::Merged);
6864    }
6865
6866    #[tokio::test]
6867    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6868        let mut state = landing_state();
6869        let forge = Scripted::new(
6870            vec![seen("a", Checks::Green, "CLEAN", false)],
6871            std::iter::repeat_n((true, ""), 100).collect(),
6872        );
6873        *forge.queued.lock().unwrap() = true;
6874        let task = async {
6875            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6876                .await
6877                .unwrap();
6878        };
6879        // Still open after the command succeeded: it keeps watching and
6880        // never records a merge (it stops at the wait ceiling instead).
6881        task.await;
6882        assert_ne!(state.status, RunStatus::Merged);
6883    }
6884
6885    #[tokio::test]
6886    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6887        let mut state = landing_state();
6888        let forge = Scripted::new(
6889            vec![seen("a", Checks::Green, "CLEAN", false)],
6890            std::iter::repeat_n((true, ""), 100).collect(),
6891        );
6892        *forge.unreadable_after_merge.lock().unwrap() = true;
6893        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6894            .await
6895            .ok();
6896        assert_ne!(state.status, RunStatus::Merged);
6897    }
6898
6899    #[tokio::test]
6900    async fn a_task_public_head_is_awaited_on_land_entry() {
6901        let mut state = landing_state();
6902        state.pr_head = Some("magi/task-abcd-1234".into());
6903        state.pr_pushed_head = Some("new".into());
6904        let forge = Scripted::new(
6905            vec![
6906                seen("old", Checks::Green, "CLEAN", false),
6907                seen("new", Checks::Green, "CLEAN", false),
6908            ],
6909            vec![(true, "")],
6910        );
6911        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6912            .await
6913            .unwrap();
6914        assert_eq!(
6915            forge.calls(),
6916            ["view", "poll", "view", "view", "merge", "view"]
6917        );
6918        assert_eq!(state.status, RunStatus::Merged);
6919    }
6920
6921    #[tokio::test]
6922    async fn a_superseded_public_head_owner_cannot_reenter_land() {
6923        let mut state = landing_state();
6924        state.pr_taken_over_by = Some("next".into());
6925        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
6926        assert!(
6927            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6928                .await
6929                .is_err()
6930        );
6931        assert!(forge.calls().is_empty());
6932    }
6933
6934    #[tokio::test]
6935    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6936        let mut state = landing_state();
6937        let forge = Scripted::new(
6938            vec![
6939                seen("old", Checks::Green, "CLEAN", true),
6940                // The forge has not moved to the new head yet: still green.
6941                seen("old", Checks::Green, "CLEAN", true),
6942                seen("new", Checks::Pending, "BLOCKED", true),
6943                seen("new", Checks::Green, "CLEAN", true),
6944            ],
6945            vec![(true, "")],
6946        );
6947        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6948            head: "NEW".to_owned(),
6949        });
6950        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6951            .await
6952            .unwrap();
6953        assert_eq!(
6954            forge.calls(),
6955            [
6956                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
6957                "view"
6958            ]
6959        );
6960        assert_eq!(state.status, RunStatus::Merged);
6961    }
6962
6963    #[tokio::test]
6964    async fn a_head_that_never_arrives_stops_naming_both_commits() {
6965        let mut state = landing_state();
6966        let forge = Scripted::new(
6967            vec![
6968                seen("old", Checks::Green, "CLEAN", true),
6969                seen("someone-elses", Checks::Green, "CLEAN", true),
6970            ],
6971            vec![],
6972        );
6973        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6974            head: "mine".to_owned(),
6975        });
6976        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6977            .await
6978            .unwrap();
6979        assert!(!forge.calls().contains(&"merge"));
6980        let why = state.merge.as_ref().unwrap().detail.clone();
6981        assert!(
6982            why.contains("mine") && why.contains("someone-elses"),
6983            "{why}"
6984        );
6985        assert_eq!(state.status, RunStatus::Blocked);
6986    }
6987
6988    #[tokio::test]
6989    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6990        let mut state = landing_state();
6991        let forge = Scripted::new(
6992            vec![
6993                seen("a", Checks::Green, "CLEAN", false),
6994                seen("a", Checks::Green, "CLEAN", false),
6995                seen("a", Checks::Pending, "BLOCKED", false),
6996                seen("a", Checks::Pending, "BLOCKED", false),
6997                seen("a", Checks::Green, "CLEAN", false),
6998            ],
6999            vec![(false, REFUSED), (true, "")],
7000        );
7001        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7002            .await
7003            .unwrap();
7004        assert_eq!(
7005            forge.calls(),
7006            [
7007                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
7008                "view"
7009            ]
7010        );
7011        assert_eq!(state.status, RunStatus::Merged);
7012    }
7013
7014    #[tokio::test]
7015    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
7016        let mut state = landing_state();
7017        let forge = Scripted::new(
7018            vec![
7019                seen("a", Checks::Green, "CLEAN", false),
7020                seen("a", Checks::Green, "BLOCKED", false),
7021            ],
7022            vec![(false, REFUSED), (false, REFUSED)],
7023        );
7024        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7025            .await
7026            .unwrap();
7027        // One re-look is allowed for the forge's own lag, then it is final.
7028        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
7029        let why = state.merge.as_ref().unwrap().detail.clone();
7030        assert!(
7031            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
7032            "{why}"
7033        );
7034        assert_eq!(state.status, RunStatus::Blocked);
7035    }
7036
7037    #[test]
7038    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
7039        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
7040        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
7041        // The pull request is still on the commit before the push.
7042        assert_eq!(bound_head("old", "old", Some("new")), None);
7043        // The checks are the previous commit's.
7044        assert_eq!(bound_head("new", "old", Some("new")), None);
7045        assert_eq!(bound_head("new", "old", None), None);
7046        // Nothing readable.
7047        assert_eq!(bound_head("", "", None), None);
7048        assert_eq!(bound_head("", "", Some("new")), None);
7049        assert_eq!(bound_head("abc", "", None), None);
7050    }
7051
7052    fn view_json(head: &str) -> String {
7053        format!(
7054            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
7055            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
7056            "reviews":[],"comments":[]}}"#
7057        )
7058    }
7059
7060    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
7061        format!(
7062            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
7063            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
7064            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
7065            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
7066        )
7067    }
7068
7069    #[test]
7070    fn rollup_is_bound_to_the_commit_in_the_same_node() {
7071        // The view already points at the new head, but the node still answers
7072        // for the old commit with its red check: the head stays unbound.
7073        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
7074        assert_eq!(s.rollup_head, "old");
7075        assert_eq!(s.pr.checks, Checks::Red);
7076        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
7077        assert_eq!(s.failing_urls.len(), 1);
7078    }
7079
7080    #[test]
7081    fn checks_come_from_the_node_not_the_view() {
7082        let view = view_json("new").replace(
7083            r#""reviews""#,
7084            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
7085        );
7086        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
7087        assert_eq!(s.pr.checks, Checks::Green);
7088        assert!(s.pr.failing.is_empty());
7089        assert_eq!(
7090            bound_head(&s.head, &s.rollup_head, Some("new")),
7091            Some("new")
7092        );
7093    }
7094
7095    #[test]
7096    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
7097        for node in [
7098            None,
7099            Some("not json".to_owned()),
7100            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
7101            Some(node_json("new", "SUCCESS", true)),
7102        ] {
7103            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
7104            assert!(s.rollup_head.is_empty());
7105            assert_eq!(s.pr.checks, Checks::Unknown);
7106            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
7107        }
7108    }
7109
7110    #[test]
7111    fn the_merge_command_is_pinned_to_the_observed_head() {
7112        let argv = merge_argv_at(7, "feat: x", "deadbeef");
7113        let at = argv
7114            .iter()
7115            .position(|a| a == "--match-head-commit")
7116            .unwrap();
7117        assert_eq!(argv[at + 1], "deadbeef");
7118    }
7119
7120    #[tokio::test]
7121    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
7122        let mut state = landing_state();
7123        // The pull request is on the pushed head but the rollup is still the
7124        // previous commit's red, non-required result.
7125        let mut stale = seen("new", Checks::Red, "CLEAN", false);
7126        stale.rollup_head = "old".to_owned();
7127        let forge = Scripted::new(
7128            vec![seen("old", Checks::Green, "CLEAN", true), stale],
7129            vec![],
7130        );
7131        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7132            head: "new".to_owned(),
7133        });
7134        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7135            .await
7136            .unwrap();
7137        assert!(!forge.calls().contains(&"merge"));
7138        assert_eq!(state.status, RunStatus::Blocked);
7139        let why = state.merge.as_ref().unwrap().detail.clone();
7140        assert!(why.contains("new") && why.contains("old"), "{why}");
7141    }
7142
7143    #[test]
7144    fn a_refusal_read_against_another_commits_checks_is_pending() {
7145        let mut after = seen("a", Checks::Green, "BLOCKED", false);
7146        after.rollup_head = "old".to_owned();
7147        assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
7148    }
7149
7150    #[tokio::test]
7151    async fn a_matching_head_with_red_non_required_checks_still_merges() {
7152        let mut state = landing_state();
7153        let forge = Scripted::new(
7154            vec![seen("a", Checks::Red, "CLEAN", false)],
7155            vec![(true, "")],
7156        );
7157        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7158            .await
7159            .unwrap();
7160        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7161        assert_eq!(state.status, RunStatus::Merged);
7162    }
7163
7164    #[tokio::test]
7165    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
7166        let mut state = landing_state();
7167        let forge = Scripted::new(
7168            vec![
7169                seen("a", Checks::Green, "CLEAN", false),
7170                seen("a", Checks::Green, "CLEAN", false),
7171                // Re-viewed after the refusal: someone pushed.
7172                seen("b", Checks::Green, "BLOCKED", false),
7173                seen("b", Checks::Green, "CLEAN", false),
7174            ],
7175            vec![(false, REFUSED), (true, "")],
7176        );
7177        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7178            .await
7179            .unwrap();
7180        assert_eq!(
7181            forge.calls(),
7182            [
7183                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
7184            ]
7185        );
7186        assert_eq!(state.status, RunStatus::Merged);
7187    }
7188
7189    fn merged_view(head: &str) -> Seen {
7190        let mut m = seen(head, Checks::Green, "CLEAN", false);
7191        m.pr.state = PrLifecycle::Merged;
7192        m
7193    }
7194
7195    fn has(argv: &[String], flag: &str) -> bool {
7196        argv.iter().any(|a| a == flag)
7197    }
7198
7199    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
7200        let at = argv.iter().position(|a| a == flag)?;
7201        argv.get(at + 1).map(String::as_str)
7202    }
7203
7204    const URL: &str = "https://github.com/o/r/pull/42";
7205
7206    #[tokio::test]
7207    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
7208        let mut state = landing_state();
7209        let forge = Scripted::new(
7210            vec![
7211                seen("abc", Checks::Green, "CLEAN", false),
7212                seen("abc", Checks::Green, "CLEAN", false),
7213            ],
7214            vec![(true, "")],
7215        );
7216        land_with(&mut state, URL, &forge).await.unwrap();
7217        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7218        let argv = &forge.argvs()[0];
7219        assert!(has(argv, "--squash") && has(argv, "--subject"));
7220        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
7221        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
7222        assert_eq!(state.status, RunStatus::Merged);
7223        assert!(state.land_armed_head.is_none());
7224    }
7225
7226    #[tokio::test]
7227    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
7228        let mut state = landing_state();
7229        state.land_armed_head = Some("a".to_owned());
7230        let forge = Scripted::new(
7231            vec![seen("a", Checks::Green, "CLEAN", false)],
7232            vec![(true, ""), (true, "")],
7233        );
7234        land_with(&mut state, URL, &forge).await.unwrap();
7235        let argvs = forge.argvs();
7236        assert!(has(&argvs[0], "--disable-auto"));
7237        assert!(!has(&argvs[1], "--auto"));
7238        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
7239        assert_eq!(state.status, RunStatus::Merged);
7240        assert!(state.land_armed_head.is_none());
7241    }
7242
7243    #[tokio::test]
7244    async fn an_approval_never_carries_over_to_a_new_head() {
7245        crate::run::pin_test_home();
7246        let mut state = run_state();
7247        state.config.graph.land_approval = true;
7248        let pr = green_pr();
7249        let store = ask::Questions::open();
7250
7251        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
7252            .await
7253            .unwrap();
7254        let mut q = store
7255            .list()
7256            .into_iter()
7257            .find(|q| q.run == state.id)
7258            .unwrap();
7259        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7260        store.put(&mut q).unwrap();
7261        assert_eq!(
7262            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
7263                .await
7264                .unwrap(),
7265            ApprovalGate::Approved,
7266            "the same head keeps its approval"
7267        );
7268
7269        // A new head is a new question, not the old word.
7270        assert_eq!(
7271            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7272                .await
7273                .unwrap(),
7274            ApprovalGate::Pending
7275        );
7276        let all: Vec<_> = store
7277            .list()
7278            .into_iter()
7279            .filter(|q| q.run == state.id)
7280            .collect();
7281        assert_eq!(all.len(), 2);
7282
7283        // A question recorded before heads were tracked is not reused either.
7284        state.land_approval = None;
7285        assert_eq!(
7286            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7287                .await
7288                .unwrap(),
7289            ApprovalGate::Pending
7290        );
7291        let open = store
7292            .list()
7293            .into_iter()
7294            .filter(|q| q.run == state.id && q.status.open())
7295            .count();
7296        assert_eq!(open, 1, "the superseded question was retired");
7297    }
7298
7299    #[tokio::test]
7300    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
7301        let mut state = landing_state();
7302        let forge = Scripted::new(
7303            vec![
7304                seen("a", Checks::Green, "CLEAN", false),
7305                // The fresh read before the merge: someone pushed.
7306                seen("b", Checks::Green, "CLEAN", false),
7307            ],
7308            vec![(true, "")],
7309        );
7310        land_with(&mut state, URL, &forge).await.unwrap();
7311        let argvs = forge.argvs();
7312        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
7313        assert!(!has(&argvs[0], "--auto"));
7314        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
7315        assert_eq!(state.status, RunStatus::Merged);
7316    }
7317
7318    fn passing(label: &str) -> CheckInfo {
7319        CheckInfo {
7320            label: label.to_owned(),
7321            verdict: Verdict::Pass,
7322            required: Some(false),
7323            run: None,
7324        }
7325    }
7326
7327    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
7328        (label.to_owned(), run.map(str::to_owned))
7329    }
7330
7331    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
7332        LandReruns {
7333            head: head.to_owned(),
7334            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
7335        }
7336    }
7337
7338    #[test]
7339    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
7340        let f = [failing("test (macos)", Some("7"))];
7341        assert_eq!(
7342            rerun_step(&f, None, "h", 100),
7343            RerunStep::Rerun(vec!["7".to_owned()])
7344        );
7345        let rec = reran("h", &[("7", 100)]);
7346        let grace = crate::release_watch::RERUN_GRACE;
7347        assert_eq!(
7348            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
7349            RerunStep::Grace
7350        );
7351        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
7352    }
7353
7354    #[test]
7355    fn a_failure_without_a_run_id_goes_to_the_fixer() {
7356        let f = [failing("a", Some("7")), failing("ci/external", None)];
7357        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
7358        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
7359    }
7360
7361    #[test]
7362    fn a_moved_head_forgets_the_reruns() {
7363        let f = [failing("a", Some("7"))];
7364        let rec = reran("old", &[("7", 100)]);
7365        assert_eq!(
7366            rerun_step(&f, Some(&rec), "new", 101),
7367            RerunStep::Rerun(vec!["7".to_owned()])
7368        );
7369    }
7370
7371    #[test]
7372    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
7373        let f = [
7374            failing("a", Some("7")),
7375            failing("b", Some("7")),
7376            failing("c", Some("8")),
7377        ];
7378        assert_eq!(
7379            rerun_step(&f, None, "h", 0),
7380            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
7381        );
7382        let rec = reran("h", &[("7", 0)]);
7383        assert_eq!(
7384            rerun_step(&f, Some(&rec), "h", 1),
7385            RerunStep::Rerun(vec!["8".to_owned()])
7386        );
7387    }
7388
7389    #[test]
7390    fn only_blocking_failures_are_candidates_for_a_rerun() {
7391        let check = |label: &str, verdict, required, run: &str| CheckInfo {
7392            label: label.to_owned(),
7393            verdict,
7394            required,
7395            run: Some(run.to_owned()),
7396        };
7397        let got = rerunnable(&[
7398            check("passed", Verdict::Pass, Some(true), "1"),
7399            check("optional", Verdict::Fail, Some(false), "2"),
7400            check("required", Verdict::Fail, Some(true), "3"),
7401            check("unknown", Verdict::Fail, None, "4"),
7402        ]);
7403        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
7404        assert_eq!(labels, ["required", "unknown"]);
7405    }
7406
7407    fn red(head: &str, run: Option<&str>) -> Seen {
7408        let mut s = seen(head, Checks::Red, "BLOCKED", false);
7409        s.pr.failing = vec!["test (macos)".to_owned()];
7410        s.contexts = vec![CheckInfo {
7411            label: "test (macos)".to_owned(),
7412            verdict: Verdict::Fail,
7413            required: Some(true),
7414            run: run.map(str::to_owned),
7415        }];
7416        s
7417    }
7418
7419    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
7420
7421    #[tokio::test]
7422    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
7423        let mut state = landing_state();
7424        let forge = Scripted::new(
7425            vec![
7426                red("a", Some("7")),
7427                seen("a", Checks::Pending, "BLOCKED", false),
7428                seen("a", Checks::Green, "CLEAN", false),
7429            ],
7430            vec![(true, "")],
7431        );
7432        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7433        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7434        assert!(!forge.calls().contains(&"fix"));
7435        assert_eq!(state.status, RunStatus::Merged);
7436        assert_eq!(state.pr.as_ref().unwrap().round, 0);
7437        let argvs = forge.argvs();
7438        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
7439        assert!(
7440            state
7441                .events
7442                .iter()
7443                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
7444        );
7445    }
7446
7447    #[tokio::test]
7448    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
7449        let mut state = landing_state();
7450        let forge = Scripted::new(
7451            vec![
7452                red("a", Some("7")),
7453                seen("a", Checks::Green, "CLEAN", false),
7454            ],
7455            vec![(true, "")],
7456        );
7457        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7458        // The red view repeats for the grace, then the fixer is asked once.
7459        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7460        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
7461        let calls = forge.calls();
7462        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
7463        let fix_at = calls.iter().position(|c| *c == "fix");
7464        assert!(fix_at.is_none_or(|f| f > rerun_at));
7465    }
7466
7467    #[tokio::test]
7468    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
7469        let mut state = landing_state();
7470        let mut v = red("a", Some("7"));
7471        v.pr.checks = Checks::Pending;
7472        v.contexts.push(CheckInfo {
7473            label: "slow".to_owned(),
7474            verdict: Verdict::Pending,
7475            required: Some(true),
7476            run: Some("8".to_owned()),
7477        });
7478        let forge = Scripted::new(
7479            vec![v, seen("a", Checks::Green, "CLEAN", false)],
7480            vec![(true, "")],
7481        );
7482        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7483        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7484        assert_eq!(state.status, RunStatus::Merged);
7485    }
7486
7487    #[test]
7488    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
7489        let mut v = red("a", Some("7"));
7490        v.contexts.push(CheckInfo {
7491            label: "same run, still going".to_owned(),
7492            verdict: Verdict::Pending,
7493            required: Some(true),
7494            run: Some("7".to_owned()),
7495        });
7496        assert!(rerunnable(&v.contexts).is_empty());
7497    }
7498
7499    #[tokio::test]
7500    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
7501        let mut state = landing_state();
7502        state.config.graph.land_rounds = 0;
7503        let forge = Scripted::new(
7504            vec![
7505                red("a", Some("7")),
7506                seen("a", Checks::Green, "CLEAN", false),
7507            ],
7508            vec![(true, "")],
7509        );
7510        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7511        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7512        assert_eq!(state.status, RunStatus::Merged);
7513        assert!(!forge.calls().contains(&"fix"));
7514        assert_eq!(state.pr.as_ref().unwrap().round, 0);
7515    }
7516
7517    #[tokio::test]
7518    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
7519        let mut state = landing_state();
7520        state.config.graph.land_rounds = 1;
7521        let forge = Scripted::new(
7522            vec![
7523                seen("a", Checks::Green, "CLEAN", true),
7524                red("b", Some("7")),
7525                seen("b", Checks::Green, "CLEAN", false),
7526            ],
7527            vec![(true, "")],
7528        );
7529        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7530            head: "b".to_owned(),
7531        });
7532        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7533        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7534        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
7535        assert_eq!(state.pr.as_ref().unwrap().round, 1);
7536        assert_eq!(state.status, RunStatus::Merged);
7537        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
7538        assert_eq!(
7539            value_of(&forge.argvs()[0], "--match-head-commit"),
7540            Some("b")
7541        );
7542    }
7543
7544    #[tokio::test]
7545    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
7546        let mut state = landing_state();
7547        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7548        *forge.rerun_fails.lock().unwrap() = true;
7549        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7550        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7551        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
7552        assert_eq!(
7553            forge.calls().iter().filter(|c| **c == "fix").count(),
7554            1,
7555            "{:?}",
7556            forge.calls()
7557        );
7558    }
7559
7560    #[tokio::test]
7561    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
7562        let mut state = landing_state();
7563        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7564        // The record was written just before the previous pass died.
7565        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
7566        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7567        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7568        assert!(forge.reruns.lock().unwrap().is_empty());
7569    }
7570
7571    #[tokio::test]
7572    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
7573        let mut state = landing_state();
7574        state.config.graph.rerun_flaky_checks = false;
7575        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7576        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7577        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7578        assert!(forge.reruns.lock().unwrap().is_empty());
7579        assert!(forge.calls().contains(&"fix"));
7580
7581        let mut state = landing_state();
7582        let forge = Scripted::new(vec![red("a", None)], vec![]);
7583        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7584        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7585        assert!(forge.reruns.lock().unwrap().is_empty());
7586        assert!(forge.calls().contains(&"fix"));
7587    }
7588
7589    fn names(xs: &[&str]) -> BTreeSet<String> {
7590        xs.iter().map(|x| (*x).to_owned()).collect()
7591    }
7592
7593    #[test]
7594    fn a_required_check_the_rollup_never_listed_is_named() {
7595        let req = names(&["build"]);
7596        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
7597        assert!(why.contains("never reported: build"), "{why}");
7598        assert!(!why.contains("probably waiting for a review"), "{why}");
7599    }
7600
7601    #[test]
7602    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
7603        let why = waiting_on("BLOCKED", &[passing("review")], None);
7604        assert!(why.contains("could not be read"), "{why}");
7605        assert!(!why.contains("probably waiting for a review"), "{why}");
7606    }
7607
7608    #[test]
7609    fn all_required_reported_keeps_the_review_guess() {
7610        let req = names(&["build"]);
7611        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7612        assert!(why.contains("probably waiting for a review"), "{why}");
7613        assert!(!why.contains("never reported"), "{why}");
7614    }
7615
7616    #[test]
7617    fn required_names_match_the_rollup_ignoring_case_only() {
7618        let req = names(&["Build"]);
7619        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7620        assert!(!why.contains("never reported"), "{why}");
7621    }
7622
7623    #[test]
7624    fn required_contexts_are_read_from_protection_and_rulesets() {
7625        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7626        assert_eq!(
7627            parse_classic_required(classic),
7628            Some(names(&["build", "lint"]))
7629        );
7630        let rules = r#"[{"type":"pull_request","parameters":{}},
7631            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7632        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7633        assert_eq!(parse_ruleset_required("nope"), None);
7634        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7635    }
7636
7637    #[test]
7638    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7639        let shown = BTreeSet::new();
7640        let ok = seen("a", Checks::Green, "CLEAN", false);
7641        let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7642        assert!(guard(Some(&ok)));
7643        assert!(!guard(None));
7644        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7645        let mut stale = ok.clone();
7646        stale.rollup_head = "old".to_owned();
7647        assert!(!guard(Some(&stale)));
7648        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7649        assert!(!guard(Some(&merged_view("a"))));
7650    }
7651
7652    #[test]
7653    fn the_rollup_node_carries_whether_each_check_is_required() {
7654        let node = node_json("new", "SUCCESS", false)
7655            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7656        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7657        assert_eq!(s.contexts.len(), 1);
7658        assert_eq!(s.contexts[0].required, Some(true));
7659        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7660        assert_eq!(s.contexts[0].required, None);
7661    }
7662
7663    #[tokio::test]
7664    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7665        let mut state = landing_state();
7666        state.land_armed_head = Some("a".to_owned());
7667        let forge = Scripted::new(
7668            vec![seen("a", Checks::Green, "CLEAN", true)],
7669            vec![(false, "disable exploded")],
7670        );
7671        land_with(&mut state, URL, &forge).await.unwrap();
7672        assert!(!forge.calls().contains(&"fix"));
7673        assert_eq!(state.status, RunStatus::Blocked);
7674        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7675        let why = state.merge.as_ref().unwrap().detail.clone();
7676        assert!(why.contains("disable exploded"), "{why}");
7677    }
7678}