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