Skip to main content

magi/
bump.rs

1//! Release version bumps, opened automatically once a merge lands.
2//!
3//! `magi`'s own "Update & restart" only ever looks at tagged GitHub Releases
4//! (`src/updater.rs`); it never builds or tags anything itself. The tag comes
5//! from `auto-tag.yml` noticing a `Cargo.toml` version change on `main`, and
6//! nothing in the graph used to touch that field - a merge that changed the
7//! phone-facing binary left `main` ahead of the last tagged release with
8//! nobody to notice, and the next "Update & restart" found nothing newer.
9//!
10//! This module is the fix. Once [`crate::land`] confirms a merge, the caller
11//! in [`crate::graph`] hands off here: an agent is asked which digit of
12//! `major.minor.patch` the change earns, and this module opens the same
13//! `chore/release-vX.Y.Z` pull request `AGENTS.md` already documents as the
14//! hand-driven recipe, with automerge enabled so CI green is the only thing
15//! standing between the merge and the tag.
16//!
17//! Everything that can be decided without touching a network or a `cargo`
18//! binary is a pure function - the version arithmetic, the `Cargo.toml`
19//! rewrite, the prompt, the coalescing policy - so the policy itself is
20//! asserted directly, the same split [`crate::land`] uses for [`land::decide`](crate::land::decide).
21
22use std::fmt::Write as _;
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26use anyhow::{Context as _, Result, bail};
27use serde::{Deserialize, Serialize};
28
29use crate::agent::{self, Invocation, SeatState};
30use crate::config::AgentSpec;
31use crate::git;
32use crate::land;
33use crate::proc::Quiet as _;
34use crate::run::{self, RunState, RunStatus};
35use crate::verdict;
36
37/// How long the decision call may run.
38///
39/// It reads a diffstat, a subject line and a version string, and returns
40/// three words and a sentence - nowhere near the budget an implement wave
41/// gets, so a fixed, generous constant is simpler than a new config knob for
42/// a call this small.
43const DECISION_TIMEOUT: Duration = Duration::from_secs(600);
44
45/// Does this run's final status mean the merge this call is downstream of
46/// actually happened?
47///
48/// All three of `land`'s success paths converge on the same signal before
49/// [`crate::graph`] ever calls into this module: a pull request already
50/// merged underneath magi (`land::Step::Done { merged: true }`),
51/// `land::Step::Merge`'s own `gh pr merge` succeeding, and the
52/// [`land::merged_after_all`] recovery for a non-zero exit that merged
53/// anyway. Every one of them ends `land::land` with `pr.state ==
54/// PrLifecycle::Merged`, which is exactly what `graph::Runner::merge` reads
55/// to set `RunStatus::Merged` on the run - see the `all_three_merge_paths_*`
56/// tests below for each path's own evidence. Every path that does *not* land
57/// (a close, `Step::GiveUp`, an unanswered `land_approval`, or a `gh pr
58/// merge` failure the forge does not confirm) leaves the run `Blocked`
59/// instead, so this one check is the whole gate a caller needs.
60pub fn should_release_bump(status: RunStatus) -> bool {
61    status == RunStatus::Merged
62}
63
64/// Which digit of `major.minor.patch` a change earns.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum BumpLevel {
68    /// A breaking change to a public surface.
69    Major,
70    /// A user-visible new capability, or - below `1.0.0` - a breaking change.
71    Minor,
72    /// A fix, internal refactor, or dependency update.
73    Patch,
74}
75
76impl BumpLevel {
77    /// Stable lower-case name, as the prompt and the events spell it.
78    pub fn as_str(self) -> &'static str {
79        match self {
80            Self::Major => "major",
81            Self::Minor => "minor",
82            Self::Patch => "patch",
83        }
84    }
85
86    /// Severity for comparing two independent decisions: `patch < minor <
87    /// major`, spelled out explicitly rather than derived from declaration
88    /// order, which exists here only for readability and must not silently
89    /// become load-bearing.
90    fn severity(self) -> u8 {
91        match self {
92            Self::Patch => 0,
93            Self::Minor => 1,
94            Self::Major => 2,
95        }
96    }
97}
98
99/// The agent's answer: which digit, and why.
100///
101/// Parsed with [`verdict::extract_json`], so a reply missing `reason`, or
102/// spelling `level` as anything but `major` / `minor` / `patch`, is a parse
103/// error rather than a value with a blank field - [`parse_decision`] never
104/// fabricates a bump out of a response it could not read.
105#[derive(Debug, Clone, Deserialize)]
106pub struct BumpDecision {
107    /// The chosen digit.
108    pub level: BumpLevel,
109    /// One line, carried into the pull request body so "why was this minor"
110    /// is answerable later without archaeology.
111    pub reason: String,
112}
113
114/// Parse the agent's reply. Never returns a default decision: an unparsable
115/// or incomplete reply is `Err`, and the caller must not open a bump pull
116/// request on the strength of a guess.
117pub fn parse_decision(text: &str) -> Result<BumpDecision> {
118    let decision: BumpDecision = verdict::extract_json(text)?;
119    if decision.reason.trim().is_empty() {
120        bail!("the bump decision carried no reason");
121    }
122    Ok(decision)
123}
124
125/// `major.minor.patch`, the only shape a `[package] version` in this
126/// ecosystem carries in practice.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
128pub struct Version {
129    /// First component.
130    pub major: u64,
131    /// Second component.
132    pub minor: u64,
133    /// Third component.
134    pub patch: u64,
135}
136
137impl Version {
138    /// Parse `major.minor.patch`. A pre-release or build suffix on the patch
139    /// component (`0.8.0-rc1`) is tolerated by reading only its leading
140    /// digits - Cargo itself never writes one into `[package] version`, but a
141    /// human editing the file by hand might.
142    pub fn parse(s: &str) -> Result<Self> {
143        let s = s.trim();
144        let mut parts = s.splitn(3, '.');
145        let major = parts
146            .next()
147            .with_context(|| format!("`{s}` has no major component"))?;
148        let minor = parts
149            .next()
150            .with_context(|| format!("`{s}` has no minor component"))?;
151        let patch = parts
152            .next()
153            .with_context(|| format!("`{s}` has no patch component"))?;
154        let patch_digits: String = patch.chars().take_while(char::is_ascii_digit).collect();
155        Ok(Self {
156            major: major
157                .trim()
158                .parse()
159                .with_context(|| format!("`{major}` is not a number"))?,
160            minor: minor
161                .trim()
162                .parse()
163                .with_context(|| format!("`{minor}` is not a number"))?,
164            patch: patch_digits
165                .parse()
166                .with_context(|| format!("`{patch}` has no numeric patch component"))?,
167        })
168    }
169
170    /// The next version at `level`. A `major`/`minor` bump zeroes every digit
171    /// below it, matching what every tool that reads a semver range expects.
172    #[must_use]
173    pub fn bump(self, level: BumpLevel) -> Self {
174        match level {
175            BumpLevel::Major => Self {
176                major: self.major + 1,
177                minor: 0,
178                patch: 0,
179            },
180            BumpLevel::Minor => Self {
181                major: self.major,
182                minor: self.minor + 1,
183                patch: 0,
184            },
185            BumpLevel::Patch => Self {
186                major: self.major,
187                minor: self.minor,
188                patch: self.patch + 1,
189            },
190        }
191    }
192}
193
194impl std::fmt::Display for Version {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
197    }
198}
199
200/// Did the merged change touch only the release manifest and its lockfile?
201///
202/// [`after_merge`] is reached from *every* qualifying merge, including a
203/// version-bump pull request's own - without this check a bump would trigger
204/// another bump forever. A human-authored version-only pull request is exempt
205/// from review for the same reason (`AGENTS.md`'s "version-bump-only pull
206/// requests"), so using its shape as the "do not treat this as a trigger"
207/// test is one rule doing both jobs instead of two.
208pub fn is_release_only(files: &[String]) -> bool {
209    !files.is_empty() && files.iter().all(|f| f == "Cargo.toml" || f == "Cargo.lock")
210}
211
212/// Rewrite `table`'s `version = "..."` line, leaving every other byte
213/// untouched.
214///
215/// Scoped to the named table specifically, rather than the first line
216/// anywhere in the file that looks like `version = "..."`: a dependency
217/// pinned as `foo = { version = "1.2.3" }` must never move, and neither must
218/// the *other* of `[package]` / `[workspace.package]` when only one of them
219/// is the one being bumped. That scoping is what lets a version-bump-only
220/// diff stay exactly that, which [`is_release_only`] and the "no reviewer
221/// needed" exemption in `AGENTS.md` both rest on.
222fn rewrite_table_version(toml: &str, table: &str, new_version: &str) -> Result<String> {
223    let mut out = String::with_capacity(toml.len() + 8);
224    let mut in_table = false;
225    let mut done = false;
226    for line in toml.split_inclusive('\n') {
227        let trimmed = line.trim();
228        if trimmed.starts_with('[') {
229            in_table = trimmed == table;
230        }
231        if !done && in_table && trimmed.split('=').next().map(str::trim) == Some("version") {
232            let newline = if line.ends_with("\r\n") { "\r\n" } else { "\n" };
233            let _ = write!(out, "version = \"{new_version}\"{newline}");
234            done = true;
235            continue;
236        }
237        out.push_str(line);
238    }
239    if !done {
240        bail!("no `version` field found under `{table}`");
241    }
242    Ok(out)
243}
244
245/// Rewrite the release version, wherever this manifest actually declares it.
246///
247/// A single crate carries its version under `[package]`. A workspace root
248/// with no crate of its own - `[workspace] members = [...]` and nothing
249/// else - carries it under `[workspace.package]` instead, and `[package]`
250/// does not exist there at all. `[package]` is tried first because it is the
251/// far more common shape and the one every existing bump so far has hit;
252/// `[workspace.package]` is the fallback for the shape that never worked
253/// before this. Either way exactly one table is ever touched, so the "one
254/// version line changes" property [`rewrite_table_version`] rests on holds
255/// regardless of which table it was.
256pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
257    rewrite_table_version(toml, "[package]", new_version)
258        .or_else(|_| rewrite_table_version(toml, "[workspace.package]", new_version))
259        .context("no `version` field found under `[package]` or `[workspace.package]`")
260}
261
262/// Find `table`'s `version` field, if it has one. No I/O.
263fn version_in_table(toml: &str, table: &str) -> Option<String> {
264    let mut in_table = false;
265    for line in toml.lines() {
266        let trimmed = line.trim();
267        if trimmed.starts_with('[') {
268            in_table = trimmed == table;
269            continue;
270        }
271        if !in_table {
272            continue;
273        }
274        let mut parts = trimmed.splitn(2, '=');
275        let key = parts.next().map(str::trim);
276        let Some(value) = parts.next() else {
277            continue;
278        };
279        if key == Some("version") {
280            return Some(value.trim().trim_matches('"').to_owned());
281        }
282    }
283    None
284}
285
286/// Read the version currently on the base branch, from `[package]` if it has
287/// one, else from `[workspace.package]` - see [`rewrite_cargo_version`] for
288/// why both exist and which wins. No I/O: the caller fetches the blob (`git
289/// show <remote>/<base>:Cargo.toml`).
290fn current_version(toml: &str) -> Result<String> {
291    version_in_table(toml, "[package]")
292        .or_else(|| version_in_table(toml, "[workspace.package]"))
293        .context("no `version` field found under `[package]` or `[workspace.package]`")
294}
295
296/// Build the prompt asking an agent which digit of `major.minor.patch` a
297/// merged change earns.
298///
299/// Pure: every input is already known once a merge lands, so the whole
300/// decision policy - the "`minor` is the breaking digit below `1.0.0`" rule,
301/// what counts as a breaking surface, and the tie-break toward the larger
302/// digit - is asserted directly on the returned string, the same way
303/// [`crate::land::fix_prompt`] doc-comments its own rules rather than leaving
304/// them for a human to spot missing from a live reply.
305pub fn decision_prompt(
306    subject: &str,
307    instruction: &str,
308    diffstat: &str,
309    files: &[String],
310    current_version: &str,
311) -> String {
312    let mut s = format!(
313        "A pull request just merged into the base branch. Decide which digit \
314         of this project's `major.minor.patch` version this change earns, so \
315         a release bump can be opened for exactly it.\n\n\
316         Current version: {current_version}\n\n\
317         # Merge subject\n\n{subject}\n\n\
318         # The task that produced it\n\n{instruction}\n\n\
319         # Files changed ({} total)\n\n",
320        files.len()
321    );
322    const MAX_FILES: usize = 50;
323    for f in files.iter().take(MAX_FILES) {
324        let _ = writeln!(s, "- {f}");
325    }
326    if files.len() > MAX_FILES {
327        let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
328    }
329    let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
330
331    s.push_str(
332        "\n# How to decide\n\n\
333         This project is below version `1.0.0`. At that stage **`minor` is \
334         the digit that carries a breaking change** - do not spend `major` \
335         below `1.0.0`.\n\n\
336         A change is breaking, and earns `minor`, when it changes any of: \
337         the public API reachable from `src/lib.rs`, a CLI subcommand or \
338         flag, an HTTP API route or response shape, a configuration key, or \
339         the on-disk shape of persisted state.\n\n\
340         A user-visible new capability that breaks none of the above also \
341         earns `minor`.\n\n\
342         A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
343         **When it is not obvious which digit applies, choose the larger \
344         one.** An oversized bump costs nothing; a breaking change shipped as \
345         `patch` breaks every downstream update that pins a range.\n\n\
346         # Output\n\n\
347         Reply with exactly one fenced JSON object and nothing that matters \
348         outside it:\n\n\
349         ```json\n\
350         {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
351         ```\n",
352    );
353    // The reason is pasted into the release pull request's body.
354    let _ = write!(
355        s,
356        "\n{}\n\nThe `reason` goes into a GitHub pull request body, so write it \
357         in English.\n",
358        crate::prompt::GITHUB_ENGLISH_HEADING
359    );
360    s
361}
362
363/// magi's own record of a bump pull request it currently has open, so a
364/// burst of merges in quick succession does not each open a competing
365/// release.
366///
367/// **Chosen policy: serialize, not coalesce two independent decisions into
368/// one.** A bump branch touches only `Cargo.toml` / `Cargo.lock`, so `gh pr
369/// merge --squash` applies it onto whatever the base branch has become by
370/// the time it lands - every commit merged while it was open rides along
371/// for free, at no extra cost, once it merges. But the *digit* a still-open
372/// pull request targets was judged from only the first change, and a more
373/// severe change landing while it waits must not ship at the smaller digit
374/// just because it arrived second - so the serialization is at the pull
375/// request, not at the judgement: a later, more severe decision escalates
376/// the same open pull request (see [`pending_action`]) rather than opening a
377/// second one or being silently absorbed at the wrong digit.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct PendingBump {
380    /// The version the open pull request bumps to.
381    pub target_version: String,
382    /// The digit that version was judged to need, so a later, more severe
383    /// merge can tell it needs to escalate rather than assume it is covered.
384    pub level: BumpLevel,
385    /// The branch the open pull request is built from, so an escalation
386    /// knows what to check out and push to.
387    pub branch: String,
388    /// The pull request's URL, so a later merge can confirm it is still
389    /// open before trusting it to block a fresh decision.
390    pub pr_url: String,
391}
392
393/// Where [`PendingBump`] is recorded for `repo` - one file per repository, so
394/// a machine running magi against more than one checkout does not confuse
395/// their releases with each other.
396pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
397    let key = repo.to_string_lossy();
398    home.join("bump")
399        .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
400}
401
402/// Read a recorded [`PendingBump`], if any. Missing or unreadable both read
403/// as "nothing pending" - a marker is bookkeeping, not a source of truth
404/// worth failing a merge over.
405pub fn read_marker(path: &Path) -> Option<PendingBump> {
406    let body = std::fs::read_to_string(path).ok()?;
407    serde_json::from_str(&body).ok()
408}
409
410/// Persist `marker`, atomically - the same tmp-then-rename shape
411/// [`crate::updater::write_progress`] uses, since this file is read by a
412/// later, unrelated process invocation and must never be seen half-written.
413pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
414    if let Some(parent) = path.parent() {
415        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
416    }
417    let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
418    let tmp = path.with_extension("json.tmp");
419    std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
420    std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
421    Ok(())
422}
423
424/// Drop a recorded marker. Best-effort: a marker that is already gone is not
425/// an error.
426pub fn clear_marker(path: &Path) {
427    let _ = std::fs::remove_file(path);
428}
429
430/// What a recorded [`PendingBump`] means for a fresh decision, given what the
431/// base branch's `Cargo.toml` says right now. No I/O: the caller reads both
432/// the marker and the version.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub enum Coalesce {
435    /// Nothing is pending, or the pending bump already landed (or was
436    /// superseded by a manual one) - safe to open a fresh decision.
437    Proceed,
438    /// A bump to `target_version` is already open; do not open a second one.
439    Skip {
440        /// The version the pending pull request already targets.
441        target_version: String,
442    },
443}
444
445/// Decide what a pending marker means against `current_version`.
446pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
447    let Some(pending) = pending else {
448        return Ok(Coalesce::Proceed);
449    };
450    let current = Version::parse(current_version)?;
451    let target = Version::parse(&pending.target_version)?;
452    if current >= target {
453        return Ok(Coalesce::Proceed);
454    }
455    Ok(Coalesce::Skip {
456        target_version: pending.target_version.clone(),
457    })
458}
459
460/// What a still-open pending bump means once a fresh decision is in hand.
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub enum PendingAction {
463    /// The new decision is no more severe than what is already queued; the
464    /// open pull request covers it once it lands.
465    AlreadyCovered,
466    /// The new decision outranks the pending target - escalate the open
467    /// pull request instead of opening a second one or dropping it.
468    Escalate,
469}
470
471/// Compare a fresh decision against what a still-open pull request already
472/// targets.
473///
474/// A patch bump left pending while a breaking change lands does not become a
475/// breaking release just because the pull request that carries both is
476/// squashed into one commit: the *version number* still comes from whichever
477/// digit was judged, and a pending `patch` never widens itself to `minor` on
478/// its own. This is the check that decides an escalation is owed.
479pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
480    if decision_level.severity() > pending_level.severity() {
481        PendingAction::Escalate
482    } else {
483        PendingAction::AlreadyCovered
484    }
485}
486
487/// Parse `gh pr view --json state` output. No I/O.
488fn parse_pr_state(json: &str) -> Result<bool> {
489    #[derive(Deserialize)]
490    struct State {
491        state: String,
492    }
493    let parsed: State =
494        serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
495    Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
496}
497
498/// Is the pull request at `pr_url` still open?
499///
500/// Read fresh rather than trusted from the marker: a bump pull request can be
501/// closed without merging - CI that never goes green, an operator who
502/// decided against it - and nothing else in this module ever revisits a
503/// marker once it is written. Without this check, that close is invisible
504/// here forever: the marker still names a pending target, the base branch
505/// never reaches it because nothing ever merged the pull request, and every
506/// later merge skips in perpetuity. A `gh` failure (network, auth) answers
507/// `true` - the same "unreadable is not absent" rule `land::CHECKS_GRACE`
508/// uses - because guessing "closed" wrongly opens a second, competing pull
509/// request, while guessing "open" wrongly only costs one more merge's wait.
510async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
511    let out = tokio::process::Command::new("gh")
512        .args(["pr", "view", pr_url, "--json", "state"])
513        .current_dir(repo)
514        .quiet()
515        .stdin(std::process::Stdio::null())
516        .output()
517        .await
518        .context("spawn gh pr view")?;
519    if !out.status.success() {
520        bail!(
521            "gh pr view {pr_url}: {}",
522            String::from_utf8_lossy(&out.stderr).trim()
523        );
524    }
525    parse_pr_state(&String::from_utf8_lossy(&out.stdout))
526}
527
528/// How long a stale lock file is trusted to mean its owner is still working,
529/// before it is reclaimed.
530///
531/// Long enough to cover the slowest real step this module takes - the agent
532/// decision call ([`DECISION_TIMEOUT`]) plus `cargo build` and a `gh pr
533/// create` - so a lock is only ever stolen from a process that has actually
534/// gone (crashed, killed), never one still inside its own critical section.
535const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
536
537/// A host-local mutual exclusion for one repository's marker file.
538///
539/// Built on exclusive file creation rather than a locking crate: neither
540/// `flock` nor `fs2` is a dependency of this crate, and the constraints on
541/// this change forbid adding one. This is not a distributed lock and does
542/// not coordinate two machines racing the same repository - it exists to
543/// close the specific race two `after_merge` calls on the *same* host can
544/// hit landing within the same window (a human `magi run` alongside the
545/// daemon, or two review loops): both would otherwise read "nothing
546/// pending", judge independently, and open two competing pull requests, with
547/// whichever `write_marker` runs last silently erasing the other's record.
548struct MarkerLock {
549    path: PathBuf,
550}
551
552impl MarkerLock {
553    /// Try to take the lock for `marker`, stealing a stale one first if it is
554    /// old enough to mean its owner is gone rather than merely slow.
555    /// `Ok(None)` means someone else genuinely holds it right now.
556    fn acquire(marker: &Path) -> Result<Option<Self>> {
557        let path = marker.with_extension("lock");
558        if let Some(parent) = path.parent() {
559            std::fs::create_dir_all(parent)
560                .with_context(|| format!("create {}", parent.display()))?;
561        }
562        if Self::try_create(&path)? {
563            return Ok(Some(Self { path }));
564        }
565        if Self::is_stale(&path) {
566            let _ = std::fs::remove_file(&path);
567            if Self::try_create(&path)? {
568                return Ok(Some(Self { path }));
569            }
570        }
571        Ok(None)
572    }
573
574    fn try_create(path: &Path) -> Result<bool> {
575        match std::fs::OpenOptions::new()
576            .write(true)
577            .create_new(true)
578            .open(path)
579        {
580            Ok(_) => Ok(true),
581            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
582            Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
583        }
584    }
585
586    fn is_stale(path: &Path) -> bool {
587        std::fs::metadata(path)
588            .and_then(|m| m.modified())
589            .ok()
590            .and_then(|m| m.elapsed().ok())
591            .is_some_and(|age| age >= LOCK_STALE_AFTER)
592    }
593}
594
595impl Drop for MarkerLock {
596    fn drop(&mut self) {
597        let _ = std::fs::remove_file(&self.path);
598    }
599}
600
601/// How often a blocked caller checks whether [`MarkerLock`] has freed up.
602const LOCK_POLL: Duration = Duration::from_secs(5);
603
604/// How long a caller waits for a contended lock before giving up on this
605/// merge's own judgement entirely.
606///
607/// A first version of this gate gave up the instant the lock was taken,
608/// which meant a change landing while another host's decision call was
609/// still running was never judged at all - not even recorded as pending,
610/// not escalated later, just dropped. The lock is only ever held for one
611/// `after_merge` call, so waiting past it is what lets that call's own
612/// decision reach [`pending_action`] against a marker the other side just
613/// finished writing, instead of finding nothing to check against. Set just
614/// under [`LOCK_STALE_AFTER`]: a lock still held this long after that point
615/// is reclaimed as abandoned rather than waited on further.
616const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
617
618/// Wait for [`MarkerLock`] to free up, polling rather than blocking forever.
619/// `Ok(None)` means the ceiling passed with the lock still held.
620async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
621    wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
622}
623
624/// [`wait_for_marker_lock`] with the poll interval and ceiling as parameters,
625/// so the retry behaviour is testable without a test actually waiting out
626/// [`LOCK_WAIT_CEILING`].
627async fn wait_for_marker_lock_with(
628    marker: &Path,
629    poll: Duration,
630    ceiling: Duration,
631) -> Result<Option<MarkerLock>> {
632    let mut waited = Duration::ZERO;
633    loop {
634        if let Some(lock) = MarkerLock::acquire(marker)? {
635            return Ok(Some(lock));
636        }
637        if waited >= ceiling {
638            return Ok(None);
639        }
640        tokio::time::sleep(poll).await;
641        waited += poll;
642    }
643}
644
645/// Which digit differs between `from` and `to`? `None` when they are equal.
646///
647/// Used to recover the level a pull request found by [`find_open_release_pr`]
648/// was judged at: the forge has the resulting version (in the branch name and
649/// the title) but not the digit an agent chose to get there, and this is the
650/// one other host-independent fact every host can compute the same way from
651/// it.
652fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
653    if to.major != from.major {
654        Some(BumpLevel::Major)
655    } else if to.minor != from.minor {
656        Some(BumpLevel::Minor)
657    } else if to.patch != from.patch {
658        Some(BumpLevel::Patch)
659    } else {
660        None
661    }
662}
663
664/// Parse `gh pr list --state open --json url,headRefName` output, returning
665/// the first pull request whose branch is one of this module's own. No I/O.
666fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
667    #[derive(Deserialize)]
668    struct Pr {
669        url: String,
670        #[serde(rename = "headRefName")]
671        head_ref_name: String,
672    }
673    let list: Vec<Pr> =
674        serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
675    Ok(list
676        .into_iter()
677        .find(|p| p.head_ref_name.starts_with("chore/release-v"))
678        .map(|p| (p.head_ref_name, p.url)))
679}
680
681/// Ask the forge directly whether a release bump is already open, for a host
682/// that has never seen it.
683///
684/// [`MarkerLock`] and the marker file only ever coordinate *this* host - a
685/// marker written on one machine is not visible to `run::home()` on another,
686/// so two hosts landing runs against the same repository at the same time
687/// can each read "nothing pending" and open a competing pull request no
688/// local lock can see. `gh pr list` is the one place every host actually
689/// shares a view, so it is consulted whenever this host's own marker says
690/// there is nothing pending, before a fresh decision is allowed to open a
691/// second pull request. This narrows the race to the gap between this call
692/// and whichever host's `gh pr create` lands first - it does not close it -
693/// because turning that into a real distributed lock would need coordination
694/// this crate has no dependency for.
695async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
696    let out = tokio::process::Command::new("gh")
697        .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
698        .current_dir(repo)
699        .quiet()
700        .stdin(std::process::Stdio::null())
701        .output()
702        .await
703        .context("spawn gh pr list")?;
704    if !out.status.success() {
705        bail!(
706            "gh pr list: {}",
707            String::from_utf8_lossy(&out.stderr).trim()
708        );
709    }
710    parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
711}
712
713/// After a merge lands, ask an agent how big the change was and open a
714/// release bump sized to it.
715///
716/// Best-effort by construction, the same way `clean::fold_due` treats one
717/// run's fold failure: this runs after the merge the run exists to produce
718/// has already succeeded, so a failure here (the decision call, `gh`,
719/// `cargo`) must never turn a landed run into a failed one. The caller logs
720/// whatever this returns and moves on.
721pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
722    after_merge_at(state, pr_url, None).await
723}
724
725/// Does the base branch carry a `Cargo.toml` at its root? Release bumps read
726/// and rewrite that file, so its absence means "not a Rust repository", which
727/// is not a fault. `ls-tree` rather than `cat-file -e`, so an unresolvable
728/// ref (a failed fetch, a misconfigured base) stays an error instead of being
729/// reported as a non-Rust repository.
730async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
731    let out = git::git(
732        repo,
733        &[
734            "ls-tree",
735            "--name-only",
736            &format!("{remote}/{base}"),
737            "--",
738            "Cargo.toml",
739        ],
740    )
741    .await
742    .context("look for Cargo.toml on the base branch")?;
743    Ok(!out.trim().is_empty())
744}
745
746/// [`after_merge`] with an optional magi home, so tests can point the
747/// marker and its lock at a scratch directory.
748async fn after_merge_at(state: &mut RunState, pr_url: &str, home: Option<&Path>) -> Result<()> {
749    if !state.config.merge.release_bump {
750        return Ok(());
751    }
752    let Some(winner) = state.winner().cloned() else {
753        return Ok(());
754    };
755    let repo = state.repo.clone();
756    let base = state.base_branch.clone();
757    let remote = state.config.merge.remote.clone();
758
759    let files = git::changed_files(&winner.worktree, &base, &winner.branch)
760        .await
761        .unwrap_or_default();
762    if is_release_only(&files) {
763        state.event(
764            "bump",
765            "the merged change touches only the release manifest; not treating it as a trigger",
766        );
767        return Ok(());
768    }
769
770    // Outside the lock, and before anything that assumes a Rust manifest.
771    // Fetch first so a stale remote-tracking ref cannot misjudge the base.
772    git::fetch(&repo, &remote, &base).await.ok();
773    if !base_has_cargo_toml(&repo, &remote, &base).await? {
774        state.event(
775            "bump",
776            "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping",
777        );
778        return Ok(());
779    }
780
781    let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
782    // Held for the rest of this function: the whole read-decide-write
783    // sequence below is the critical section two `after_merge` calls landing
784    // within the same window must not both be inside at once. See
785    // `MarkerLock`'s own doc for why a second, unrelated bump PR is what
786    // that race produces without it, and `wait_for_marker_lock`'s for why
787    // this waits rather than giving up the instant it is contended.
788    let Some(_lock) = wait_for_marker_lock(&marker).await? else {
789        state.event(
790            "bump",
791            "another release bump decision held the lock past the wait ceiling; skipping this round",
792        );
793        return Ok(());
794    };
795
796    git::fetch(&repo, &remote, &base).await.ok();
797    let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
798        .await
799        .context("read Cargo.toml from the base branch")?;
800    let base_version = current_version(&cargo_toml)?;
801
802    let mut pending = read_marker(&marker);
803    if let Some(p) = &pending {
804        match coalesce(Some(p), &base_version)? {
805            Coalesce::Proceed => {
806                // Landed, or superseded by a manual bump: free for a fresh
807                // decision.
808                clear_marker(&marker);
809                pending = None;
810            }
811            Coalesce::Skip { target_version } => {
812                if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
813                    state.event(
814                        "bump",
815                        format!(
816                            "the pending release bump to v{target_version} ({}) is no longer \
817                             open; treating it as abandoned",
818                            p.pr_url
819                        ),
820                    );
821                    clear_marker(&marker);
822                    pending = None;
823                }
824                // Otherwise still genuinely open: fall through and ask the
825                // same question this merge would get on a fresh path, so a
826                // more severe change landing while it waits can escalate it
827                // instead of being silently absorbed at the wrong digit.
828            }
829        }
830    }
831
832    if pending.is_none() {
833        // This host's own marker has nothing to say - check the forge itself
834        // before trusting that to mean a fresh pull request is safe to open.
835        // See `find_open_release_pr`'s own doc for what this does and does
836        // not close.
837        if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
838            && let Some(target) = branch
839                .strip_prefix("chore/release-v")
840                .and_then(|v| Version::parse(v).ok())
841        {
842            let base_parsed = Version::parse(&base_version)?;
843            if target > base_parsed
844                && let Some(level) = level_between(base_parsed, target)
845            {
846                let adopted = PendingBump {
847                    target_version: target.to_string(),
848                    level,
849                    branch,
850                    pr_url: url,
851                };
852                // Best-effort: worst case this host asks the forge again
853                // next time instead of finding its own record of it.
854                let _ = write_marker(&marker, &adopted);
855                pending = Some(adopted);
856            }
857        }
858    }
859
860    let title = pr_title(&repo, pr_url).await.unwrap_or_default();
861    let subject = land::merge_subject(&title, &state.instruction);
862    let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
863        .await
864        .unwrap_or_default();
865    let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
866
867    // No dedicated role for this one-off decision. Borrows `[roles] chatter`
868    // - the nearest surviving single-agent-seat preference - rather than
869    // falling straight to `agent::pick`'s own default order, so an operator
870    // who has already named a preferred seat there is not silently
871    // overridden for this decision too.
872    let spec: AgentSpec = agent::pick(
873        &state.config.agents,
874        state.config.roles.chatter.as_deref(),
875        &agent::installed,
876    )
877    .context("choose an agent for the release-bump decision")?;
878    let mut seat = SeatState::new("bump", &spec.id, state.seed);
879    let artifacts = agent::artifacts_dir(&state.dir());
880    let out = agent::invoke(
881        &spec,
882        &mut seat,
883        &Invocation {
884            cwd: &repo,
885            prompt: &prompt,
886            timeout: DECISION_TIMEOUT,
887            // The decision reads a diffstat and writes a verdict; it must
888            // never touch a file.
889            allow_write: false,
890            sessions: false,
891            artifacts: &artifacts,
892            stem: "bump-decision",
893            run: &state.id,
894            node: "bump",
895            cache_dir: state.config.cache_dir().as_deref(),
896            attachments: &[],
897        },
898    )
899    .await
900    .context("ask an agent how big the merged change was")?;
901    if !out.usable() {
902        bail!(
903            "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
904            out.exit_code,
905            out.timed_out
906        );
907    }
908    let decision = parse_decision(&out.text).context("parse the release-bump decision")?;
909
910    if let Some(p) = pending {
911        return match pending_action(p.level, decision.level) {
912            PendingAction::AlreadyCovered => {
913                state.event(
914                    "bump",
915                    format!(
916                        "a release bump to v{} ({}) already covers at least a {} change; not \
917                         opening another",
918                        p.target_version,
919                        p.pr_url,
920                        decision.level.as_str()
921                    ),
922                );
923                Ok(())
924            }
925            PendingAction::Escalate => {
926                escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
927            }
928        };
929    }
930
931    let next = Version::parse(&base_version)?
932        .bump(decision.level)
933        .to_string();
934    let branch = format!("chore/release-v{next}");
935    let worktree = state.dir().join("bump");
936    git::worktree_remove(&repo, &worktree).await.ok();
937    git::worktree_add_branch(&repo, &worktree, &branch, &format!("{remote}/{base}"))
938        .await
939        .context("create the release-bump worktree")?;
940    let opened = open_bump_pr(state, &worktree, &branch, &next, &decision, pr_url).await;
941    // Throwaway either way: nothing downstream reads this worktree, and a
942    // release worktree left behind after a failed attempt would collide with
943    // the next one this same run tries.
944    git::worktree_remove(&repo, &worktree).await.ok();
945    let (pr_url_opened, automerge_warning) = opened?;
946
947    // The pull request exists on the forge the moment `open_bump_pr` returns
948    // its URL, regardless of what happens next - so the event that names it
949    // is unconditional, and a marker write failing (a full disk, a missing
950    // `home/bump` directory) is reported as its own warning rather than
951    // swallowing that URL entirely the way propagating it with `?` would.
952    // `find_open_release_pr` is the fallback if this leaves no local record:
953    // the next merge that finds no marker still finds this pull request on
954    // the forge before opening a second one.
955    let marker_write = write_marker(
956        &marker,
957        &PendingBump {
958            target_version: next.clone(),
959            level: decision.level,
960            branch,
961            pr_url: pr_url_opened.clone(),
962        },
963    );
964    state.event(
965        "bump",
966        format!(
967            "opened a {} release bump to v{next} ({}): {pr_url_opened}",
968            decision.level.as_str(),
969            decision.reason
970        ),
971    );
972    if let Err(e) = marker_write {
973        state.event(
974            "bump",
975            format!(
976                "could not record the pending release bump marker for v{next}: {e:#}; a later \
977                 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
978                 the forge either"
979            ),
980        );
981    }
982    if let Some(warning) = automerge_warning {
983        state.event(
984            "bump",
985            format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
986        );
987    }
988    Ok(())
989}
990
991/// Bump an already-open release pull request further, because a change more
992/// severe than what it already covers landed while it waited on CI or
993/// automerge - see [`pending_action`].
994///
995/// Adds a second commit rather than rewriting the first: `gh pr merge
996/// --squash` prefers a single commit's own message over the pull request's
997/// title, and falls back to the title once there is more than one commit -
998/// so the title is what is kept honest here, via `gh pr edit`.
999async fn escalate_pending(
1000    state: &mut RunState,
1001    repo: &Path,
1002    remote: &str,
1003    pending: &PendingBump,
1004    decision: &BumpDecision,
1005    base_version: &str,
1006    marker: &Path,
1007) -> Result<()> {
1008    let next = Version::parse(base_version)?
1009        .bump(decision.level)
1010        .to_string();
1011    let worktree = state.dir().join("bump");
1012    git::worktree_remove(repo, &worktree).await.ok();
1013    let checked_out = git::git_raw(
1014        repo,
1015        &[
1016            "worktree",
1017            "add",
1018            "--force",
1019            &worktree.to_string_lossy(),
1020            &pending.branch,
1021        ],
1022    )
1023    .await?;
1024    if !checked_out.ok() {
1025        bail!(
1026            "checking out the pending release branch {} failed: {}",
1027            pending.branch,
1028            checked_out.stderr
1029        );
1030    }
1031
1032    // Only the substantive change - the commit landing on the remote branch
1033    // - has to succeed for the escalation to have happened at all. Anything
1034    // after the push is a follow-up, not a precondition: the branch already
1035    // carries the new version whether or not it succeeds.
1036    let pushed: Result<()> = async {
1037        let cargo_toml_path = worktree.join("Cargo.toml");
1038        let toml = tokio::fs::read_to_string(&cargo_toml_path)
1039            .await
1040            .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1041        let rewritten = rewrite_cargo_version(&toml, &next)?;
1042        tokio::fs::write(&cargo_toml_path, rewritten)
1043            .await
1044            .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1045        sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1046        let committed = git::commit_all(
1047            &worktree,
1048            &format!(
1049                "chore: release v{next} (supersedes v{})",
1050                pending.target_version
1051            ),
1052        )
1053        .await
1054        .context("commit the escalated version bump")?;
1055        if !committed {
1056            bail!("escalating the version bump left nothing to commit");
1057        }
1058        let pushed = git::push(&worktree, remote, &pending.branch).await?;
1059        if !pushed.ok() {
1060            bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1061        }
1062        Ok(())
1063    }
1064    .await;
1065    if let Err(e) = pushed {
1066        git::worktree_remove(repo, &worktree).await.ok();
1067        return Err(e);
1068    }
1069
1070    // The commit is on the remote branch now regardless of what happens
1071    // below - the title edit is cosmetic, and the marker and the event must
1072    // both reflect the real, already-pushed state even if it fails.
1073    let title_warning = match gh_pr_edit_title(
1074        &worktree,
1075        &pending.pr_url,
1076        &format!("chore: release v{next}"),
1077    )
1078    .await
1079    {
1080        Ok(()) => None,
1081        Err(e) => Some(e.to_string()),
1082    };
1083    git::worktree_remove(repo, &worktree).await.ok();
1084
1085    let marker_write = write_marker(
1086        marker,
1087        &PendingBump {
1088            target_version: next.clone(),
1089            level: decision.level,
1090            branch: pending.branch.clone(),
1091            pr_url: pending.pr_url.clone(),
1092        },
1093    );
1094    state.event(
1095        "bump",
1096        format!(
1097            "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1098            pending.target_version,
1099            decision.level.as_str(),
1100            decision.reason,
1101            pending.pr_url
1102        ),
1103    );
1104    if let Err(e) = marker_write {
1105        state.event(
1106            "bump",
1107            format!(
1108                "could not update the pending release bump marker to v{next}: {e:#}; a later \
1109                 merge may misjudge whether it is already covered"
1110            ),
1111        );
1112    }
1113    if let Some(warning) = title_warning {
1114        state.event(
1115            "bump",
1116            format!(
1117                "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1118                 subject may still read the superseded version",
1119                pending.pr_url
1120            ),
1121        );
1122    }
1123    Ok(())
1124}
1125
1126/// Edit the version, let the lockfile follow, commit, push, and open the pull
1127/// request with automerge enabled. Returns the opened pull request's URL and,
1128/// when enabling automerge itself failed, a note of why - the pull request
1129/// still exists on the forge either way, and the caller must not lose track
1130/// of its URL over that failure alone.
1131async fn open_bump_pr(
1132    state: &RunState,
1133    worktree: &Path,
1134    branch: &str,
1135    next_version: &str,
1136    decision: &BumpDecision,
1137    source_pr_url: &str,
1138) -> Result<(String, Option<String>)> {
1139    let cargo_toml_path = worktree.join("Cargo.toml");
1140    let toml = tokio::fs::read_to_string(&cargo_toml_path)
1141        .await
1142        .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1143    let rewritten = rewrite_cargo_version(&toml, next_version)?;
1144    tokio::fs::write(&cargo_toml_path, rewritten)
1145        .await
1146        .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1147
1148    sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
1149
1150    let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
1151        .await
1152        .context("commit the version bump")?;
1153    if !committed {
1154        bail!("the version bump left nothing to commit");
1155    }
1156
1157    let remote = state.config.merge.remote.clone();
1158    let pushed = git::push(worktree, &remote, branch).await?;
1159    if !pushed.ok() {
1160        bail!("pushing {branch} failed: {}", pushed.stderr);
1161    }
1162
1163    let (title, body) = release_pr(
1164        decision.level.as_str(),
1165        &decision.reason,
1166        next_version,
1167        &state.id,
1168        source_pr_url,
1169    );
1170    let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
1171    let automerge_warning = match gh_enable_automerge(worktree, &url).await {
1172        Ok(()) => None,
1173        Err(e) => Some(e.to_string()),
1174    };
1175    Ok((url, automerge_warning))
1176}
1177
1178/// Run `cargo build` so `Cargo.lock` follows the version bump, the same step
1179/// `AGENTS.md`'s hand-driven release recipe calls for.
1180///
1181/// Not exercised by a test: it is the one step in this module that runs the
1182/// real `cargo`, which the constraints on this change rule out doing from a
1183/// test (no network, no writing outside a throwaway worktree the test itself
1184/// does not have).
1185async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
1186    let mut cmd = tokio::process::Command::new("cargo");
1187    cmd.arg("build").current_dir(worktree).quiet();
1188    if let Some(dir) = cache_dir {
1189        cmd.env("CARGO_TARGET_DIR", dir);
1190    }
1191    let out = cmd
1192        .stdin(std::process::Stdio::null())
1193        .output()
1194        .await
1195        .context("spawn cargo build")?;
1196    if !out.status.success() {
1197        bail!(
1198            "cargo build failed while syncing Cargo.lock: {}",
1199            String::from_utf8_lossy(&out.stderr).trim()
1200        );
1201    }
1202    Ok(())
1203}
1204
1205/// Title and body of a release bump pull request. Fixed English whatever
1206/// `[graph] language` says: it lands on GitHub. Pure so a test can hold it to
1207/// that. (`reason` comes from the decision seat, which the prompt tells to
1208/// write English.)
1209fn release_pr(
1210    level: &str,
1211    reason: &str,
1212    next_version: &str,
1213    run_id: &str,
1214    source_pr_url: &str,
1215) -> (String, String) {
1216    let title = format!("chore: release v{next_version}");
1217    let body = format!(
1218        "Release bump: `{level}` to `v{next_version}`.\n\n{reason}\n\n\
1219         Triggered by run `{run_id}`, which landed {source_pr_url}.\n\n\
1220         version-bump-only; nothing here needs a review \
1221         (AGENTS.md: \"Version-bump-only pull requests\").",
1222    );
1223    (title, body)
1224}
1225
1226/// The merged pull request's title, for [`land::merge_subject`].
1227async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
1228    let out = tokio::process::Command::new("gh")
1229        .args(["pr", "view", pr_url, "--json", "title"])
1230        .current_dir(repo)
1231        .quiet()
1232        .stdin(std::process::Stdio::null())
1233        .output()
1234        .await
1235        .context("spawn gh pr view")?;
1236    if !out.status.success() {
1237        bail!(
1238            "gh pr view {pr_url}: {}",
1239            String::from_utf8_lossy(&out.stderr).trim()
1240        );
1241    }
1242    #[derive(Deserialize)]
1243    struct Title {
1244        title: String,
1245    }
1246    let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
1247        .context("parse `gh pr view --json title` output")?;
1248    Ok(parsed.title)
1249}
1250
1251async fn gh_pr_create(
1252    cwd: &Path,
1253    base: &str,
1254    head: &str,
1255    title: &str,
1256    body: &str,
1257) -> Result<String> {
1258    let out = tokio::process::Command::new("gh")
1259        .args([
1260            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
1261        ])
1262        .current_dir(cwd)
1263        .quiet()
1264        .stdin(std::process::Stdio::null())
1265        .output()
1266        .await
1267        .context("spawn gh pr create")?;
1268    if out.status.success() {
1269        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
1270    } else {
1271        bail!(
1272            "gh pr create: {}",
1273            String::from_utf8_lossy(&out.stderr).trim()
1274        )
1275    }
1276}
1277
1278/// Enable automerge, mirroring `AGENTS.md`'s `gh pr merge --auto --squash
1279/// --delete-branch`. Never `git tag`: `auto-tag.yml` mints the tag once this
1280/// merges, and a manual tag would collide with its push.
1281async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
1282    let out = tokio::process::Command::new("gh")
1283        .args([
1284            "pr",
1285            "merge",
1286            pr_url,
1287            "--auto",
1288            "--squash",
1289            "--delete-branch",
1290        ])
1291        .current_dir(cwd)
1292        .quiet()
1293        .stdin(std::process::Stdio::null())
1294        .output()
1295        .await
1296        .context("spawn gh pr merge --auto")?;
1297    if out.status.success() {
1298        Ok(())
1299    } else {
1300        bail!(
1301            "gh pr merge --auto: {}",
1302            String::from_utf8_lossy(&out.stderr).trim()
1303        )
1304    }
1305}
1306
1307/// Rewrite a pull request's title, used when [`escalate_pending`] adds a
1308/// second commit: `gh pr merge --squash` only prefers a single commit's own
1309/// message over the title, so once there are two the title is what lands.
1310async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
1311    let out = tokio::process::Command::new("gh")
1312        .args(["pr", "edit", pr_url, "--title", title])
1313        .current_dir(cwd)
1314        .quiet()
1315        .stdin(std::process::Stdio::null())
1316        .output()
1317        .await
1318        .context("spawn gh pr edit")?;
1319    if out.status.success() {
1320        Ok(())
1321    } else {
1322        bail!(
1323            "gh pr edit --title: {}",
1324            String::from_utf8_lossy(&out.stderr).trim()
1325        )
1326    }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use super::*;
1332
1333    #[test]
1334    fn github_facing_bump_text_is_english() {
1335        let (title, body) =
1336            release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
1337        assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
1338        assert_eq!(title, "chore: release v0.37.0");
1339        let p = decision_prompt("s", "i", "d", &[], "0.36.5");
1340        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
1341    }
1342    use crate::config::Config;
1343    use crate::land::PrLifecycle;
1344
1345    /// `[merge] release_bump = false` must short-circuit before any I/O -
1346    /// `after_merge` is reached from a live run with a real repo and a real
1347    /// `gh`, so the disabled case is asserted with a `RunState` that would
1348    /// fail loudly (an unresolvable `/no/such/repo`) the moment anything past
1349    /// the flag check tried to touch it.
1350    #[tokio::test]
1351    async fn a_disabled_config_does_nothing() {
1352        let config = Config {
1353            merge: crate::config::Merge {
1354                release_bump: false,
1355                ..crate::config::Merge::default()
1356            },
1357            ..Config::default()
1358        };
1359        let mut state = RunState::new(
1360            PathBuf::from("/no/such/repo"),
1361            "main".to_owned(),
1362            "0000000000000000000000000000000000000000".to_owned(),
1363            "irrelevant".to_owned(),
1364            config,
1365        );
1366        after_merge(&mut state, "https://example.invalid/pull/1")
1367            .await
1368            .expect("a disabled config must return Ok without touching anything");
1369        assert!(
1370            state.events.is_empty(),
1371            "nothing should happen at all, not even a logged event"
1372        );
1373    }
1374
1375    /// A bare `origin` plus a clone of it, `main` pushed with `files`.
1376    async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
1377        let dir = tempfile::tempdir().unwrap();
1378        let origin = dir.path().join("origin.git");
1379        let repo = dir.path().join("repo");
1380        let o = origin.to_string_lossy().into_owned();
1381        git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
1382            .await
1383            .unwrap();
1384        tokio::fs::create_dir_all(&repo).await.unwrap();
1385        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
1386        git::git(&repo, &["config", "user.name", "test"])
1387            .await
1388            .unwrap();
1389        git::git(&repo, &["config", "user.email", "test@example.com"])
1390            .await
1391            .unwrap();
1392        for (name, body) in files {
1393            tokio::fs::write(repo.join(name), body).await.unwrap();
1394        }
1395        git::git(&repo, &["add", "-A"]).await.unwrap();
1396        git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
1397        git::git(&repo, &["remote", "add", "origin", &o])
1398            .await
1399            .unwrap();
1400        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
1401        (dir, repo)
1402    }
1403
1404    #[tokio::test]
1405    async fn base_has_cargo_toml_tells_rust_from_non_rust() {
1406        let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
1407        assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
1408        let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
1409        assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
1410        // An unresolvable ref is a fault, not "not Rust".
1411        assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
1412    }
1413
1414    #[tokio::test]
1415    async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
1416        let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
1417        let home = tempfile::tempdir().unwrap();
1418        let mut state = RunState::new(
1419            repo.clone(),
1420            "main".to_owned(),
1421            "0000000000000000000000000000000000000000".to_owned(),
1422            "task".to_owned(),
1423            Config::default(),
1424        );
1425        state.candidates.push(crate::run::Candidate {
1426            index: 0,
1427            label: 'A',
1428            agent: "x".to_owned(),
1429            branch: "main".to_owned(),
1430            worktree: repo.clone(),
1431            summary: String::new(),
1432            stat: String::new(),
1433            files: 1,
1434            commits: 1,
1435            empty: false,
1436            failed: None,
1437            verified_noop: None,
1438            duration_ms: 0,
1439            folded: false,
1440        });
1441        state.tally = Some(
1442            serde_json::from_value(serde_json::json!({
1443                "first_choice": {}, "borda": {}, "winner": "A",
1444                "unanimous_initial": true, "deliberated": false,
1445                "changed_votes": 0, "unanimous_final": true,
1446            }))
1447            .unwrap(),
1448        );
1449        after_merge_at(
1450            &mut state,
1451            "https://example.invalid/pull/1",
1452            Some(home.path()),
1453        )
1454        .await
1455        .expect("a non-Rust repository is not an error");
1456        let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
1457        assert_eq!(bumps.len(), 1, "{:?}", state.events);
1458        assert_eq!(
1459            bumps[0].message,
1460            "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
1461        );
1462        assert!(
1463            std::fs::read_dir(home.path()).unwrap().next().is_none(),
1464            "no marker and no lock may be created"
1465        );
1466    }
1467
1468    #[test]
1469    fn version_parses_and_bumps_each_digit() {
1470        let v = Version::parse("0.4.0").unwrap();
1471        assert_eq!(
1472            v,
1473            Version {
1474                major: 0,
1475                minor: 4,
1476                patch: 0
1477            }
1478        );
1479
1480        assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
1481        assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
1482        assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
1483    }
1484
1485    #[test]
1486    fn version_tolerates_a_prerelease_suffix_on_patch() {
1487        let v = Version::parse("1.2.3-rc1").unwrap();
1488        assert_eq!(
1489            v,
1490            Version {
1491                major: 1,
1492                minor: 2,
1493                patch: 3
1494            }
1495        );
1496    }
1497
1498    #[test]
1499    fn version_rejects_garbage() {
1500        assert!(Version::parse("not-a-version").is_err());
1501        assert!(Version::parse("1.2").is_err());
1502    }
1503
1504    #[test]
1505    fn decision_parses_each_level() {
1506        for (json, level) in [
1507            (
1508                r#"{"level":"major","reason":"drops a config key"}"#,
1509                BumpLevel::Major,
1510            ),
1511            (
1512                r#"{"level":"minor","reason":"adds a new flag"}"#,
1513                BumpLevel::Minor,
1514            ),
1515            (
1516                r#"{"level":"patch","reason":"fixes a race"}"#,
1517                BumpLevel::Patch,
1518            ),
1519        ] {
1520            let decision = parse_decision(json).unwrap();
1521            assert_eq!(decision.level, level);
1522            assert!(!decision.reason.is_empty());
1523        }
1524    }
1525
1526    #[test]
1527    fn decision_wrapped_in_a_fence_and_prose_still_parses() {
1528        let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
1529        let decision = parse_decision(text).unwrap();
1530        assert_eq!(decision.level, BumpLevel::Minor);
1531        assert_eq!(decision.reason, "new HTTP route");
1532    }
1533
1534    #[test]
1535    fn a_broken_reply_is_an_error_not_a_default() {
1536        assert!(parse_decision("I decline to answer.").is_err());
1537        assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
1538        assert!(
1539            parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
1540            "an empty reason must not pass either"
1541        );
1542        assert!(
1543            parse_decision(r#"{"level":"patch"}"#).is_err(),
1544            "a reply with no reason at all must not pass"
1545        );
1546    }
1547
1548    #[test]
1549    fn prompt_states_the_zero_x_rule_and_the_tie_break() {
1550        let prompt = decision_prompt(
1551            "feat: add a phone endpoint",
1552            "add POST /api/widgets",
1553            "1 file changed, 10 insertions(+)",
1554            &["src/web.rs".to_owned()],
1555            "0.8.0",
1556        );
1557        assert!(prompt.contains("0.8.0"), "the current version is stated");
1558        assert!(
1559            prompt.contains("below `1.0.0`")
1560                && prompt.contains("`minor` is the digit that carries a breaking change"),
1561            "the 0.x rule must be explicit: {prompt}"
1562        );
1563        assert!(
1564            prompt.contains("choose the larger"),
1565            "the tie-break toward the bigger digit must be explicit: {prompt}"
1566        );
1567    }
1568
1569    #[test]
1570    fn release_only_diffs_are_recognised() {
1571        assert!(is_release_only(&["Cargo.toml".to_owned()]));
1572        assert!(is_release_only(&[
1573            "Cargo.toml".to_owned(),
1574            "Cargo.lock".to_owned()
1575        ]));
1576        assert!(!is_release_only(&[]));
1577        assert!(!is_release_only(&[
1578            "Cargo.toml".to_owned(),
1579            "src/main.rs".to_owned()
1580        ]));
1581    }
1582
1583    #[test]
1584    fn cargo_version_rewrite_touches_only_the_package_table() {
1585        let toml = "\
1586[package]\n\
1587# a comment mentioning version on purpose\n\
1588name = \"magi-cli\"\n\
1589version = \"0.8.0\"\n\
1590edition = \"2024\"\n\
1591\n\
1592[dependencies]\n\
1593foo = { version = \"1.2.3\" }\n";
1594        let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
1595        assert!(out.contains("version = \"0.9.0\""));
1596        assert!(
1597            out.contains("foo = { version = \"1.2.3\" }"),
1598            "a dependency's own version pin must survive: {out}"
1599        );
1600        assert!(
1601            out.contains("# a comment mentioning version on purpose"),
1602            "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
1603        );
1604        assert_eq!(
1605            out.lines().count(),
1606            toml.lines().count(),
1607            "the rewrite replaces one line, it does not add or remove any"
1608        );
1609    }
1610
1611    #[test]
1612    fn cargo_version_rewrite_fails_without_a_package_table() {
1613        let toml = "[dependencies]\nfoo = \"1\"\n";
1614        assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
1615    }
1616
1617    /// The shape `kanadehq/kanade` has: a workspace root with member crates
1618    /// but no crate of its own, so `[package]` never exists and the version
1619    /// lives under `[workspace.package]` alone. Before this fell back,
1620    /// `rewrite_cargo_version` bailed on every such repository and no bump
1621    /// pull request was ever opened for it.
1622    #[test]
1623    fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
1624        let toml = "\
1625[workspace]\n\
1626members = [\"crates/a\", \"crates/b\"]\n\
1627\n\
1628[workspace.package]\n\
1629version = \"0.45.18\"\n\
1630edition = \"2024\"\n\
1631\n\
1632[workspace.dependencies]\n\
1633foo = { version = \"1.2.3\" }\n";
1634        let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
1635        assert!(out.contains("version = \"0.45.19\""));
1636        assert!(
1637            out.contains("foo = { version = \"1.2.3\" }"),
1638            "a workspace dependency's own version pin must survive: {out}"
1639        );
1640        assert_eq!(
1641            out.lines().count(),
1642            toml.lines().count(),
1643            "the rewrite replaces one line, it does not add or remove any"
1644        );
1645    }
1646
1647    #[test]
1648    fn current_version_prefers_the_package_table_when_both_exist() {
1649        let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
1650        assert_eq!(current_version(toml).unwrap(), "0.8.0");
1651    }
1652
1653    /// Same shape as `kanadehq/kanade`'s root `Cargo.toml`: no `[package]`
1654    /// at all, only `[workspace]` and `[workspace.package]`.
1655    #[test]
1656    fn current_version_falls_back_to_workspace_package_without_a_package_table() {
1657        let toml = "\
1658[workspace]\n\
1659members = [\"crates/a\", \"crates/b\"]\n\
1660\n\
1661[workspace.package]\n\
1662version = \"0.45.18\"\n";
1663        assert_eq!(current_version(toml).unwrap(), "0.45.18");
1664    }
1665
1666    #[test]
1667    fn coalesce_proceeds_with_nothing_pending() {
1668        assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
1669    }
1670
1671    /// A minimal, otherwise-plausible pending marker for tests that only
1672    /// care about one field.
1673    fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
1674        PendingBump {
1675            target_version: target_version.to_owned(),
1676            level,
1677            branch: format!("chore/release-v{target_version}"),
1678            pr_url: "https://example.invalid/pull/9".to_owned(),
1679        }
1680    }
1681
1682    #[test]
1683    fn coalesce_skips_while_the_pending_target_is_still_ahead() {
1684        let pending = test_pending("0.9.0", BumpLevel::Minor);
1685        assert_eq!(
1686            coalesce(Some(&pending), "0.8.0").unwrap(),
1687            Coalesce::Skip {
1688                target_version: "0.9.0".to_owned()
1689            }
1690        );
1691    }
1692
1693    #[test]
1694    fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
1695        let pending = test_pending("0.9.0", BumpLevel::Minor);
1696        // The pending bump landed exactly: proceed with a fresh decision.
1697        assert_eq!(
1698            coalesce(Some(&pending), "0.9.0").unwrap(),
1699            Coalesce::Proceed
1700        );
1701        // A human bumped further than what was pending: also proceed.
1702        assert_eq!(
1703            coalesce(Some(&pending), "1.0.0").unwrap(),
1704            Coalesce::Proceed
1705        );
1706    }
1707
1708    #[test]
1709    fn pending_action_escalates_only_for_a_more_severe_decision() {
1710        assert_eq!(
1711            pending_action(BumpLevel::Patch, BumpLevel::Patch),
1712            PendingAction::AlreadyCovered
1713        );
1714        assert_eq!(
1715            pending_action(BumpLevel::Patch, BumpLevel::Minor),
1716            PendingAction::Escalate
1717        );
1718        assert_eq!(
1719            pending_action(BumpLevel::Patch, BumpLevel::Major),
1720            PendingAction::Escalate
1721        );
1722        assert_eq!(
1723            pending_action(BumpLevel::Minor, BumpLevel::Patch),
1724            PendingAction::AlreadyCovered
1725        );
1726        assert_eq!(
1727            pending_action(BumpLevel::Major, BumpLevel::Minor),
1728            PendingAction::AlreadyCovered
1729        );
1730        assert_eq!(
1731            pending_action(BumpLevel::Major, BumpLevel::Major),
1732            PendingAction::AlreadyCovered
1733        );
1734    }
1735
1736    #[test]
1737    fn pr_state_parsing_reads_open_and_not_open() {
1738        assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
1739        assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
1740        assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
1741    }
1742
1743    #[test]
1744    fn a_lock_is_exclusive_until_dropped() {
1745        let dir = tempfile::tempdir().unwrap();
1746        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1747        let first = MarkerLock::acquire(&marker)
1748            .unwrap()
1749            .expect("first attempt takes the lock");
1750        assert!(
1751            MarkerLock::acquire(&marker).unwrap().is_none(),
1752            "a second attempt must be refused while the first holds it"
1753        );
1754        drop(first);
1755        assert!(
1756            MarkerLock::acquire(&marker).unwrap().is_some(),
1757            "dropping the guard releases the lock for the next attempt"
1758        );
1759    }
1760
1761    #[test]
1762    fn a_stale_lock_is_reclaimed() {
1763        let dir = tempfile::tempdir().unwrap();
1764        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1765        let lock_path = marker.with_extension("lock");
1766        std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1767        std::fs::write(&lock_path, b"").unwrap();
1768        let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
1769        std::fs::OpenOptions::new()
1770            .write(true)
1771            .open(&lock_path)
1772            .unwrap()
1773            .set_modified(old)
1774            .unwrap();
1775        assert!(
1776            MarkerLock::acquire(&marker).unwrap().is_some(),
1777            "a lock older than the stale window must be reclaimed rather than block forever"
1778        );
1779    }
1780
1781    #[tokio::test]
1782    async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
1783        let dir = tempfile::tempdir().unwrap();
1784        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1785        let held = MarkerLock::acquire(&marker)
1786            .unwrap()
1787            .expect("seed the contention");
1788        let releaser = tokio::spawn(async move {
1789            tokio::time::sleep(Duration::from_millis(20)).await;
1790            drop(held);
1791        });
1792        let waited =
1793            wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
1794                .await
1795                .unwrap();
1796        assert!(
1797            waited.is_some(),
1798            "a merge landing behind another's still-running decision must not be dropped - it \
1799             must wait for that decision to finish and then judge against what it left behind"
1800        );
1801        releaser.await.unwrap();
1802    }
1803
1804    #[tokio::test]
1805    async fn a_lock_held_past_the_ceiling_gives_up() {
1806        let dir = tempfile::tempdir().unwrap();
1807        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1808        let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
1809        let waited =
1810            wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
1811                .await
1812                .unwrap();
1813        assert!(
1814            waited.is_none(),
1815            "a lock genuinely held past the ceiling must eventually give up rather than wait \
1816             forever"
1817        );
1818    }
1819
1820    #[test]
1821    fn level_between_reads_off_the_differing_digit() {
1822        assert_eq!(
1823            level_between(
1824                Version::parse("0.8.0").unwrap(),
1825                Version::parse("1.0.0").unwrap()
1826            ),
1827            Some(BumpLevel::Major)
1828        );
1829        assert_eq!(
1830            level_between(
1831                Version::parse("0.8.0").unwrap(),
1832                Version::parse("0.9.0").unwrap()
1833            ),
1834            Some(BumpLevel::Minor)
1835        );
1836        assert_eq!(
1837            level_between(
1838                Version::parse("0.8.0").unwrap(),
1839                Version::parse("0.8.1").unwrap()
1840            ),
1841            Some(BumpLevel::Patch)
1842        );
1843        assert_eq!(
1844            level_between(
1845                Version::parse("0.8.0").unwrap(),
1846                Version::parse("0.8.0").unwrap()
1847            ),
1848            None
1849        );
1850    }
1851
1852    #[test]
1853    fn open_release_pr_is_found_among_unrelated_pull_requests() {
1854        let json = r#"[
1855            {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
1856            {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
1857        ]"#;
1858        let found = parse_open_release_pr(json).unwrap();
1859        assert_eq!(
1860            found,
1861            Some((
1862                "chore/release-v0.9.0".to_owned(),
1863                "https://example.invalid/pull/2".to_owned()
1864            ))
1865        );
1866    }
1867
1868    #[test]
1869    fn no_open_release_pr_reads_as_none_not_an_error() {
1870        let json =
1871            r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
1872        assert_eq!(parse_open_release_pr(json).unwrap(), None);
1873        assert_eq!(parse_open_release_pr("[]").unwrap(), None);
1874    }
1875
1876    #[test]
1877    fn marker_round_trips_through_disk() {
1878        let dir = tempfile::tempdir().unwrap();
1879        let path = marker_path(dir.path(), Path::new("/repos/magi"));
1880        assert!(read_marker(&path).is_none());
1881
1882        let marker = test_pending("0.9.0", BumpLevel::Patch);
1883        write_marker(&path, &marker).unwrap();
1884        let read_back = read_marker(&path).unwrap();
1885        assert_eq!(read_back.target_version, "0.9.0");
1886        assert_eq!(read_back.level, BumpLevel::Patch);
1887        assert_eq!(read_back.pr_url, marker.pr_url);
1888
1889        clear_marker(&path);
1890        assert!(read_marker(&path).is_none());
1891    }
1892
1893    #[test]
1894    fn different_repos_get_different_marker_files() {
1895        let dir = tempfile::tempdir().unwrap();
1896        let a = marker_path(dir.path(), Path::new("/repos/a"));
1897        let b = marker_path(dir.path(), Path::new("/repos/b"));
1898        assert_ne!(a, b);
1899    }
1900
1901    /// A version-bump-only pull request must never trigger the next bump - see
1902    /// [`is_release_only`]'s own doc for why that shape is the trigger for
1903    /// "do not treat this as a change to react to".
1904    #[test]
1905    fn a_bump_pull_requests_own_merge_does_not_retrigger() {
1906        let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
1907        assert!(
1908            is_release_only(&files),
1909            "the bump pull request's own diff must read as release-only"
1910        );
1911    }
1912
1913    #[test]
1914    fn should_release_bump_reads_only_a_merged_status() {
1915        assert!(should_release_bump(RunStatus::Merged));
1916        for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
1917            assert!(!should_release_bump(other));
1918        }
1919    }
1920
1921    /// `land::Step::Done { merged: true }` - a pull request already merged
1922    /// underneath magi. `land::decide` reads that straight off the pull
1923    /// request's own lifecycle before it looks at checks or comments at all.
1924    #[test]
1925    fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
1926        let pr = land::PrState {
1927            url: "https://github.com/o/r/pull/1".to_owned(),
1928            number: 1,
1929            state: PrLifecycle::Merged,
1930            checks: land::Checks::Green,
1931            failing: Vec::new(),
1932            review_comments: Vec::new(),
1933            blocking: land::Blocking::No,
1934        };
1935        assert_eq!(
1936            land::decide(&pr, 0, 4, Duration::ZERO),
1937            land::Step::Done { merged: true }
1938        );
1939        assert!(should_release_bump(RunStatus::Merged));
1940    }
1941
1942    /// `land::Step::Merge`'s own `gh pr merge` succeeding: `land::land` then
1943    /// sets `pr.state = PrLifecycle::Merged` by hand before returning (see
1944    /// `land::land`'s `Step::Merge` arm), which is the same value the other
1945    /// two paths converge on.
1946    #[test]
1947    fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
1948        let pr = land::PrState {
1949            url: "https://github.com/o/r/pull/2".to_owned(),
1950            number: 2,
1951            state: PrLifecycle::Open,
1952            checks: land::Checks::Green,
1953            failing: Vec::new(),
1954            review_comments: Vec::new(),
1955            blocking: land::Blocking::No,
1956        };
1957        assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
1958        // land::land's Step::Merge arm sets this by hand on success; asserted
1959        // here as the value that then makes should_release_bump fire.
1960        assert!(should_release_bump(RunStatus::Merged));
1961    }
1962
1963    /// [`land::merged_after_all`] - `gh pr merge` exited non-zero but the
1964    /// forge confirms the pull request merged anyway.
1965    #[test]
1966    fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
1967        let argv = land::merge_argv(3, "feat: something");
1968        let outcome = land::merged_after_all(
1969            &argv,
1970            "could not determine current branch: not on any branch",
1971            Some(PrLifecycle::Merged),
1972        );
1973        assert!(outcome.is_some(), "the forge's confirmation must win");
1974        assert!(should_release_bump(RunStatus::Merged));
1975
1976        // The same recovery must not fabricate a merge when the forge does
1977        // not confirm one.
1978        assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
1979        assert!(land::merged_after_all(&argv, "network error", None).is_none());
1980    }
1981
1982    /// The paths that do *not* land must not read as merged either.
1983    #[test]
1984    fn a_close_or_a_give_up_does_not_trigger_a_bump() {
1985        let pr = land::PrState {
1986            url: "https://github.com/o/r/pull/4".to_owned(),
1987            number: 4,
1988            state: PrLifecycle::Closed,
1989            checks: land::Checks::Green,
1990            failing: Vec::new(),
1991            review_comments: Vec::new(),
1992            blocking: land::Blocking::No,
1993        };
1994        assert_eq!(
1995            land::decide(&pr, 0, 4, Duration::ZERO),
1996            land::Step::Done { merged: false }
1997        );
1998        assert!(!should_release_bump(RunStatus::Blocked));
1999    }
2000}