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