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