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 the `[package]` table's `version = "..."` line, leaving every
213/// other byte untouched.
214///
215/// Scoped to the `[package]` 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" }`, or - in a workspace this crate is
218/// not, but a fork might become - a `[workspace.package]` table, must not
219/// move. That scoping is what lets a version-bump-only diff stay exactly
220/// that, which [`is_release_only`] and the "no reviewer needed" exemption in
221/// `AGENTS.md` both rest on.
222pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
223    let mut out = String::with_capacity(toml.len() + 8);
224    let mut in_package = 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_package = trimmed == "[package]";
230        }
231        if !done && in_package && 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 `[package]`");
241    }
242    Ok(out)
243}
244
245/// Read the `[package] version` currently on the base branch. No I/O: the
246/// caller fetches the blob (`git show <remote>/<base>:Cargo.toml`).
247fn current_version(toml: &str) -> Result<String> {
248    let mut in_package = false;
249    for line in toml.lines() {
250        let trimmed = line.trim();
251        if trimmed.starts_with('[') {
252            in_package = trimmed == "[package]";
253            continue;
254        }
255        if !in_package {
256            continue;
257        }
258        let mut parts = trimmed.splitn(2, '=');
259        let key = parts.next().map(str::trim);
260        let Some(value) = parts.next() else { continue };
261        if key == Some("version") {
262            return Ok(value.trim().trim_matches('"').to_owned());
263        }
264    }
265    bail!("no `version` field found under `[package]`")
266}
267
268/// Build the prompt asking an agent which digit of `major.minor.patch` a
269/// merged change earns.
270///
271/// Pure: every input is already known once a merge lands, so the whole
272/// decision policy - the "`minor` is the breaking digit below `1.0.0`" rule,
273/// what counts as a breaking surface, and the tie-break toward the larger
274/// digit - is asserted directly on the returned string, the same way
275/// [`crate::land::fix_prompt`] doc-comments its own rules rather than leaving
276/// them for a human to spot missing from a live reply.
277pub fn decision_prompt(
278    subject: &str,
279    instruction: &str,
280    diffstat: &str,
281    files: &[String],
282    current_version: &str,
283) -> String {
284    let mut s = format!(
285        "A pull request just merged into the base branch. Decide which digit \
286         of this project's `major.minor.patch` version this change earns, so \
287         a release bump can be opened for exactly it.\n\n\
288         Current version: {current_version}\n\n\
289         # Merge subject\n\n{subject}\n\n\
290         # The task that produced it\n\n{instruction}\n\n\
291         # Files changed ({} total)\n\n",
292        files.len()
293    );
294    const MAX_FILES: usize = 50;
295    for f in files.iter().take(MAX_FILES) {
296        let _ = writeln!(s, "- {f}");
297    }
298    if files.len() > MAX_FILES {
299        let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
300    }
301    let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
302
303    s.push_str(
304        "\n# How to decide\n\n\
305         This project is below version `1.0.0`. At that stage **`minor` is \
306         the digit that carries a breaking change** - do not spend `major` \
307         below `1.0.0`.\n\n\
308         A change is breaking, and earns `minor`, when it changes any of: \
309         the public API reachable from `src/lib.rs`, a CLI subcommand or \
310         flag, an HTTP API route or response shape, a configuration key, or \
311         the on-disk shape of persisted state.\n\n\
312         A user-visible new capability that breaks none of the above also \
313         earns `minor`.\n\n\
314         A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
315         **When it is not obvious which digit applies, choose the larger \
316         one.** An oversized bump costs nothing; a breaking change shipped as \
317         `patch` breaks every downstream update that pins a range.\n\n\
318         # Output\n\n\
319         Reply with exactly one fenced JSON object and nothing that matters \
320         outside it:\n\n\
321         ```json\n\
322         {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
323         ```\n",
324    );
325    s
326}
327
328/// magi's own record of a bump pull request it currently has open, so a
329/// burst of merges in quick succession does not each open a competing
330/// release.
331///
332/// **Chosen policy: serialize, not coalesce two independent decisions into
333/// one.** A bump branch touches only `Cargo.toml` / `Cargo.lock`, so `gh pr
334/// merge --squash` applies it onto whatever the base branch has become by
335/// the time it lands - every commit merged while it was open rides along
336/// for free, at no extra cost, once it merges. But the *digit* a still-open
337/// pull request targets was judged from only the first change, and a more
338/// severe change landing while it waits must not ship at the smaller digit
339/// just because it arrived second - so the serialization is at the pull
340/// request, not at the judgement: a later, more severe decision escalates
341/// the same open pull request (see [`pending_action`]) rather than opening a
342/// second one or being silently absorbed at the wrong digit.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct PendingBump {
345    /// The version the open pull request bumps to.
346    pub target_version: String,
347    /// The digit that version was judged to need, so a later, more severe
348    /// merge can tell it needs to escalate rather than assume it is covered.
349    pub level: BumpLevel,
350    /// The branch the open pull request is built from, so an escalation
351    /// knows what to check out and push to.
352    pub branch: String,
353    /// The pull request's URL, so a later merge can confirm it is still
354    /// open before trusting it to block a fresh decision.
355    pub pr_url: String,
356}
357
358/// Where [`PendingBump`] is recorded for `repo` - one file per repository, so
359/// a machine running magi against more than one checkout does not confuse
360/// their releases with each other.
361pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
362    let key = repo.to_string_lossy();
363    home.join("bump")
364        .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
365}
366
367/// Read a recorded [`PendingBump`], if any. Missing or unreadable both read
368/// as "nothing pending" - a marker is bookkeeping, not a source of truth
369/// worth failing a merge over.
370pub fn read_marker(path: &Path) -> Option<PendingBump> {
371    let body = std::fs::read_to_string(path).ok()?;
372    serde_json::from_str(&body).ok()
373}
374
375/// Persist `marker`, atomically - the same tmp-then-rename shape
376/// [`crate::updater::write_progress`] uses, since this file is read by a
377/// later, unrelated process invocation and must never be seen half-written.
378pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
379    if let Some(parent) = path.parent() {
380        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
381    }
382    let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
383    let tmp = path.with_extension("json.tmp");
384    std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
385    std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
386    Ok(())
387}
388
389/// Drop a recorded marker. Best-effort: a marker that is already gone is not
390/// an error.
391pub fn clear_marker(path: &Path) {
392    let _ = std::fs::remove_file(path);
393}
394
395/// What a recorded [`PendingBump`] means for a fresh decision, given what the
396/// base branch's `Cargo.toml` says right now. No I/O: the caller reads both
397/// the marker and the version.
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub enum Coalesce {
400    /// Nothing is pending, or the pending bump already landed (or was
401    /// superseded by a manual one) - safe to open a fresh decision.
402    Proceed,
403    /// A bump to `target_version` is already open; do not open a second one.
404    Skip {
405        /// The version the pending pull request already targets.
406        target_version: String,
407    },
408}
409
410/// Decide what a pending marker means against `current_version`.
411pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
412    let Some(pending) = pending else {
413        return Ok(Coalesce::Proceed);
414    };
415    let current = Version::parse(current_version)?;
416    let target = Version::parse(&pending.target_version)?;
417    if current >= target {
418        return Ok(Coalesce::Proceed);
419    }
420    Ok(Coalesce::Skip {
421        target_version: pending.target_version.clone(),
422    })
423}
424
425/// What a still-open pending bump means once a fresh decision is in hand.
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub enum PendingAction {
428    /// The new decision is no more severe than what is already queued; the
429    /// open pull request covers it once it lands.
430    AlreadyCovered,
431    /// The new decision outranks the pending target - escalate the open
432    /// pull request instead of opening a second one or dropping it.
433    Escalate,
434}
435
436/// Compare a fresh decision against what a still-open pull request already
437/// targets.
438///
439/// A patch bump left pending while a breaking change lands does not become a
440/// breaking release just because the pull request that carries both is
441/// squashed into one commit: the *version number* still comes from whichever
442/// digit was judged, and a pending `patch` never widens itself to `minor` on
443/// its own. This is the check that decides an escalation is owed.
444pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
445    if decision_level.severity() > pending_level.severity() {
446        PendingAction::Escalate
447    } else {
448        PendingAction::AlreadyCovered
449    }
450}
451
452/// Parse `gh pr view --json state` output. No I/O.
453fn parse_pr_state(json: &str) -> Result<bool> {
454    #[derive(Deserialize)]
455    struct State {
456        state: String,
457    }
458    let parsed: State =
459        serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
460    Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
461}
462
463/// Is the pull request at `pr_url` still open?
464///
465/// Read fresh rather than trusted from the marker: a bump pull request can be
466/// closed without merging - CI that never goes green, an operator who
467/// decided against it - and nothing else in this module ever revisits a
468/// marker once it is written. Without this check, that close is invisible
469/// here forever: the marker still names a pending target, the base branch
470/// never reaches it because nothing ever merged the pull request, and every
471/// later merge skips in perpetuity. A `gh` failure (network, auth) answers
472/// `true` - the same "unreadable is not absent" rule `land::CHECKS_GRACE`
473/// uses - because guessing "closed" wrongly opens a second, competing pull
474/// request, while guessing "open" wrongly only costs one more merge's wait.
475async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
476    let out = tokio::process::Command::new("gh")
477        .args(["pr", "view", pr_url, "--json", "state"])
478        .current_dir(repo)
479        .quiet()
480        .stdin(std::process::Stdio::null())
481        .output()
482        .await
483        .context("spawn gh pr view")?;
484    if !out.status.success() {
485        bail!(
486            "gh pr view {pr_url}: {}",
487            String::from_utf8_lossy(&out.stderr).trim()
488        );
489    }
490    parse_pr_state(&String::from_utf8_lossy(&out.stdout))
491}
492
493/// How long a stale lock file is trusted to mean its owner is still working,
494/// before it is reclaimed.
495///
496/// Long enough to cover the slowest real step this module takes - the agent
497/// decision call ([`DECISION_TIMEOUT`]) plus `cargo build` and a `gh pr
498/// create` - so a lock is only ever stolen from a process that has actually
499/// gone (crashed, killed), never one still inside its own critical section.
500const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
501
502/// A host-local mutual exclusion for one repository's marker file.
503///
504/// Built on exclusive file creation rather than a locking crate: neither
505/// `flock` nor `fs2` is a dependency of this crate, and the constraints on
506/// this change forbid adding one. This is not a distributed lock and does
507/// not coordinate two machines racing the same repository - it exists to
508/// close the specific race two `after_merge` calls on the *same* host can
509/// hit landing within the same window (a human `magi run` alongside the
510/// daemon, or two review loops): both would otherwise read "nothing
511/// pending", judge independently, and open two competing pull requests, with
512/// whichever `write_marker` runs last silently erasing the other's record.
513struct MarkerLock {
514    path: PathBuf,
515}
516
517impl MarkerLock {
518    /// Try to take the lock for `marker`, stealing a stale one first if it is
519    /// old enough to mean its owner is gone rather than merely slow.
520    /// `Ok(None)` means someone else genuinely holds it right now.
521    fn acquire(marker: &Path) -> Result<Option<Self>> {
522        let path = marker.with_extension("lock");
523        if let Some(parent) = path.parent() {
524            std::fs::create_dir_all(parent)
525                .with_context(|| format!("create {}", parent.display()))?;
526        }
527        if Self::try_create(&path)? {
528            return Ok(Some(Self { path }));
529        }
530        if Self::is_stale(&path) {
531            let _ = std::fs::remove_file(&path);
532            if Self::try_create(&path)? {
533                return Ok(Some(Self { path }));
534            }
535        }
536        Ok(None)
537    }
538
539    fn try_create(path: &Path) -> Result<bool> {
540        match std::fs::OpenOptions::new()
541            .write(true)
542            .create_new(true)
543            .open(path)
544        {
545            Ok(_) => Ok(true),
546            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
547            Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
548        }
549    }
550
551    fn is_stale(path: &Path) -> bool {
552        std::fs::metadata(path)
553            .and_then(|m| m.modified())
554            .ok()
555            .and_then(|m| m.elapsed().ok())
556            .is_some_and(|age| age >= LOCK_STALE_AFTER)
557    }
558}
559
560impl Drop for MarkerLock {
561    fn drop(&mut self) {
562        let _ = std::fs::remove_file(&self.path);
563    }
564}
565
566/// How often a blocked caller checks whether [`MarkerLock`] has freed up.
567const LOCK_POLL: Duration = Duration::from_secs(5);
568
569/// How long a caller waits for a contended lock before giving up on this
570/// merge's own judgement entirely.
571///
572/// A first version of this gate gave up the instant the lock was taken,
573/// which meant a change landing while another host's decision call was
574/// still running was never judged at all - not even recorded as pending,
575/// not escalated later, just dropped. The lock is only ever held for one
576/// `after_merge` call, so waiting past it is what lets that call's own
577/// decision reach [`pending_action`] against a marker the other side just
578/// finished writing, instead of finding nothing to check against. Set just
579/// under [`LOCK_STALE_AFTER`]: a lock still held this long after that point
580/// is reclaimed as abandoned rather than waited on further.
581const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
582
583/// Wait for [`MarkerLock`] to free up, polling rather than blocking forever.
584/// `Ok(None)` means the ceiling passed with the lock still held.
585async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
586    wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
587}
588
589/// [`wait_for_marker_lock`] with the poll interval and ceiling as parameters,
590/// so the retry behaviour is testable without a test actually waiting out
591/// [`LOCK_WAIT_CEILING`].
592async fn wait_for_marker_lock_with(
593    marker: &Path,
594    poll: Duration,
595    ceiling: Duration,
596) -> Result<Option<MarkerLock>> {
597    let mut waited = Duration::ZERO;
598    loop {
599        if let Some(lock) = MarkerLock::acquire(marker)? {
600            return Ok(Some(lock));
601        }
602        if waited >= ceiling {
603            return Ok(None);
604        }
605        tokio::time::sleep(poll).await;
606        waited += poll;
607    }
608}
609
610/// Which digit differs between `from` and `to`? `None` when they are equal.
611///
612/// Used to recover the level a pull request found by [`find_open_release_pr`]
613/// was judged at: the forge has the resulting version (in the branch name and
614/// the title) but not the digit an agent chose to get there, and this is the
615/// one other host-independent fact every host can compute the same way from
616/// it.
617fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
618    if to.major != from.major {
619        Some(BumpLevel::Major)
620    } else if to.minor != from.minor {
621        Some(BumpLevel::Minor)
622    } else if to.patch != from.patch {
623        Some(BumpLevel::Patch)
624    } else {
625        None
626    }
627}
628
629/// Parse `gh pr list --state open --json url,headRefName` output, returning
630/// the first pull request whose branch is one of this module's own. No I/O.
631fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
632    #[derive(Deserialize)]
633    struct Pr {
634        url: String,
635        #[serde(rename = "headRefName")]
636        head_ref_name: String,
637    }
638    let list: Vec<Pr> =
639        serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
640    Ok(list
641        .into_iter()
642        .find(|p| p.head_ref_name.starts_with("chore/release-v"))
643        .map(|p| (p.head_ref_name, p.url)))
644}
645
646/// Ask the forge directly whether a release bump is already open, for a host
647/// that has never seen it.
648///
649/// [`MarkerLock`] and the marker file only ever coordinate *this* host - a
650/// marker written on one machine is not visible to `run::home()` on another,
651/// so two hosts landing runs against the same repository at the same time
652/// can each read "nothing pending" and open a competing pull request no
653/// local lock can see. `gh pr list` is the one place every host actually
654/// shares a view, so it is consulted whenever this host's own marker says
655/// there is nothing pending, before a fresh decision is allowed to open a
656/// second pull request. This narrows the race to the gap between this call
657/// and whichever host's `gh pr create` lands first - it does not close it -
658/// because turning that into a real distributed lock would need coordination
659/// this crate has no dependency for.
660async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
661    let out = tokio::process::Command::new("gh")
662        .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
663        .current_dir(repo)
664        .quiet()
665        .stdin(std::process::Stdio::null())
666        .output()
667        .await
668        .context("spawn gh pr list")?;
669    if !out.status.success() {
670        bail!(
671            "gh pr list: {}",
672            String::from_utf8_lossy(&out.stderr).trim()
673        );
674    }
675    parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
676}
677
678/// After a merge lands, ask an agent how big the change was and open a
679/// release bump sized to it.
680///
681/// Best-effort by construction, the same way `clean::fold_due` treats one
682/// run's fold failure: this runs after the merge the run exists to produce
683/// has already succeeded, so a failure here (the decision call, `gh`,
684/// `cargo`) must never turn a landed run into a failed one. The caller logs
685/// whatever this returns and moves on.
686pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
687    if !state.config.merge.release_bump {
688        return Ok(());
689    }
690    let Some(winner) = state.winner().cloned() else {
691        return Ok(());
692    };
693    let repo = state.repo.clone();
694    let base = state.base_branch.clone();
695    let remote = state.config.merge.remote.clone();
696
697    let files = git::changed_files(&winner.worktree, &base, &winner.branch)
698        .await
699        .unwrap_or_default();
700    if is_release_only(&files) {
701        state.event(
702            "bump",
703            "the merged change touches only the release manifest; not treating it as a trigger",
704        );
705        return Ok(());
706    }
707
708    let marker = marker_path(&run::home(), &repo);
709    // Held for the rest of this function: the whole read-decide-write
710    // sequence below is the critical section two `after_merge` calls landing
711    // within the same window must not both be inside at once. See
712    // `MarkerLock`'s own doc for why a second, unrelated bump PR is what
713    // that race produces without it, and `wait_for_marker_lock`'s for why
714    // this waits rather than giving up the instant it is contended.
715    let Some(_lock) = wait_for_marker_lock(&marker).await? else {
716        state.event(
717            "bump",
718            "another release bump decision held the lock past the wait ceiling; skipping this round",
719        );
720        return Ok(());
721    };
722
723    git::fetch(&repo, &remote, &base).await.ok();
724    let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
725        .await
726        .context("read Cargo.toml from the base branch")?;
727    let base_version = current_version(&cargo_toml)?;
728
729    let mut pending = read_marker(&marker);
730    if let Some(p) = &pending {
731        match coalesce(Some(p), &base_version)? {
732            Coalesce::Proceed => {
733                // Landed, or superseded by a manual bump: free for a fresh
734                // decision.
735                clear_marker(&marker);
736                pending = None;
737            }
738            Coalesce::Skip { target_version } => {
739                if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
740                    state.event(
741                        "bump",
742                        format!(
743                            "the pending release bump to v{target_version} ({}) is no longer \
744                             open; treating it as abandoned",
745                            p.pr_url
746                        ),
747                    );
748                    clear_marker(&marker);
749                    pending = None;
750                }
751                // Otherwise still genuinely open: fall through and ask the
752                // same question this merge would get on a fresh path, so a
753                // more severe change landing while it waits can escalate it
754                // instead of being silently absorbed at the wrong digit.
755            }
756        }
757    }
758
759    if pending.is_none() {
760        // This host's own marker has nothing to say - check the forge itself
761        // before trusting that to mean a fresh pull request is safe to open.
762        // See `find_open_release_pr`'s own doc for what this does and does
763        // not close.
764        if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
765            && let Some(target) = branch
766                .strip_prefix("chore/release-v")
767                .and_then(|v| Version::parse(v).ok())
768        {
769            let base_parsed = Version::parse(&base_version)?;
770            if target > base_parsed
771                && let Some(level) = level_between(base_parsed, target)
772            {
773                let adopted = PendingBump {
774                    target_version: target.to_string(),
775                    level,
776                    branch,
777                    pr_url: url,
778                };
779                // Best-effort: worst case this host asks the forge again
780                // next time instead of finding its own record of it.
781                let _ = write_marker(&marker, &adopted);
782                pending = Some(adopted);
783            }
784        }
785    }
786
787    let title = pr_title(&repo, pr_url).await.unwrap_or_default();
788    let subject = land::merge_subject(&title, &state.instruction);
789    let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
790        .await
791        .unwrap_or_default();
792    let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
793
794    // No dedicated role for this one-off decision. Borrows `[roles] chatter`
795    // - the nearest surviving single-agent-seat preference - rather than
796    // falling straight to `agent::pick`'s own default order, so an operator
797    // who has already named a preferred seat there is not silently
798    // overridden for this decision too.
799    let spec: AgentSpec = agent::pick(
800        &state.config.agents,
801        state.config.roles.chatter.as_deref(),
802        &agent::installed,
803    )
804    .context("choose an agent for the release-bump decision")?;
805    let mut seat = SeatState::new("bump", &spec.id, state.seed);
806    let artifacts = agent::artifacts_dir(&state.dir());
807    let out = agent::invoke(
808        &spec,
809        &mut seat,
810        &Invocation {
811            cwd: &repo,
812            prompt: &prompt,
813            timeout: DECISION_TIMEOUT,
814            // The decision reads a diffstat and writes a verdict; it must
815            // never touch a file.
816            allow_write: false,
817            sessions: false,
818            artifacts: &artifacts,
819            stem: "bump-decision",
820            run: &state.id,
821            node: "bump",
822            cache_dir: state.config.cache_dir().as_deref(),
823            attachments: &[],
824        },
825    )
826    .await
827    .context("ask an agent how big the merged change was")?;
828    if !out.usable() {
829        bail!(
830            "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
831            out.exit_code,
832            out.timed_out
833        );
834    }
835    let decision = parse_decision(&out.text).context("parse the release-bump decision")?;
836
837    if let Some(p) = pending {
838        return match pending_action(p.level, decision.level) {
839            PendingAction::AlreadyCovered => {
840                state.event(
841                    "bump",
842                    format!(
843                        "a release bump to v{} ({}) already covers at least a {} change; not \
844                         opening another",
845                        p.target_version,
846                        p.pr_url,
847                        decision.level.as_str()
848                    ),
849                );
850                Ok(())
851            }
852            PendingAction::Escalate => {
853                escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
854            }
855        };
856    }
857
858    let next = Version::parse(&base_version)?
859        .bump(decision.level)
860        .to_string();
861    let branch = format!("chore/release-v{next}");
862    let worktree = state.dir().join("bump");
863    git::worktree_remove(&repo, &worktree).await.ok();
864    git::worktree_add_branch(&repo, &worktree, &branch, &format!("{remote}/{base}"))
865        .await
866        .context("create the release-bump worktree")?;
867    let opened = open_bump_pr(state, &worktree, &branch, &next, &decision, pr_url).await;
868    // Throwaway either way: nothing downstream reads this worktree, and a
869    // release worktree left behind after a failed attempt would collide with
870    // the next one this same run tries.
871    git::worktree_remove(&repo, &worktree).await.ok();
872    let (pr_url_opened, automerge_warning) = opened?;
873
874    // The pull request exists on the forge the moment `open_bump_pr` returns
875    // its URL, regardless of what happens next - so the event that names it
876    // is unconditional, and a marker write failing (a full disk, a missing
877    // `home/bump` directory) is reported as its own warning rather than
878    // swallowing that URL entirely the way propagating it with `?` would.
879    // `find_open_release_pr` is the fallback if this leaves no local record:
880    // the next merge that finds no marker still finds this pull request on
881    // the forge before opening a second one.
882    let marker_write = write_marker(
883        &marker,
884        &PendingBump {
885            target_version: next.clone(),
886            level: decision.level,
887            branch,
888            pr_url: pr_url_opened.clone(),
889        },
890    );
891    state.event(
892        "bump",
893        format!(
894            "opened a {} release bump to v{next} ({}): {pr_url_opened}",
895            decision.level.as_str(),
896            decision.reason
897        ),
898    );
899    if let Err(e) = marker_write {
900        state.event(
901            "bump",
902            format!(
903                "could not record the pending release bump marker for v{next}: {e:#}; a later \
904                 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
905                 the forge either"
906            ),
907        );
908    }
909    if let Some(warning) = automerge_warning {
910        state.event(
911            "bump",
912            format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
913        );
914    }
915    Ok(())
916}
917
918/// Bump an already-open release pull request further, because a change more
919/// severe than what it already covers landed while it waited on CI or
920/// automerge - see [`pending_action`].
921///
922/// Adds a second commit rather than rewriting the first: `gh pr merge
923/// --squash` prefers a single commit's own message over the pull request's
924/// title, and falls back to the title once there is more than one commit -
925/// so the title is what is kept honest here, via `gh pr edit`.
926async fn escalate_pending(
927    state: &mut RunState,
928    repo: &Path,
929    remote: &str,
930    pending: &PendingBump,
931    decision: &BumpDecision,
932    base_version: &str,
933    marker: &Path,
934) -> Result<()> {
935    let next = Version::parse(base_version)?
936        .bump(decision.level)
937        .to_string();
938    let worktree = state.dir().join("bump");
939    git::worktree_remove(repo, &worktree).await.ok();
940    let checked_out = git::git_raw(
941        repo,
942        &[
943            "worktree",
944            "add",
945            "--force",
946            &worktree.to_string_lossy(),
947            &pending.branch,
948        ],
949    )
950    .await?;
951    if !checked_out.ok() {
952        bail!(
953            "checking out the pending release branch {} failed: {}",
954            pending.branch,
955            checked_out.stderr
956        );
957    }
958
959    // Only the substantive change - the commit landing on the remote branch
960    // - has to succeed for the escalation to have happened at all. Anything
961    // after the push is a follow-up, not a precondition: the branch already
962    // carries the new version whether or not it succeeds.
963    let pushed: Result<()> = async {
964        let cargo_toml_path = worktree.join("Cargo.toml");
965        let toml = tokio::fs::read_to_string(&cargo_toml_path)
966            .await
967            .with_context(|| format!("read {}", cargo_toml_path.display()))?;
968        let rewritten = rewrite_cargo_version(&toml, &next)?;
969        tokio::fs::write(&cargo_toml_path, rewritten)
970            .await
971            .with_context(|| format!("write {}", cargo_toml_path.display()))?;
972        sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
973        let committed = git::commit_all(
974            &worktree,
975            &format!(
976                "chore: release v{next} (supersedes v{})",
977                pending.target_version
978            ),
979        )
980        .await
981        .context("commit the escalated version bump")?;
982        if !committed {
983            bail!("escalating the version bump left nothing to commit");
984        }
985        let pushed = git::push(&worktree, remote, &pending.branch).await?;
986        if !pushed.ok() {
987            bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
988        }
989        Ok(())
990    }
991    .await;
992    if let Err(e) = pushed {
993        git::worktree_remove(repo, &worktree).await.ok();
994        return Err(e);
995    }
996
997    // The commit is on the remote branch now regardless of what happens
998    // below - the title edit is cosmetic, and the marker and the event must
999    // both reflect the real, already-pushed state even if it fails.
1000    let title_warning = match gh_pr_edit_title(
1001        &worktree,
1002        &pending.pr_url,
1003        &format!("chore: release v{next}"),
1004    )
1005    .await
1006    {
1007        Ok(()) => None,
1008        Err(e) => Some(e.to_string()),
1009    };
1010    git::worktree_remove(repo, &worktree).await.ok();
1011
1012    let marker_write = write_marker(
1013        marker,
1014        &PendingBump {
1015            target_version: next.clone(),
1016            level: decision.level,
1017            branch: pending.branch.clone(),
1018            pr_url: pending.pr_url.clone(),
1019        },
1020    );
1021    state.event(
1022        "bump",
1023        format!(
1024            "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1025            pending.target_version,
1026            decision.level.as_str(),
1027            decision.reason,
1028            pending.pr_url
1029        ),
1030    );
1031    if let Err(e) = marker_write {
1032        state.event(
1033            "bump",
1034            format!(
1035                "could not update the pending release bump marker to v{next}: {e:#}; a later \
1036                 merge may misjudge whether it is already covered"
1037            ),
1038        );
1039    }
1040    if let Some(warning) = title_warning {
1041        state.event(
1042            "bump",
1043            format!(
1044                "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1045                 subject may still read the superseded version",
1046                pending.pr_url
1047            ),
1048        );
1049    }
1050    Ok(())
1051}
1052
1053/// Edit the version, let the lockfile follow, commit, push, and open the pull
1054/// request with automerge enabled. Returns the opened pull request's URL and,
1055/// when enabling automerge itself failed, a note of why - the pull request
1056/// still exists on the forge either way, and the caller must not lose track
1057/// of its URL over that failure alone.
1058async fn open_bump_pr(
1059    state: &RunState,
1060    worktree: &Path,
1061    branch: &str,
1062    next_version: &str,
1063    decision: &BumpDecision,
1064    source_pr_url: &str,
1065) -> Result<(String, Option<String>)> {
1066    let cargo_toml_path = worktree.join("Cargo.toml");
1067    let toml = tokio::fs::read_to_string(&cargo_toml_path)
1068        .await
1069        .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1070    let rewritten = rewrite_cargo_version(&toml, next_version)?;
1071    tokio::fs::write(&cargo_toml_path, rewritten)
1072        .await
1073        .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1074
1075    sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
1076
1077    let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
1078        .await
1079        .context("commit the version bump")?;
1080    if !committed {
1081        bail!("the version bump left nothing to commit");
1082    }
1083
1084    let remote = state.config.merge.remote.clone();
1085    let pushed = git::push(worktree, &remote, branch).await?;
1086    if !pushed.ok() {
1087        bail!("pushing {branch} failed: {}", pushed.stderr);
1088    }
1089
1090    let title = format!("chore: release v{next_version}");
1091    let body = format!(
1092        "Release bump: `{}` to `v{next_version}`.\n\n{}\n\n\
1093         Triggered by run `{}`, which landed {source_pr_url}.\n\n\
1094         version-bump-only; nothing here needs a review \
1095         (AGENTS.md: \"Version-bump-only pull requests\").",
1096        decision.level.as_str(),
1097        decision.reason,
1098        state.id,
1099    );
1100    let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
1101    let automerge_warning = match gh_enable_automerge(worktree, &url).await {
1102        Ok(()) => None,
1103        Err(e) => Some(e.to_string()),
1104    };
1105    Ok((url, automerge_warning))
1106}
1107
1108/// Run `cargo build` so `Cargo.lock` follows the version bump, the same step
1109/// `AGENTS.md`'s hand-driven release recipe calls for.
1110///
1111/// Not exercised by a test: it is the one step in this module that runs the
1112/// real `cargo`, which the constraints on this change rule out doing from a
1113/// test (no network, no writing outside a throwaway worktree the test itself
1114/// does not have).
1115async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
1116    let mut cmd = tokio::process::Command::new("cargo");
1117    cmd.arg("build").current_dir(worktree).quiet();
1118    if let Some(dir) = cache_dir {
1119        cmd.env("CARGO_TARGET_DIR", dir);
1120    }
1121    let out = cmd
1122        .stdin(std::process::Stdio::null())
1123        .output()
1124        .await
1125        .context("spawn cargo build")?;
1126    if !out.status.success() {
1127        bail!(
1128            "cargo build failed while syncing Cargo.lock: {}",
1129            String::from_utf8_lossy(&out.stderr).trim()
1130        );
1131    }
1132    Ok(())
1133}
1134
1135/// The merged pull request's title, for [`land::merge_subject`].
1136async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
1137    let out = tokio::process::Command::new("gh")
1138        .args(["pr", "view", pr_url, "--json", "title"])
1139        .current_dir(repo)
1140        .quiet()
1141        .stdin(std::process::Stdio::null())
1142        .output()
1143        .await
1144        .context("spawn gh pr view")?;
1145    if !out.status.success() {
1146        bail!(
1147            "gh pr view {pr_url}: {}",
1148            String::from_utf8_lossy(&out.stderr).trim()
1149        );
1150    }
1151    #[derive(Deserialize)]
1152    struct Title {
1153        title: String,
1154    }
1155    let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
1156        .context("parse `gh pr view --json title` output")?;
1157    Ok(parsed.title)
1158}
1159
1160async fn gh_pr_create(
1161    cwd: &Path,
1162    base: &str,
1163    head: &str,
1164    title: &str,
1165    body: &str,
1166) -> Result<String> {
1167    let out = tokio::process::Command::new("gh")
1168        .args([
1169            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
1170        ])
1171        .current_dir(cwd)
1172        .quiet()
1173        .stdin(std::process::Stdio::null())
1174        .output()
1175        .await
1176        .context("spawn gh pr create")?;
1177    if out.status.success() {
1178        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
1179    } else {
1180        bail!(
1181            "gh pr create: {}",
1182            String::from_utf8_lossy(&out.stderr).trim()
1183        )
1184    }
1185}
1186
1187/// Enable automerge, mirroring `AGENTS.md`'s `gh pr merge --auto --squash
1188/// --delete-branch`. Never `git tag`: `auto-tag.yml` mints the tag once this
1189/// merges, and a manual tag would collide with its push.
1190async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
1191    let out = tokio::process::Command::new("gh")
1192        .args([
1193            "pr",
1194            "merge",
1195            pr_url,
1196            "--auto",
1197            "--squash",
1198            "--delete-branch",
1199        ])
1200        .current_dir(cwd)
1201        .quiet()
1202        .stdin(std::process::Stdio::null())
1203        .output()
1204        .await
1205        .context("spawn gh pr merge --auto")?;
1206    if out.status.success() {
1207        Ok(())
1208    } else {
1209        bail!(
1210            "gh pr merge --auto: {}",
1211            String::from_utf8_lossy(&out.stderr).trim()
1212        )
1213    }
1214}
1215
1216/// Rewrite a pull request's title, used when [`escalate_pending`] adds a
1217/// second commit: `gh pr merge --squash` only prefers a single commit's own
1218/// message over the title, so once there are two the title is what lands.
1219async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
1220    let out = tokio::process::Command::new("gh")
1221        .args(["pr", "edit", pr_url, "--title", title])
1222        .current_dir(cwd)
1223        .quiet()
1224        .stdin(std::process::Stdio::null())
1225        .output()
1226        .await
1227        .context("spawn gh pr edit")?;
1228    if out.status.success() {
1229        Ok(())
1230    } else {
1231        bail!(
1232            "gh pr edit --title: {}",
1233            String::from_utf8_lossy(&out.stderr).trim()
1234        )
1235    }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240    use super::*;
1241    use crate::config::Config;
1242    use crate::land::PrLifecycle;
1243
1244    /// `[merge] release_bump = false` must short-circuit before any I/O -
1245    /// `after_merge` is reached from a live run with a real repo and a real
1246    /// `gh`, so the disabled case is asserted with a `RunState` that would
1247    /// fail loudly (an unresolvable `/no/such/repo`) the moment anything past
1248    /// the flag check tried to touch it.
1249    #[tokio::test]
1250    async fn a_disabled_config_does_nothing() {
1251        let config = Config {
1252            merge: crate::config::Merge {
1253                release_bump: false,
1254                ..crate::config::Merge::default()
1255            },
1256            ..Config::default()
1257        };
1258        let mut state = RunState::new(
1259            PathBuf::from("/no/such/repo"),
1260            "main".to_owned(),
1261            "0000000000000000000000000000000000000000".to_owned(),
1262            "irrelevant".to_owned(),
1263            config,
1264        );
1265        after_merge(&mut state, "https://example.invalid/pull/1")
1266            .await
1267            .expect("a disabled config must return Ok without touching anything");
1268        assert!(
1269            state.events.is_empty(),
1270            "nothing should happen at all, not even a logged event"
1271        );
1272    }
1273
1274    #[test]
1275    fn version_parses_and_bumps_each_digit() {
1276        let v = Version::parse("0.4.0").unwrap();
1277        assert_eq!(
1278            v,
1279            Version {
1280                major: 0,
1281                minor: 4,
1282                patch: 0
1283            }
1284        );
1285
1286        assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
1287        assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
1288        assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
1289    }
1290
1291    #[test]
1292    fn version_tolerates_a_prerelease_suffix_on_patch() {
1293        let v = Version::parse("1.2.3-rc1").unwrap();
1294        assert_eq!(
1295            v,
1296            Version {
1297                major: 1,
1298                minor: 2,
1299                patch: 3
1300            }
1301        );
1302    }
1303
1304    #[test]
1305    fn version_rejects_garbage() {
1306        assert!(Version::parse("not-a-version").is_err());
1307        assert!(Version::parse("1.2").is_err());
1308    }
1309
1310    #[test]
1311    fn decision_parses_each_level() {
1312        for (json, level) in [
1313            (
1314                r#"{"level":"major","reason":"drops a config key"}"#,
1315                BumpLevel::Major,
1316            ),
1317            (
1318                r#"{"level":"minor","reason":"adds a new flag"}"#,
1319                BumpLevel::Minor,
1320            ),
1321            (
1322                r#"{"level":"patch","reason":"fixes a race"}"#,
1323                BumpLevel::Patch,
1324            ),
1325        ] {
1326            let decision = parse_decision(json).unwrap();
1327            assert_eq!(decision.level, level);
1328            assert!(!decision.reason.is_empty());
1329        }
1330    }
1331
1332    #[test]
1333    fn decision_wrapped_in_a_fence_and_prose_still_parses() {
1334        let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
1335        let decision = parse_decision(text).unwrap();
1336        assert_eq!(decision.level, BumpLevel::Minor);
1337        assert_eq!(decision.reason, "new HTTP route");
1338    }
1339
1340    #[test]
1341    fn a_broken_reply_is_an_error_not_a_default() {
1342        assert!(parse_decision("I decline to answer.").is_err());
1343        assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
1344        assert!(
1345            parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
1346            "an empty reason must not pass either"
1347        );
1348        assert!(
1349            parse_decision(r#"{"level":"patch"}"#).is_err(),
1350            "a reply with no reason at all must not pass"
1351        );
1352    }
1353
1354    #[test]
1355    fn prompt_states_the_zero_x_rule_and_the_tie_break() {
1356        let prompt = decision_prompt(
1357            "feat: add a phone endpoint",
1358            "add POST /api/widgets",
1359            "1 file changed, 10 insertions(+)",
1360            &["src/web.rs".to_owned()],
1361            "0.8.0",
1362        );
1363        assert!(prompt.contains("0.8.0"), "the current version is stated");
1364        assert!(
1365            prompt.contains("below `1.0.0`")
1366                && prompt.contains("`minor` is the digit that carries a breaking change"),
1367            "the 0.x rule must be explicit: {prompt}"
1368        );
1369        assert!(
1370            prompt.contains("choose the larger"),
1371            "the tie-break toward the bigger digit must be explicit: {prompt}"
1372        );
1373    }
1374
1375    #[test]
1376    fn release_only_diffs_are_recognised() {
1377        assert!(is_release_only(&["Cargo.toml".to_owned()]));
1378        assert!(is_release_only(&[
1379            "Cargo.toml".to_owned(),
1380            "Cargo.lock".to_owned()
1381        ]));
1382        assert!(!is_release_only(&[]));
1383        assert!(!is_release_only(&[
1384            "Cargo.toml".to_owned(),
1385            "src/main.rs".to_owned()
1386        ]));
1387    }
1388
1389    #[test]
1390    fn cargo_version_rewrite_touches_only_the_package_table() {
1391        let toml = "\
1392[package]\n\
1393# a comment mentioning version on purpose\n\
1394name = \"magi-cli\"\n\
1395version = \"0.8.0\"\n\
1396edition = \"2024\"\n\
1397\n\
1398[dependencies]\n\
1399foo = { version = \"1.2.3\" }\n";
1400        let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
1401        assert!(out.contains("version = \"0.9.0\""));
1402        assert!(
1403            out.contains("foo = { version = \"1.2.3\" }"),
1404            "a dependency's own version pin must survive: {out}"
1405        );
1406        assert!(
1407            out.contains("# a comment mentioning version on purpose"),
1408            "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
1409        );
1410        assert_eq!(
1411            out.lines().count(),
1412            toml.lines().count(),
1413            "the rewrite replaces one line, it does not add or remove any"
1414        );
1415    }
1416
1417    #[test]
1418    fn cargo_version_rewrite_fails_without_a_package_table() {
1419        let toml = "[dependencies]\nfoo = \"1\"\n";
1420        assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
1421    }
1422
1423    #[test]
1424    fn current_version_reads_only_the_package_table() {
1425        let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
1426        assert_eq!(current_version(toml).unwrap(), "0.8.0");
1427    }
1428
1429    #[test]
1430    fn coalesce_proceeds_with_nothing_pending() {
1431        assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
1432    }
1433
1434    /// A minimal, otherwise-plausible pending marker for tests that only
1435    /// care about one field.
1436    fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
1437        PendingBump {
1438            target_version: target_version.to_owned(),
1439            level,
1440            branch: format!("chore/release-v{target_version}"),
1441            pr_url: "https://example.invalid/pull/9".to_owned(),
1442        }
1443    }
1444
1445    #[test]
1446    fn coalesce_skips_while_the_pending_target_is_still_ahead() {
1447        let pending = test_pending("0.9.0", BumpLevel::Minor);
1448        assert_eq!(
1449            coalesce(Some(&pending), "0.8.0").unwrap(),
1450            Coalesce::Skip {
1451                target_version: "0.9.0".to_owned()
1452            }
1453        );
1454    }
1455
1456    #[test]
1457    fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
1458        let pending = test_pending("0.9.0", BumpLevel::Minor);
1459        // The pending bump landed exactly: proceed with a fresh decision.
1460        assert_eq!(
1461            coalesce(Some(&pending), "0.9.0").unwrap(),
1462            Coalesce::Proceed
1463        );
1464        // A human bumped further than what was pending: also proceed.
1465        assert_eq!(
1466            coalesce(Some(&pending), "1.0.0").unwrap(),
1467            Coalesce::Proceed
1468        );
1469    }
1470
1471    #[test]
1472    fn pending_action_escalates_only_for_a_more_severe_decision() {
1473        assert_eq!(
1474            pending_action(BumpLevel::Patch, BumpLevel::Patch),
1475            PendingAction::AlreadyCovered
1476        );
1477        assert_eq!(
1478            pending_action(BumpLevel::Patch, BumpLevel::Minor),
1479            PendingAction::Escalate
1480        );
1481        assert_eq!(
1482            pending_action(BumpLevel::Patch, BumpLevel::Major),
1483            PendingAction::Escalate
1484        );
1485        assert_eq!(
1486            pending_action(BumpLevel::Minor, BumpLevel::Patch),
1487            PendingAction::AlreadyCovered
1488        );
1489        assert_eq!(
1490            pending_action(BumpLevel::Major, BumpLevel::Minor),
1491            PendingAction::AlreadyCovered
1492        );
1493        assert_eq!(
1494            pending_action(BumpLevel::Major, BumpLevel::Major),
1495            PendingAction::AlreadyCovered
1496        );
1497    }
1498
1499    #[test]
1500    fn pr_state_parsing_reads_open_and_not_open() {
1501        assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
1502        assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
1503        assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
1504    }
1505
1506    #[test]
1507    fn a_lock_is_exclusive_until_dropped() {
1508        let dir = tempfile::tempdir().unwrap();
1509        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1510        let first = MarkerLock::acquire(&marker)
1511            .unwrap()
1512            .expect("first attempt takes the lock");
1513        assert!(
1514            MarkerLock::acquire(&marker).unwrap().is_none(),
1515            "a second attempt must be refused while the first holds it"
1516        );
1517        drop(first);
1518        assert!(
1519            MarkerLock::acquire(&marker).unwrap().is_some(),
1520            "dropping the guard releases the lock for the next attempt"
1521        );
1522    }
1523
1524    #[test]
1525    fn a_stale_lock_is_reclaimed() {
1526        let dir = tempfile::tempdir().unwrap();
1527        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1528        let lock_path = marker.with_extension("lock");
1529        std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1530        std::fs::write(&lock_path, b"").unwrap();
1531        let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
1532        std::fs::OpenOptions::new()
1533            .write(true)
1534            .open(&lock_path)
1535            .unwrap()
1536            .set_modified(old)
1537            .unwrap();
1538        assert!(
1539            MarkerLock::acquire(&marker).unwrap().is_some(),
1540            "a lock older than the stale window must be reclaimed rather than block forever"
1541        );
1542    }
1543
1544    #[tokio::test]
1545    async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
1546        let dir = tempfile::tempdir().unwrap();
1547        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1548        let held = MarkerLock::acquire(&marker)
1549            .unwrap()
1550            .expect("seed the contention");
1551        let releaser = tokio::spawn(async move {
1552            tokio::time::sleep(Duration::from_millis(20)).await;
1553            drop(held);
1554        });
1555        let waited =
1556            wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
1557                .await
1558                .unwrap();
1559        assert!(
1560            waited.is_some(),
1561            "a merge landing behind another's still-running decision must not be dropped - it \
1562             must wait for that decision to finish and then judge against what it left behind"
1563        );
1564        releaser.await.unwrap();
1565    }
1566
1567    #[tokio::test]
1568    async fn a_lock_held_past_the_ceiling_gives_up() {
1569        let dir = tempfile::tempdir().unwrap();
1570        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1571        let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
1572        let waited =
1573            wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
1574                .await
1575                .unwrap();
1576        assert!(
1577            waited.is_none(),
1578            "a lock genuinely held past the ceiling must eventually give up rather than wait \
1579             forever"
1580        );
1581    }
1582
1583    #[test]
1584    fn level_between_reads_off_the_differing_digit() {
1585        assert_eq!(
1586            level_between(
1587                Version::parse("0.8.0").unwrap(),
1588                Version::parse("1.0.0").unwrap()
1589            ),
1590            Some(BumpLevel::Major)
1591        );
1592        assert_eq!(
1593            level_between(
1594                Version::parse("0.8.0").unwrap(),
1595                Version::parse("0.9.0").unwrap()
1596            ),
1597            Some(BumpLevel::Minor)
1598        );
1599        assert_eq!(
1600            level_between(
1601                Version::parse("0.8.0").unwrap(),
1602                Version::parse("0.8.1").unwrap()
1603            ),
1604            Some(BumpLevel::Patch)
1605        );
1606        assert_eq!(
1607            level_between(
1608                Version::parse("0.8.0").unwrap(),
1609                Version::parse("0.8.0").unwrap()
1610            ),
1611            None
1612        );
1613    }
1614
1615    #[test]
1616    fn open_release_pr_is_found_among_unrelated_pull_requests() {
1617        let json = r#"[
1618            {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
1619            {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
1620        ]"#;
1621        let found = parse_open_release_pr(json).unwrap();
1622        assert_eq!(
1623            found,
1624            Some((
1625                "chore/release-v0.9.0".to_owned(),
1626                "https://example.invalid/pull/2".to_owned()
1627            ))
1628        );
1629    }
1630
1631    #[test]
1632    fn no_open_release_pr_reads_as_none_not_an_error() {
1633        let json =
1634            r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
1635        assert_eq!(parse_open_release_pr(json).unwrap(), None);
1636        assert_eq!(parse_open_release_pr("[]").unwrap(), None);
1637    }
1638
1639    #[test]
1640    fn marker_round_trips_through_disk() {
1641        let dir = tempfile::tempdir().unwrap();
1642        let path = marker_path(dir.path(), Path::new("/repos/magi"));
1643        assert!(read_marker(&path).is_none());
1644
1645        let marker = test_pending("0.9.0", BumpLevel::Patch);
1646        write_marker(&path, &marker).unwrap();
1647        let read_back = read_marker(&path).unwrap();
1648        assert_eq!(read_back.target_version, "0.9.0");
1649        assert_eq!(read_back.level, BumpLevel::Patch);
1650        assert_eq!(read_back.pr_url, marker.pr_url);
1651
1652        clear_marker(&path);
1653        assert!(read_marker(&path).is_none());
1654    }
1655
1656    #[test]
1657    fn different_repos_get_different_marker_files() {
1658        let dir = tempfile::tempdir().unwrap();
1659        let a = marker_path(dir.path(), Path::new("/repos/a"));
1660        let b = marker_path(dir.path(), Path::new("/repos/b"));
1661        assert_ne!(a, b);
1662    }
1663
1664    /// A version-bump-only pull request must never trigger the next bump - see
1665    /// [`is_release_only`]'s own doc for why that shape is the trigger for
1666    /// "do not treat this as a change to react to".
1667    #[test]
1668    fn a_bump_pull_requests_own_merge_does_not_retrigger() {
1669        let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
1670        assert!(
1671            is_release_only(&files),
1672            "the bump pull request's own diff must read as release-only"
1673        );
1674    }
1675
1676    #[test]
1677    fn should_release_bump_reads_only_a_merged_status() {
1678        assert!(should_release_bump(RunStatus::Merged));
1679        for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
1680            assert!(!should_release_bump(other));
1681        }
1682    }
1683
1684    /// `land::Step::Done { merged: true }` - a pull request already merged
1685    /// underneath magi. `land::decide` reads that straight off the pull
1686    /// request's own lifecycle before it looks at checks or comments at all.
1687    #[test]
1688    fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
1689        let pr = land::PrState {
1690            url: "https://github.com/o/r/pull/1".to_owned(),
1691            number: 1,
1692            state: PrLifecycle::Merged,
1693            checks: land::Checks::Green,
1694            failing: Vec::new(),
1695            review_comments: Vec::new(),
1696            blocking: land::Blocking::No,
1697        };
1698        assert_eq!(
1699            land::decide(&pr, 0, 4, Duration::ZERO),
1700            land::Step::Done { merged: true }
1701        );
1702        assert!(should_release_bump(RunStatus::Merged));
1703    }
1704
1705    /// `land::Step::Merge`'s own `gh pr merge` succeeding: `land::land` then
1706    /// sets `pr.state = PrLifecycle::Merged` by hand before returning (see
1707    /// `land::land`'s `Step::Merge` arm), which is the same value the other
1708    /// two paths converge on.
1709    #[test]
1710    fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
1711        let pr = land::PrState {
1712            url: "https://github.com/o/r/pull/2".to_owned(),
1713            number: 2,
1714            state: PrLifecycle::Open,
1715            checks: land::Checks::Green,
1716            failing: Vec::new(),
1717            review_comments: Vec::new(),
1718            blocking: land::Blocking::No,
1719        };
1720        assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
1721        // land::land's Step::Merge arm sets this by hand on success; asserted
1722        // here as the value that then makes should_release_bump fire.
1723        assert!(should_release_bump(RunStatus::Merged));
1724    }
1725
1726    /// [`land::merged_after_all`] - `gh pr merge` exited non-zero but the
1727    /// forge confirms the pull request merged anyway.
1728    #[test]
1729    fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
1730        let argv = land::merge_argv(3, "feat: something");
1731        let outcome = land::merged_after_all(
1732            &argv,
1733            "could not determine current branch: not on any branch",
1734            Some(PrLifecycle::Merged),
1735        );
1736        assert!(outcome.is_some(), "the forge's confirmation must win");
1737        assert!(should_release_bump(RunStatus::Merged));
1738
1739        // The same recovery must not fabricate a merge when the forge does
1740        // not confirm one.
1741        assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
1742        assert!(land::merged_after_all(&argv, "network error", None).is_none());
1743    }
1744
1745    /// The paths that do *not* land must not read as merged either.
1746    #[test]
1747    fn a_close_or_a_give_up_does_not_trigger_a_bump() {
1748        let pr = land::PrState {
1749            url: "https://github.com/o/r/pull/4".to_owned(),
1750            number: 4,
1751            state: PrLifecycle::Closed,
1752            checks: land::Checks::Green,
1753            failing: Vec::new(),
1754            review_comments: Vec::new(),
1755            blocking: land::Blocking::No,
1756        };
1757        assert_eq!(
1758            land::decide(&pr, 0, 4, Duration::ZERO),
1759            land::Step::Done { merged: false }
1760        );
1761        assert!(!should_release_bump(RunStatus::Blocked));
1762    }
1763}