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::ask;
31use crate::config::AgentSpec;
32use crate::git;
33use crate::land;
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 `table`'s `version = "..."` line, leaving every other byte
214/// untouched.
215///
216/// Scoped to the named 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" }` must never move, and neither must
219/// the *other* of `[package]` / `[workspace.package]` when only one of them
220/// is the one being bumped. That scoping is what lets a version-bump-only
221/// diff stay exactly that, which [`is_release_only`] and the "no reviewer
222/// needed" exemption in `AGENTS.md` both rest on.
223fn rewrite_table_version(toml: &str, table: &str, new_version: &str) -> Result<String> {
224    let mut out = String::with_capacity(toml.len() + 8);
225    let mut in_table = 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_table = trimmed == table;
231        }
232        if !done && in_table && 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 `{table}`");
242    }
243    Ok(out)
244}
245
246/// Rewrite the release version, wherever this manifest actually declares it.
247///
248/// A single crate carries its version under `[package]`. A workspace root
249/// with no crate of its own - `[workspace] members = [...]` and nothing
250/// else - carries it under `[workspace.package]` instead, and `[package]`
251/// does not exist there at all. `[package]` is tried first because it is the
252/// far more common shape and the one every existing bump so far has hit;
253/// `[workspace.package]` is the fallback for the shape that never worked
254/// before this. Either way exactly one table is ever touched, so the "one
255/// version line changes" property [`rewrite_table_version`] rests on holds
256/// regardless of which table it was.
257pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
258    rewrite_table_version(toml, "[package]", new_version)
259        .or_else(|_| rewrite_table_version(toml, "[workspace.package]", new_version))
260        .context("no `version` field found under `[package]` or `[workspace.package]`")
261}
262
263/// Find `table`'s `version` field, if it has one. No I/O.
264fn version_in_table(toml: &str, table: &str) -> Option<String> {
265    let mut in_table = false;
266    for line in toml.lines() {
267        let trimmed = line.trim();
268        if trimmed.starts_with('[') {
269            in_table = trimmed == table;
270            continue;
271        }
272        if !in_table {
273            continue;
274        }
275        let mut parts = trimmed.splitn(2, '=');
276        let key = parts.next().map(str::trim);
277        let Some(value) = parts.next() else {
278            continue;
279        };
280        if key == Some("version") {
281            return Some(value.trim().trim_matches('"').to_owned());
282        }
283    }
284    None
285}
286
287/// Read the version currently on the base branch, from `[package]` if it has
288/// one, else from `[workspace.package]` - see [`rewrite_cargo_version`] for
289/// why both exist and which wins. No I/O: the caller fetches the blob (`git
290/// show <remote>/<base>:Cargo.toml`).
291fn current_version(toml: &str) -> Result<String> {
292    version_in_table(toml, "[package]")
293        .or_else(|| version_in_table(toml, "[workspace.package]"))
294        .context("no `version` field found under `[package]` or `[workspace.package]`")
295}
296
297/// Build the prompt asking an agent which digit of `major.minor.patch` a
298/// merged change earns.
299///
300/// Pure: every input is already known once a merge lands, so the whole
301/// decision policy - the "`minor` is the breaking digit below `1.0.0`" rule,
302/// what counts as a breaking surface, and the tie-break toward the larger
303/// digit - is asserted directly on the returned string, the same way
304/// [`crate::land::fix_prompt`] doc-comments its own rules rather than leaving
305/// them for a human to spot missing from a live reply.
306pub fn decision_prompt(
307    subject: &str,
308    instruction: &str,
309    diffstat: &str,
310    files: &[String],
311    current_version: &str,
312) -> String {
313    let mut s = format!(
314        "A pull request just merged into the base branch. Decide which digit \
315         of this project's `major.minor.patch` version this change earns, so \
316         a release bump can be opened for exactly it.\n\n\
317         Current version: {current_version}\n\n\
318         # Merge subject\n\n{subject}\n\n\
319         # The task that produced it\n\n{instruction}\n\n\
320         # Files changed ({} total)\n\n",
321        files.len()
322    );
323    const MAX_FILES: usize = 50;
324    for f in files.iter().take(MAX_FILES) {
325        let _ = writeln!(s, "- {f}");
326    }
327    if files.len() > MAX_FILES {
328        let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
329    }
330    let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
331
332    s.push_str(
333        "\n# How to decide\n\n\
334         This project is below version `1.0.0`. At that stage **`minor` is \
335         the digit that carries a breaking change** - do not spend `major` \
336         below `1.0.0`.\n\n\
337         A change is breaking, and earns `minor`, when it changes any of: \
338         the public API reachable from `src/lib.rs`, a CLI subcommand or \
339         flag, an HTTP API route or response shape, a configuration key, or \
340         the on-disk shape of persisted state.\n\n\
341         A user-visible new capability that breaks none of the above also \
342         earns `minor`.\n\n\
343         A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
344         **When it is not obvious which digit applies, choose the larger \
345         one.** An oversized bump costs nothing; a breaking change shipped as \
346         `patch` breaks every downstream update that pins a range.\n\n\
347         # Output\n\n\
348         Reply with exactly one fenced JSON object and nothing that matters \
349         outside it:\n\n\
350         ```json\n\
351         {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
352         ```\n",
353    );
354    // The reason is pasted into the release pull request's body.
355    let _ = write!(
356        s,
357        "\n{}\n\nThe `reason` goes into a GitHub pull request body, so write it \
358         in English.\n",
359        crate::prompt::GITHUB_ENGLISH_HEADING
360    );
361    s
362}
363
364/// magi's own record of a bump pull request it currently has open, so a
365/// burst of merges in quick succession does not each open a competing
366/// release.
367///
368/// **Chosen policy: serialize, not coalesce two independent decisions into
369/// one.** A bump branch touches only `Cargo.toml` / `Cargo.lock`, so `gh pr
370/// merge --squash` applies it onto whatever the base branch has become by
371/// the time it lands - every commit merged while it was open rides along
372/// for free, at no extra cost, once it merges. But the *digit* a still-open
373/// pull request targets was judged from only the first change, and a more
374/// severe change landing while it waits must not ship at the smaller digit
375/// just because it arrived second - so the serialization is at the pull
376/// request, not at the judgement: a later, more severe decision escalates
377/// the same open pull request (see [`pending_action`]) rather than opening a
378/// second one or being silently absorbed at the wrong digit.
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct PendingBump {
381    /// The version the open pull request bumps to.
382    pub target_version: String,
383    /// The digit that version was judged to need, so a later, more severe
384    /// merge can tell it needs to escalate rather than assume it is covered.
385    pub level: BumpLevel,
386    /// The branch the open pull request is built from, so an escalation
387    /// knows what to check out and push to.
388    pub branch: String,
389    /// The pull request's URL, so a later merge can confirm it is still
390    /// open before trusting it to block a fresh decision.
391    pub pr_url: String,
392}
393
394/// Where [`PendingBump`] is recorded for `repo` - one file per repository, so
395/// a machine running magi against more than one checkout does not confuse
396/// their releases with each other.
397pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
398    let key = repo.to_string_lossy();
399    home.join("bump")
400        .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
401}
402
403/// Read a recorded [`PendingBump`], if any. Missing or unreadable both read
404/// as "nothing pending" - a marker is bookkeeping, not a source of truth
405/// worth failing a merge over.
406pub fn read_marker(path: &Path) -> Option<PendingBump> {
407    let body = std::fs::read_to_string(path).ok()?;
408    serde_json::from_str(&body).ok()
409}
410
411/// Persist `marker`, atomically - the same tmp-then-rename shape
412/// [`crate::updater::write_progress`] uses, since this file is read by a
413/// later, unrelated process invocation and must never be seen half-written.
414pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
415    if let Some(parent) = path.parent() {
416        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
417    }
418    let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
419    let tmp = path.with_extension("json.tmp");
420    std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
421    std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
422    Ok(())
423}
424
425/// Drop a recorded marker. Best-effort: a marker that is already gone is not
426/// an error.
427pub fn clear_marker(path: &Path) {
428    let _ = std::fs::remove_file(path);
429}
430
431/// What a recorded [`PendingBump`] means for a fresh decision, given what the
432/// base branch's `Cargo.toml` says right now. No I/O: the caller reads both
433/// the marker and the version.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub enum Coalesce {
436    /// Nothing is pending, or the pending bump already landed (or was
437    /// superseded by a manual one) - safe to open a fresh decision.
438    Proceed,
439    /// A bump to `target_version` is already open; do not open a second one.
440    Skip {
441        /// The version the pending pull request already targets.
442        target_version: String,
443    },
444}
445
446/// Decide what a pending marker means against `current_version`.
447pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
448    let Some(pending) = pending else {
449        return Ok(Coalesce::Proceed);
450    };
451    let current = Version::parse(current_version)?;
452    let target = Version::parse(&pending.target_version)?;
453    if current >= target {
454        return Ok(Coalesce::Proceed);
455    }
456    Ok(Coalesce::Skip {
457        target_version: pending.target_version.clone(),
458    })
459}
460
461/// What a still-open pending bump means once a fresh decision is in hand.
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub enum PendingAction {
464    /// The new decision is no more severe than what is already queued; the
465    /// open pull request covers it once it lands.
466    AlreadyCovered,
467    /// The new decision outranks the pending target - escalate the open
468    /// pull request instead of opening a second one or dropping it.
469    Escalate,
470}
471
472/// Compare a fresh decision against what a still-open pull request already
473/// targets.
474///
475/// A patch bump left pending while a breaking change lands does not become a
476/// breaking release just because the pull request that carries both is
477/// squashed into one commit: the *version number* still comes from whichever
478/// digit was judged, and a pending `patch` never widens itself to `minor` on
479/// its own. This is the check that decides an escalation is owed.
480pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
481    if decision_level.severity() > pending_level.severity() {
482        PendingAction::Escalate
483    } else {
484        PendingAction::AlreadyCovered
485    }
486}
487
488/// Parse `gh pr view --json state` output. No I/O.
489fn parse_pr_state(json: &str) -> Result<bool> {
490    #[derive(Deserialize)]
491    struct State {
492        state: String,
493    }
494    let parsed: State =
495        serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
496    Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
497}
498
499/// Is the pull request at `pr_url` still open?
500///
501/// Read fresh rather than trusted from the marker: a bump pull request can be
502/// closed without merging - CI that never goes green, an operator who
503/// decided against it - and nothing else in this module ever revisits a
504/// marker once it is written. Without this check, that close is invisible
505/// here forever: the marker still names a pending target, the base branch
506/// never reaches it because nothing ever merged the pull request, and every
507/// later merge skips in perpetuity. A `gh` failure (network, auth) answers
508/// `true` - the same "unreadable is not absent" rule `land::CHECKS_GRACE`
509/// uses - because guessing "closed" wrongly opens a second, competing pull
510/// request, while guessing "open" wrongly only costs one more merge's wait.
511async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
512    let out = tokio::process::Command::new("gh")
513        .args(["pr", "view", pr_url, "--json", "state"])
514        .current_dir(repo)
515        .quiet()
516        .stdin(std::process::Stdio::null())
517        .output()
518        .await
519        .context("spawn gh pr view")?;
520    if !out.status.success() {
521        bail!(
522            "gh pr view {pr_url}: {}",
523            String::from_utf8_lossy(&out.stderr).trim()
524        );
525    }
526    parse_pr_state(&String::from_utf8_lossy(&out.stdout))
527}
528
529/// How long a stale lock file is trusted to mean its owner is still working,
530/// before it is reclaimed.
531///
532/// Long enough to cover the slowest real step this module takes - the agent
533/// decision call ([`DECISION_TIMEOUT`]) plus `cargo build` and a `gh pr
534/// create` - so a lock is only ever stolen from a process that has actually
535/// gone (crashed, killed), never one still inside its own critical section.
536const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
537
538/// A host-local mutual exclusion for one repository's marker file.
539///
540/// Built on exclusive file creation rather than a locking crate: neither
541/// `flock` nor `fs2` is a dependency of this crate, and the constraints on
542/// this change forbid adding one. This is not a distributed lock and does
543/// not coordinate two machines racing the same repository - it exists to
544/// close the specific race two `after_merge` calls on the *same* host can
545/// hit landing within the same window (a human `magi run` alongside the
546/// daemon, or two review loops): both would otherwise read "nothing
547/// pending", judge independently, and open two competing pull requests, with
548/// whichever `write_marker` runs last silently erasing the other's record.
549struct MarkerLock {
550    path: PathBuf,
551}
552
553impl MarkerLock {
554    /// Try to take the lock for `marker`, stealing a stale one first if it is
555    /// old enough to mean its owner is gone rather than merely slow.
556    /// `Ok(None)` means someone else genuinely holds it right now.
557    fn acquire(marker: &Path) -> Result<Option<Self>> {
558        let path = marker.with_extension("lock");
559        if let Some(parent) = path.parent() {
560            std::fs::create_dir_all(parent)
561                .with_context(|| format!("create {}", parent.display()))?;
562        }
563        if Self::try_create(&path)? {
564            return Ok(Some(Self { path }));
565        }
566        if Self::is_stale(&path) {
567            let _ = std::fs::remove_file(&path);
568            if Self::try_create(&path)? {
569                return Ok(Some(Self { path }));
570            }
571        }
572        Ok(None)
573    }
574
575    fn try_create(path: &Path) -> Result<bool> {
576        match std::fs::OpenOptions::new()
577            .write(true)
578            .create_new(true)
579            .open(path)
580        {
581            Ok(_) => Ok(true),
582            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
583            Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
584        }
585    }
586
587    fn is_stale(path: &Path) -> bool {
588        std::fs::metadata(path)
589            .and_then(|m| m.modified())
590            .ok()
591            .and_then(|m| m.elapsed().ok())
592            .is_some_and(|age| age >= LOCK_STALE_AFTER)
593    }
594}
595
596impl Drop for MarkerLock {
597    fn drop(&mut self) {
598        let _ = std::fs::remove_file(&self.path);
599    }
600}
601
602/// How often a blocked caller checks whether [`MarkerLock`] has freed up.
603const LOCK_POLL: Duration = Duration::from_secs(5);
604
605/// How long a caller waits for a contended lock before giving up on this
606/// merge's own judgement entirely.
607///
608/// A first version of this gate gave up the instant the lock was taken,
609/// which meant a change landing while another host's decision call was
610/// still running was never judged at all - not even recorded as pending,
611/// not escalated later, just dropped. The lock is only ever held for one
612/// `after_merge` call, so waiting past it is what lets that call's own
613/// decision reach [`pending_action`] against a marker the other side just
614/// finished writing, instead of finding nothing to check against. Set just
615/// under [`LOCK_STALE_AFTER`]: a lock still held this long after that point
616/// is reclaimed as abandoned rather than waited on further.
617const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
618
619/// Wait for [`MarkerLock`] to free up, polling rather than blocking forever.
620/// `Ok(None)` means the ceiling passed with the lock still held.
621async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
622    wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
623}
624
625/// [`wait_for_marker_lock`] with the poll interval and ceiling as parameters,
626/// so the retry behaviour is testable without a test actually waiting out
627/// [`LOCK_WAIT_CEILING`].
628async fn wait_for_marker_lock_with(
629    marker: &Path,
630    poll: Duration,
631    ceiling: Duration,
632) -> Result<Option<MarkerLock>> {
633    let mut waited = Duration::ZERO;
634    loop {
635        if let Some(lock) = MarkerLock::acquire(marker)? {
636            return Ok(Some(lock));
637        }
638        if waited >= ceiling {
639            return Ok(None);
640        }
641        tokio::time::sleep(poll).await;
642        waited += poll;
643    }
644}
645
646/// Which digit differs between `from` and `to`? `None` when they are equal.
647///
648/// Used to recover the level a pull request found by [`find_open_release_pr`]
649/// was judged at: the forge has the resulting version (in the branch name and
650/// the title) but not the digit an agent chose to get there, and this is the
651/// one other host-independent fact every host can compute the same way from
652/// it.
653fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
654    if to.major != from.major {
655        Some(BumpLevel::Major)
656    } else if to.minor != from.minor {
657        Some(BumpLevel::Minor)
658    } else if to.patch != from.patch {
659        Some(BumpLevel::Patch)
660    } else {
661        None
662    }
663}
664
665/// Parse `gh pr list --state open --json url,headRefName` output, returning
666/// the first pull request whose branch is one of this module's own. No I/O.
667fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
668    #[derive(Deserialize)]
669    struct Pr {
670        url: String,
671        #[serde(rename = "headRefName")]
672        head_ref_name: String,
673    }
674    let list: Vec<Pr> =
675        serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
676    Ok(list
677        .into_iter()
678        .find(|p| p.head_ref_name.starts_with("chore/release-v"))
679        .map(|p| (p.head_ref_name, p.url)))
680}
681
682/// Ask the forge directly whether a release bump is already open, for a host
683/// that has never seen it.
684///
685/// [`MarkerLock`] and the marker file only ever coordinate *this* host - a
686/// marker written on one machine is not visible to `run::home()` on another,
687/// so two hosts landing runs against the same repository at the same time
688/// can each read "nothing pending" and open a competing pull request no
689/// local lock can see. `gh pr list` is the one place every host actually
690/// shares a view, so it is consulted whenever this host's own marker says
691/// there is nothing pending, before a fresh decision is allowed to open a
692/// second pull request. This narrows the race to the gap between this call
693/// and whichever host's `gh pr create` lands first - it does not close it -
694/// because turning that into a real distributed lock would need coordination
695/// this crate has no dependency for.
696async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
697    let out = tokio::process::Command::new("gh")
698        .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
699        .current_dir(repo)
700        .quiet()
701        .stdin(std::process::Stdio::null())
702        .output()
703        .await
704        .context("spawn gh pr list")?;
705    if !out.status.success() {
706        bail!(
707            "gh pr list: {}",
708            String::from_utf8_lossy(&out.stderr).trim()
709        );
710    }
711    parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
712}
713
714/// After a merge lands, ask an agent how big the change was and open a
715/// release bump sized to it.
716///
717/// Best-effort by construction, the same way `clean::fold_due` treats one
718/// run's fold failure: this runs after the merge the run exists to produce
719/// has already succeeded, so a failure here (the decision call, `gh`,
720/// `cargo`) must never turn a landed run into a failed one. The caller logs
721/// whatever this returns and moves on.
722pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
723    after_merge_at(state, pr_url, None).await
724}
725
726/// Does the base branch carry a `Cargo.toml` at its root? Release bumps read
727/// and rewrite that file, so its absence means "not a Rust repository", which
728/// is not a fault. `ls-tree` rather than `cat-file -e`, so an unresolvable
729/// ref (a failed fetch, a misconfigured base) stays an error instead of being
730/// reported as a non-Rust repository.
731async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
732    let out = git::git(
733        repo,
734        &[
735            "ls-tree",
736            "--name-only",
737            &format!("{remote}/{base}"),
738            "--",
739            "Cargo.toml",
740        ],
741    )
742    .await
743    .context("look for Cargo.toml on the base branch")?;
744    Ok(!out.trim().is_empty())
745}
746
747/// [`after_merge`] with an optional magi home, so tests can point the
748/// marker and its lock at a scratch directory.
749async fn after_merge_at(state: &mut RunState, pr_url: &str, home: Option<&Path>) -> Result<()> {
750    if !state.config.merge.release_bump {
751        return Ok(());
752    }
753    let Some(winner) = state.winner().cloned() else {
754        return Ok(());
755    };
756    let repo = state.repo.clone();
757    let base = state.base_branch.clone();
758    let remote = state.config.merge.remote.clone();
759
760    let files = git::changed_files(&winner.worktree, &base, &winner.branch)
761        .await
762        .unwrap_or_default();
763    if is_release_only(&files) {
764        state.event(
765            "bump",
766            "the merged change touches only the release manifest; not treating it as a trigger",
767        );
768        return Ok(());
769    }
770
771    // Outside the lock, and before anything that assumes a Rust manifest.
772    // Fetch first so a stale remote-tracking ref cannot misjudge the base.
773    git::fetch(&repo, &remote, &base).await.ok();
774    if !base_has_cargo_toml(&repo, &remote, &base).await? {
775        state.event(
776            "bump",
777            "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping",
778        );
779        return Ok(());
780    }
781
782    let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
783    // Held for the rest of this function: the whole read-decide-write
784    // sequence below is the critical section two `after_merge` calls landing
785    // within the same window must not both be inside at once. See
786    // `MarkerLock`'s own doc for why a second, unrelated bump PR is what
787    // that race produces without it, and `wait_for_marker_lock`'s for why
788    // this waits rather than giving up the instant it is contended.
789    let Some(_lock) = wait_for_marker_lock(&marker).await? else {
790        state.event(
791            "bump",
792            "another release bump decision held the lock past the wait ceiling; skipping this round",
793        );
794        return Ok(());
795    };
796
797    git::fetch(&repo, &remote, &base).await.ok();
798    let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
799        .await
800        .context("read Cargo.toml from the base branch")?;
801    let base_version = current_version(&cargo_toml)?;
802
803    let mut pending = read_marker(&marker);
804    if let Some(p) = &pending {
805        match coalesce(Some(p), &base_version)? {
806            Coalesce::Proceed => {
807                // Landed, or superseded by a manual bump: free for a fresh
808                // decision.
809                clear_marker(&marker);
810                pending = None;
811            }
812            Coalesce::Skip { target_version } => {
813                if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
814                    state.event(
815                        "bump",
816                        format!(
817                            "the pending release bump to v{target_version} ({}) is no longer \
818                             open; treating it as abandoned",
819                            p.pr_url
820                        ),
821                    );
822                    clear_marker(&marker);
823                    pending = None;
824                }
825                // Otherwise still genuinely open: fall through and ask the
826                // same question this merge would get on a fresh path, so a
827                // more severe change landing while it waits can escalate it
828                // instead of being silently absorbed at the wrong digit.
829            }
830        }
831    }
832
833    if pending.is_none() {
834        // This host's own marker has nothing to say - check the forge itself
835        // before trusting that to mean a fresh pull request is safe to open.
836        // See `find_open_release_pr`'s own doc for what this does and does
837        // not close.
838        if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
839            && let Some(target) = branch
840                .strip_prefix("chore/release-v")
841                .and_then(|v| Version::parse(v).ok())
842        {
843            let base_parsed = Version::parse(&base_version)?;
844            if target > base_parsed
845                && let Some(level) = level_between(base_parsed, target)
846            {
847                let adopted = PendingBump {
848                    target_version: target.to_string(),
849                    level,
850                    branch,
851                    pr_url: url,
852                };
853                // Best-effort: worst case this host asks the forge again
854                // next time instead of finding its own record of it.
855                let _ = write_marker(&marker, &adopted);
856                pending = Some(adopted);
857            }
858        }
859    }
860
861    let title = pr_title(&repo, pr_url).await.unwrap_or_default();
862    let subject = land::merge_subject(&title, &state.instruction);
863    let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
864        .await
865        .unwrap_or_default();
866    let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
867
868    // No dedicated role for this one-off decision. Borrows `[roles] chatter`
869    // - the nearest surviving single-agent-seat preference - rather than
870    // falling straight to `agent::pick`'s own default order, so an operator
871    // who has already named a preferred seat there is not silently
872    // overridden for this decision too.
873    let spec: AgentSpec = agent::pick(
874        &state.config.agents,
875        state.config.roles.chatter.as_deref(),
876        &agent::installed,
877    )
878    .context("choose an agent for the release-bump decision")?;
879    let mut seat = SeatState::new("bump", &spec.id, state.seed);
880    let artifacts = agent::artifacts_dir(&state.dir());
881    let out = agent::invoke(
882        &spec,
883        &mut seat,
884        &Invocation {
885            cwd: &repo,
886            prompt: &prompt,
887            timeout: DECISION_TIMEOUT,
888            // The decision reads a diffstat and writes a verdict; it must
889            // never touch a file.
890            allow_write: false,
891            sessions: false,
892            artifacts: &artifacts,
893            stem: "bump-decision",
894            run: &state.id,
895            node: "bump",
896            cache_dir: state.config.cache_dir().as_deref(),
897            attachments: &[],
898        },
899    )
900    .await
901    .context("ask an agent how big the merged change was")?;
902    if !out.usable() {
903        bail!(
904            "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
905            out.exit_code,
906            out.timed_out
907        );
908    }
909    let decision = parse_decision(&out.text).context("parse the release-bump decision")?;
910
911    if let Some(p) = pending {
912        return match pending_action(p.level, decision.level) {
913            PendingAction::AlreadyCovered => {
914                state.event(
915                    "bump",
916                    format!(
917                        "a release bump to v{} ({}) already covers at least a {} change; not \
918                         opening another",
919                        p.target_version,
920                        p.pr_url,
921                        decision.level.as_str()
922                    ),
923                );
924                Ok(())
925            }
926            PendingAction::Escalate => {
927                escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
928            }
929        };
930    }
931
932    let next = Version::parse(&base_version)?
933        .bump(decision.level)
934        .to_string();
935    let branch = format!("chore/release-v{next}");
936    let worktree = state.dir().join("bump");
937    git::worktree_remove(&repo, &worktree).await.ok();
938    git::worktree_add_branch(&repo, &worktree, &branch, &format!("{remote}/{base}"))
939        .await
940        .context("create the release-bump worktree")?;
941    let opened = open_bump_pr(state, &worktree, &branch, &next, &decision, pr_url).await;
942    // Throwaway either way: nothing downstream reads this worktree, and a
943    // release worktree left behind after a failed attempt would collide with
944    // the next one this same run tries.
945    git::worktree_remove(&repo, &worktree).await.ok();
946    let (pr_url_opened, outcome) = opened?;
947    let (automerge_warning, merged_detail) = match outcome {
948        AutomergeOutcome::Enabled => (None, None),
949        AutomergeOutcome::MergedDirectly { detail } => (None, Some(detail)),
950        AutomergeOutcome::Failed { reason } => (Some(reason), None),
951    };
952
953    // The pull request exists on the forge the moment `open_bump_pr` returns
954    // its URL, regardless of what happens next - so the event that names it
955    // is unconditional, and a marker write failing (a full disk, a missing
956    // `home/bump` directory) is reported as its own warning rather than
957    // swallowing that URL entirely the way propagating it with `?` would.
958    // `find_open_release_pr` is the fallback if this leaves no local record:
959    // the next merge that finds no marker still finds this pull request on
960    // the forge before opening a second one.
961    let marker_write = write_marker(
962        &marker,
963        &PendingBump {
964            target_version: next.clone(),
965            level: decision.level,
966            branch,
967            pr_url: pr_url_opened.clone(),
968        },
969    );
970    state.event(
971        "bump",
972        format!(
973            "opened a {} release bump to v{next} ({}): {pr_url_opened}",
974            decision.level.as_str(),
975            decision.reason
976        ),
977    );
978    if let Err(e) = marker_write {
979        state.event(
980            "bump",
981            format!(
982                "could not record the pending release bump marker for v{next}: {e:#}; a later \
983                 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
984                 the forge either"
985            ),
986        );
987    }
988    state.release_bump = Some(run::ReleaseBump {
989        pr_url: Some(pr_url_opened.clone()),
990        version: Some(next.clone()),
991        automerge_enabled: automerge_warning.is_none() && merged_detail.is_none(),
992        merged_directly: merged_detail.is_some(),
993        ..run::ReleaseBump::default()
994    });
995    if let Some(detail) = merged_detail {
996        // Merged already: a pending marker would make the next merge wait on
997        // a pull request that is gone.
998        clear_marker(&marker);
999        state.event("bump", format!("merged v{next} directly: {detail}"));
1000    }
1001    if let Some(warning) = automerge_warning {
1002        state.event(
1003            "bump",
1004            format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
1005        );
1006        report_problem(state, Some(&pr_url_opened), Some(&next), &warning).await;
1007    }
1008    Ok(())
1009}
1010
1011/// The node name a post-merge notice is filed under. [`ask::Questions::settle_run`]
1012/// exempts it: the run it belongs to is `Merged` by definition, and abandoning
1013/// the notice on that ground would erase it the moment it is filed.
1014pub const NOTICE_NODE: &str = "release-bump";
1015/// Answer: the owner dealt with the release pull request themselves.
1016const NOTICE_DONE: &str = "merged by hand";
1017/// Answer: seen, nothing to do.
1018const NOTICE_DISMISS: &str = "dismiss";
1019
1020/// What to tell the operator to do about a refused `gh pr merge --auto`.
1021///
1022/// Keys on the wording GitHub is known to use when the base branch has no
1023/// required status checks (`enablePullRequestAutoMerge` / "protected branch
1024/// rules"); anything else falls back to the generic advice, with the reason
1025/// carried verbatim next to it.
1026fn automerge_hint(reason: &str) -> &'static str {
1027    let r = reason.to_lowercase();
1028    if is_clean_status_refusal(reason) {
1029        "merge the release pull request by hand; CI is already green"
1030    } else if r.contains("enablepullrequestautomerge") || r.contains("protected branch rules") {
1031        "merge the release pull request by hand, and enable branch protection with required \
1032         status checks on the base branch so automerge can work next time"
1033    } else {
1034        "merge the release pull request by hand"
1035    }
1036}
1037
1038/// The comment left on the release pull request itself.
1039fn automerge_failure_comment(reason: &str) -> String {
1040    format!(
1041        "magi could not enable automerge on this pull request: {reason}\n\n\
1042         Action required: {}. Until then the release does not happen.",
1043        automerge_hint(reason)
1044    )
1045}
1046
1047/// Record a post-merge problem on the run and file the operator-facing
1048/// notice, without touching the forge. Returns the comment body meant for the
1049/// release pull request when there is one.
1050///
1051/// Split from [`report_problem`] so the state, the question and the wording
1052/// can be asserted without a `gh`.
1053fn surface_problem(
1054    state: &mut RunState,
1055    store: &ask::Questions,
1056    pr_url: Option<&str>,
1057    version: Option<&str>,
1058    reason: &str,
1059) -> Result<(ask::Question, Option<String>)> {
1060    let action = if pr_url.is_some() {
1061        automerge_hint(reason).to_owned()
1062    } else {
1063        "the release bump did not run; open the release pull request by hand".to_owned()
1064    };
1065    let record = state.release_bump.get_or_insert_with(Default::default);
1066    record.pr_url = pr_url.map(str::to_owned).or(record.pr_url.take());
1067    record.version = version.map(str::to_owned).or(record.version.take());
1068    record.automerge_enabled = false;
1069    record.problem = Some(reason.to_owned());
1070    record.action_required = Some(action.clone());
1071
1072    let summary = match pr_url {
1073        Some(url) => format!("Release PR needs a human: {url}"),
1074        None => "Release bump did not run".to_owned(),
1075    };
1076    let detail = format!(
1077        "Run {} merged, but the release step after it failed.\n\n{reason}\n\n\
1078         Action required: {action}.",
1079        state.id
1080    );
1081    let mut q = ask::Question::new(
1082        state.id.clone(),
1083        NOTICE_NODE.to_owned(),
1084        "bump".to_owned(),
1085        summary,
1086        detail,
1087        vec![NOTICE_DONE.to_owned(), NOTICE_DISMISS.to_owned()],
1088    );
1089    store.put(&mut q).context("file the release-bump notice")?;
1090    state.event(
1091        "bump",
1092        format!("needs attention: notice {} filed - {action}", q.short()),
1093    );
1094    let comment = pr_url.map(|_| automerge_failure_comment(reason));
1095    Ok((q, comment))
1096}
1097
1098/// Make a post-merge problem visible: record it, comment on the release pull
1099/// request, and raise a notice through the question queue and the configured
1100/// notifier. Every step is best-effort - a failed comment or webhook is an
1101/// event, never a reason to lose the record or the run.
1102pub async fn report_problem(
1103    state: &mut RunState,
1104    pr_url: Option<&str>,
1105    version: Option<&str>,
1106    reason: &str,
1107) {
1108    match surface_problem(state, &ask::Questions::open(), pr_url, version, reason) {
1109        Ok((q, comment)) => {
1110            if let (Some(url), Some(body)) = (pr_url, comment)
1111                && let Err(e) = gh_pr_comment(
1112                    &state.repo,
1113                    url,
1114                    &crate::scrub::scrub(&body, &crate::scrub::Identity::current()),
1115                )
1116                .await
1117            {
1118                state.event("bump", format!("could not comment on {url}: {e:#}"));
1119            }
1120            if let Err(e) = ask::notify(&state.config.notify, &q).await {
1121                tracing::warn!(
1122                    "could not notify about release-bump notice {}: {e:#}",
1123                    q.short()
1124                );
1125            }
1126        }
1127        Err(e) => state.event("bump", format!("could not raise a notice: {e:#}")),
1128    }
1129}
1130
1131async fn gh_pr_comment(cwd: &Path, pr_url: &str, body: &str) -> Result<()> {
1132    let out = tokio::process::Command::new("gh")
1133        .args(["pr", "comment", pr_url, "--body", body])
1134        .current_dir(cwd)
1135        .quiet()
1136        .stdin(std::process::Stdio::null())
1137        .output()
1138        .await
1139        .context("spawn gh pr comment")?;
1140    if out.status.success() {
1141        Ok(())
1142    } else {
1143        bail!(
1144            "gh pr comment: {}",
1145            String::from_utf8_lossy(&out.stderr).trim()
1146        )
1147    }
1148}
1149
1150/// Bump an already-open release pull request further, because a change more
1151/// severe than what it already covers landed while it waited on CI or
1152/// automerge - see [`pending_action`].
1153///
1154/// Adds a second commit rather than rewriting the first: `gh pr merge
1155/// --squash` prefers a single commit's own message over the pull request's
1156/// title, and falls back to the title once there is more than one commit -
1157/// so the title is what is kept honest here, via `gh pr edit`.
1158async fn escalate_pending(
1159    state: &mut RunState,
1160    repo: &Path,
1161    remote: &str,
1162    pending: &PendingBump,
1163    decision: &BumpDecision,
1164    base_version: &str,
1165    marker: &Path,
1166) -> Result<()> {
1167    let next = Version::parse(base_version)?
1168        .bump(decision.level)
1169        .to_string();
1170    let worktree = state.dir().join("bump");
1171    git::worktree_remove(repo, &worktree).await.ok();
1172    let checked_out = git::git_raw(
1173        repo,
1174        &[
1175            "worktree",
1176            "add",
1177            "--force",
1178            &worktree.to_string_lossy(),
1179            &pending.branch,
1180        ],
1181    )
1182    .await?;
1183    if !checked_out.ok() {
1184        bail!(
1185            "checking out the pending release branch {} failed: {}",
1186            pending.branch,
1187            checked_out.stderr
1188        );
1189    }
1190
1191    // Only the substantive change - the commit landing on the remote branch
1192    // - has to succeed for the escalation to have happened at all. Anything
1193    // after the push is a follow-up, not a precondition: the branch already
1194    // carries the new version whether or not it succeeds.
1195    let pushed: Result<()> = async {
1196        let cargo_toml_path = worktree.join("Cargo.toml");
1197        let toml = tokio::fs::read_to_string(&cargo_toml_path)
1198            .await
1199            .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1200        let rewritten = rewrite_cargo_version(&toml, &next)?;
1201        tokio::fs::write(&cargo_toml_path, rewritten)
1202            .await
1203            .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1204        sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1205        let committed = git::commit_all(
1206            &worktree,
1207            &format!(
1208                "chore: release v{next} (supersedes v{})",
1209                pending.target_version
1210            ),
1211        )
1212        .await
1213        .context("commit the escalated version bump")?;
1214        if !committed {
1215            bail!("escalating the version bump left nothing to commit");
1216        }
1217        let pushed = git::push(&worktree, remote, &pending.branch).await?;
1218        if !pushed.ok() {
1219            bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1220        }
1221        Ok(())
1222    }
1223    .await;
1224    if let Err(e) = pushed {
1225        git::worktree_remove(repo, &worktree).await.ok();
1226        return Err(e);
1227    }
1228
1229    // The commit is on the remote branch now regardless of what happens
1230    // below - the title edit is cosmetic, and the marker and the event must
1231    // both reflect the real, already-pushed state even if it fails.
1232    let title_warning = match gh_pr_edit_title(
1233        &worktree,
1234        &pending.pr_url,
1235        &crate::scrub::scrub(
1236            &format!("chore: release v{next} ({} bump)", decision.level.as_str()),
1237            &crate::scrub::Identity::current(),
1238        ),
1239    )
1240    .await
1241    {
1242        Ok(()) => None,
1243        Err(e) => Some(e.to_string()),
1244    };
1245    git::worktree_remove(repo, &worktree).await.ok();
1246
1247    let marker_write = write_marker(
1248        marker,
1249        &PendingBump {
1250            target_version: next.clone(),
1251            level: decision.level,
1252            branch: pending.branch.clone(),
1253            pr_url: pending.pr_url.clone(),
1254        },
1255    );
1256    state.event(
1257        "bump",
1258        format!(
1259            "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1260            pending.target_version,
1261            decision.level.as_str(),
1262            decision.reason,
1263            pending.pr_url
1264        ),
1265    );
1266    if let Err(e) = marker_write {
1267        state.event(
1268            "bump",
1269            format!(
1270                "could not update the pending release bump marker to v{next}: {e:#}; a later \
1271                 merge may misjudge whether it is already covered"
1272            ),
1273        );
1274    }
1275    if let Some(warning) = title_warning {
1276        state.event(
1277            "bump",
1278            format!(
1279                "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1280                 subject may still read the superseded version",
1281                pending.pr_url
1282            ),
1283        );
1284    }
1285    Ok(())
1286}
1287
1288/// Edit the version, let the lockfile follow, commit, push, and open the pull
1289/// request with automerge enabled. Returns the opened pull request's URL and,
1290/// when enabling automerge itself failed, a note of why - the pull request
1291/// still exists on the forge either way, and the caller must not lose track
1292/// of its URL over that failure alone.
1293async fn open_bump_pr(
1294    state: &RunState,
1295    worktree: &Path,
1296    branch: &str,
1297    next_version: &str,
1298    decision: &BumpDecision,
1299    source_pr_url: &str,
1300) -> Result<(String, AutomergeOutcome)> {
1301    let cargo_toml_path = worktree.join("Cargo.toml");
1302    let toml = tokio::fs::read_to_string(&cargo_toml_path)
1303        .await
1304        .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1305    let rewritten = rewrite_cargo_version(&toml, next_version)?;
1306    tokio::fs::write(&cargo_toml_path, rewritten)
1307        .await
1308        .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1309
1310    sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
1311
1312    let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
1313        .await
1314        .context("commit the version bump")?;
1315    if !committed {
1316        bail!("the version bump left nothing to commit");
1317    }
1318
1319    let remote = state.config.merge.remote.clone();
1320    let pushed = git::push(worktree, &remote, branch).await?;
1321    if !pushed.ok() {
1322        bail!("pushing {branch} failed: {}", pushed.stderr);
1323    }
1324
1325    let (title, body) = release_pr(
1326        decision.level.as_str(),
1327        &decision.reason,
1328        next_version,
1329        &state.id,
1330        source_pr_url,
1331    );
1332    let who = crate::scrub::Identity::current();
1333    let (title, body) = (
1334        crate::scrub::scrub(&title, &who),
1335        crate::scrub::scrub(&body, &who),
1336    );
1337    let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
1338    let outcome = match gh_enable_automerge(worktree, &url).await {
1339        Ok(()) => AutomergeOutcome::Enabled,
1340        Err(e) => {
1341            let reason = e.to_string();
1342            if is_clean_status_refusal(&reason) {
1343                gh_merge_directly(worktree, &url, &title, reason).await
1344            } else {
1345                AutomergeOutcome::Failed { reason }
1346            }
1347        }
1348    };
1349    Ok((url, outcome))
1350}
1351
1352/// What became of the release pull request's path to `main`.
1353#[derive(Debug, Clone, PartialEq, Eq)]
1354enum AutomergeOutcome {
1355    /// Automerge is armed; CI green will merge it.
1356    Enabled,
1357    /// CI beat us to it, so magi merged the pull request itself.
1358    MergedDirectly { detail: String },
1359    /// Neither worked; a human has to merge it.
1360    Failed { reason: String },
1361}
1362
1363/// Is this GitHub's refusal to arm automerge on a pull request that is already
1364/// mergeable? Automerge can only be enabled while something is still pending,
1365/// so a fast CI that finishes first gets `Pull request is in clean status`.
1366/// Both fragments are required so an unrelated "clean status" wording or a
1367/// branch-protection refusal does not trigger a direct merge.
1368fn is_clean_status_refusal(reason: &str) -> bool {
1369    let r = reason.to_lowercase();
1370    r.contains("is in clean status") && r.contains("enablepullrequestautomerge")
1371}
1372
1373/// The direct merge, in the same shape [`land::merge_argv`] uses but addressed
1374/// by URL: squash under the pull request's own title, delete the branch.
1375fn bump_merge_argv(pr_url: &str, subject: &str) -> Vec<String> {
1376    [
1377        "pr",
1378        "merge",
1379        pr_url,
1380        "--squash",
1381        "--delete-branch",
1382        "--subject",
1383        subject,
1384    ]
1385    .map(str::to_owned)
1386    .to_vec()
1387}
1388
1389/// Decide what a direct merge amounted to. No I/O. A zero exit is a merge; a
1390/// non-zero one is judged by the forge (`after`), never by the exit code, and
1391/// an unreadable forge is not evidence of success.
1392fn resolve_direct_merge(
1393    refusal: &str,
1394    argv: &[String],
1395    merge_ok: bool,
1396    stderr: &str,
1397    after: Option<land::PrLifecycle>,
1398) -> AutomergeOutcome {
1399    if merge_ok {
1400        return AutomergeOutcome::MergedDirectly {
1401            detail: format!("automerge was refused ({refusal}); gh {}", argv.join(" ")),
1402        };
1403    }
1404    match land::merged_after_all(argv, stderr, after) {
1405        Some(m) => AutomergeOutcome::MergedDirectly { detail: m.detail },
1406        None => AutomergeOutcome::Failed {
1407            reason: format!("{refusal}; merging directly failed too: {}", stderr.trim()),
1408        },
1409    }
1410}
1411
1412/// Merge the pull request directly after automerge was refused as clean.
1413/// Every failure lands in [`AutomergeOutcome::Failed`] so the caller keeps the
1414/// warning-and-comment path.
1415async fn gh_merge_directly(
1416    cwd: &Path,
1417    pr_url: &str,
1418    subject: &str,
1419    refusal: String,
1420) -> AutomergeOutcome {
1421    let argv = bump_merge_argv(pr_url, subject);
1422    let out = match tokio::process::Command::new("gh")
1423        .args(&argv)
1424        .current_dir(cwd)
1425        .quiet()
1426        .stdin(std::process::Stdio::null())
1427        .output()
1428        .await
1429    {
1430        Ok(o) => o,
1431        Err(e) => {
1432            return AutomergeOutcome::Failed {
1433                reason: format!("{refusal}; could not spawn gh to merge directly: {e}"),
1434            };
1435        }
1436    };
1437    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
1438    // jj keeps HEAD detached, so `--delete-branch` exits non-zero after the
1439    // merge has happened; ask the forge.
1440    let after = if out.status.success() {
1441        None
1442    } else {
1443        land::lifecycle(cwd, pr_url).await.ok()
1444    };
1445    resolve_direct_merge(&refusal, &argv, out.status.success(), &stderr, after)
1446}
1447
1448/// Run `cargo build` so `Cargo.lock` follows the version bump, the same step
1449/// `AGENTS.md`'s hand-driven release recipe calls for.
1450///
1451/// Not exercised by a test: it is the one step in this module that runs the
1452/// real `cargo`, which the constraints on this change rule out doing from a
1453/// test (no network, no writing outside a throwaway worktree the test itself
1454/// does not have).
1455async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
1456    let mut cmd = tokio::process::Command::new("cargo");
1457    cmd.arg("build").current_dir(worktree).quiet();
1458    if let Some(dir) = cache_dir {
1459        cmd.env("CARGO_TARGET_DIR", dir);
1460    }
1461    let out = cmd
1462        .stdin(std::process::Stdio::null())
1463        .output()
1464        .await
1465        .context("spawn cargo build")?;
1466    if !out.status.success() {
1467        bail!(
1468            "cargo build failed while syncing Cargo.lock: {}",
1469            String::from_utf8_lossy(&out.stderr).trim()
1470        );
1471    }
1472    Ok(())
1473}
1474
1475/// Title and body of a release bump pull request. Fixed English whatever
1476/// `[graph] language` says: it lands on GitHub. Pure so a test can hold it to
1477/// that. (`reason` comes from the decision seat, which the prompt tells to
1478/// write English.)
1479fn release_pr(
1480    level: &str,
1481    reason: &str,
1482    next_version: &str,
1483    run_id: &str,
1484    source_pr_url: &str,
1485) -> (String, String) {
1486    let title = format!("chore: release v{next_version} ({level} bump)");
1487    let body = format!(
1488        "## Background\n\n\
1489         A change that was just merged is a `{level}` change, so the crate needs a new \
1490         release: {reason}\n\n\
1491         Triggered by magi run `{run_id}`, which landed {source}.\n\n\
1492         ## Change\n\n\
1493         Raises the package version to `v{next_version}` in `Cargo.toml`, with \
1494         `Cargo.lock` following it. Nothing else changes.\n\n\
1495         ## Risk\n\n\
1496         Version-bump-only, so there is nothing here for a reviewer to find. Merging \
1497         it starts the release pipeline (auto-tag, then the release workflow).",
1498        source = source_pr_url,
1499    );
1500    (title, body)
1501}
1502
1503/// The merged pull request's title, for [`land::merge_subject`].
1504async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
1505    let out = tokio::process::Command::new("gh")
1506        .args(["pr", "view", pr_url, "--json", "title"])
1507        .current_dir(repo)
1508        .quiet()
1509        .stdin(std::process::Stdio::null())
1510        .output()
1511        .await
1512        .context("spawn gh pr view")?;
1513    if !out.status.success() {
1514        bail!(
1515            "gh pr view {pr_url}: {}",
1516            String::from_utf8_lossy(&out.stderr).trim()
1517        );
1518    }
1519    #[derive(Deserialize)]
1520    struct Title {
1521        title: String,
1522    }
1523    let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
1524        .context("parse `gh pr view --json title` output")?;
1525    Ok(parsed.title)
1526}
1527
1528async fn gh_pr_create(
1529    cwd: &Path,
1530    base: &str,
1531    head: &str,
1532    title: &str,
1533    body: &str,
1534) -> Result<String> {
1535    let out = tokio::process::Command::new("gh")
1536        .args([
1537            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
1538        ])
1539        .current_dir(cwd)
1540        .quiet()
1541        .stdin(std::process::Stdio::null())
1542        .output()
1543        .await
1544        .context("spawn gh pr create")?;
1545    if out.status.success() {
1546        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
1547    } else {
1548        bail!(
1549            "gh pr create: {}",
1550            String::from_utf8_lossy(&out.stderr).trim()
1551        )
1552    }
1553}
1554
1555/// Enable automerge, mirroring `AGENTS.md`'s `gh pr merge --auto --squash
1556/// --delete-branch`. Never `git tag`: `auto-tag.yml` mints the tag once this
1557/// merges, and a manual tag would collide with its push.
1558async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
1559    let out = tokio::process::Command::new("gh")
1560        .args([
1561            "pr",
1562            "merge",
1563            pr_url,
1564            "--auto",
1565            "--squash",
1566            "--delete-branch",
1567        ])
1568        .current_dir(cwd)
1569        .quiet()
1570        .stdin(std::process::Stdio::null())
1571        .output()
1572        .await
1573        .context("spawn gh pr merge --auto")?;
1574    if out.status.success() {
1575        Ok(())
1576    } else {
1577        bail!(
1578            "gh pr merge --auto: {}",
1579            String::from_utf8_lossy(&out.stderr).trim()
1580        )
1581    }
1582}
1583
1584/// Rewrite a pull request's title, used when [`escalate_pending`] adds a
1585/// second commit: `gh pr merge --squash` only prefers a single commit's own
1586/// message over the title, so once there are two the title is what lands.
1587async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
1588    let out = tokio::process::Command::new("gh")
1589        .args(["pr", "edit", pr_url, "--title", title])
1590        .current_dir(cwd)
1591        .quiet()
1592        .stdin(std::process::Stdio::null())
1593        .output()
1594        .await
1595        .context("spawn gh pr edit")?;
1596    if out.status.success() {
1597        Ok(())
1598    } else {
1599        bail!(
1600            "gh pr edit --title: {}",
1601            String::from_utf8_lossy(&out.stderr).trim()
1602        )
1603    }
1604}
1605
1606#[cfg(test)]
1607mod tests {
1608    use super::*;
1609
1610    #[test]
1611    fn github_facing_bump_text_is_english() {
1612        let (title, body) =
1613            release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
1614        assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
1615        assert_eq!(title, "chore: release v0.37.0 (minor bump)");
1616        assert!(
1617            body.contains("## Background") && body.contains("## Change"),
1618            "{body}"
1619        );
1620        let p = decision_prompt("s", "i", "d", &[], "0.36.5");
1621        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
1622    }
1623    use crate::config::Config;
1624    use crate::land::PrLifecycle;
1625
1626    /// `[merge] release_bump = false` must short-circuit before any I/O -
1627    /// `after_merge` is reached from a live run with a real repo and a real
1628    /// `gh`, so the disabled case is asserted with a `RunState` that would
1629    /// fail loudly (an unresolvable `/no/such/repo`) the moment anything past
1630    /// the flag check tried to touch it.
1631    #[tokio::test]
1632    async fn a_disabled_config_does_nothing() {
1633        let config = Config {
1634            merge: crate::config::Merge {
1635                release_bump: false,
1636                ..crate::config::Merge::default()
1637            },
1638            ..Config::default()
1639        };
1640        let mut state = RunState::new(
1641            PathBuf::from("/no/such/repo"),
1642            "main".to_owned(),
1643            "0000000000000000000000000000000000000000".to_owned(),
1644            "irrelevant".to_owned(),
1645            config,
1646        );
1647        after_merge(&mut state, "https://example.invalid/pull/1")
1648            .await
1649            .expect("a disabled config must return Ok without touching anything");
1650        assert!(
1651            state.events.is_empty(),
1652            "nothing should happen at all, not even a logged event"
1653        );
1654    }
1655
1656    /// A bare `origin` plus a clone of it, `main` pushed with `files`.
1657    async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
1658        let dir = tempfile::tempdir().unwrap();
1659        let origin = dir.path().join("origin.git");
1660        let repo = dir.path().join("repo");
1661        let o = origin.to_string_lossy().into_owned();
1662        git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
1663            .await
1664            .unwrap();
1665        tokio::fs::create_dir_all(&repo).await.unwrap();
1666        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
1667        git::git(&repo, &["config", "user.name", "test"])
1668            .await
1669            .unwrap();
1670        git::git(&repo, &["config", "user.email", "test@example.com"])
1671            .await
1672            .unwrap();
1673        for (name, body) in files {
1674            tokio::fs::write(repo.join(name), body).await.unwrap();
1675        }
1676        git::git(&repo, &["add", "-A"]).await.unwrap();
1677        git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
1678        git::git(&repo, &["remote", "add", "origin", &o])
1679            .await
1680            .unwrap();
1681        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
1682        (dir, repo)
1683    }
1684
1685    #[tokio::test]
1686    async fn base_has_cargo_toml_tells_rust_from_non_rust() {
1687        let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
1688        assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
1689        let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
1690        assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
1691        // An unresolvable ref is a fault, not "not Rust".
1692        assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
1693    }
1694
1695    #[tokio::test]
1696    async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
1697        let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
1698        let home = tempfile::tempdir().unwrap();
1699        let mut state = RunState::new(
1700            repo.clone(),
1701            "main".to_owned(),
1702            "0000000000000000000000000000000000000000".to_owned(),
1703            "task".to_owned(),
1704            Config::default(),
1705        );
1706        state.candidates.push(crate::run::Candidate {
1707            index: 0,
1708            label: 'A',
1709            agent: "x".to_owned(),
1710            branch: "main".to_owned(),
1711            worktree: repo.clone(),
1712            summary: String::new(),
1713            stat: String::new(),
1714            files: 1,
1715            commits: 1,
1716            empty: false,
1717            failed: None,
1718            verified_noop: None,
1719            duration_ms: 0,
1720            folded: false,
1721        });
1722        state.tally = Some(
1723            serde_json::from_value(serde_json::json!({
1724                "first_choice": {}, "borda": {}, "winner": "A",
1725                "unanimous_initial": true, "deliberated": false,
1726                "changed_votes": 0, "unanimous_final": true,
1727            }))
1728            .unwrap(),
1729        );
1730        after_merge_at(
1731            &mut state,
1732            "https://example.invalid/pull/1",
1733            Some(home.path()),
1734        )
1735        .await
1736        .expect("a non-Rust repository is not an error");
1737        let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
1738        assert_eq!(bumps.len(), 1, "{:?}", state.events);
1739        assert_eq!(
1740            bumps[0].message,
1741            "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
1742        );
1743        assert!(
1744            std::fs::read_dir(home.path()).unwrap().next().is_none(),
1745            "no marker and no lock may be created"
1746        );
1747    }
1748
1749    const NO_RULES: &str = "gh pr merge --auto: GraphQL: Pull request Branch does not have \
1750                            required protected branch rules (enablePullRequestAutoMerge)";
1751
1752    fn merged_state() -> RunState {
1753        // `report::run` prints `state.dir()`; pin the global home like the
1754        // report tests do so nothing reaches the operator's real one.
1755        run::set_home(std::env::temp_dir().join("magi-report-test-home"));
1756        let mut s = RunState::new(
1757            PathBuf::from("/no/such/repo"),
1758            "main".to_owned(),
1759            "0000000000000000000000000000000000000000".to_owned(),
1760            "task".to_owned(),
1761            Config::default(),
1762        );
1763        s.status = RunStatus::Merged;
1764        s
1765    }
1766
1767    #[test]
1768    fn the_known_automerge_refusal_names_branch_protection() {
1769        assert!(automerge_hint(NO_RULES).contains("branch protection with required"));
1770        let other = automerge_hint("gh: network unreachable");
1771        assert!(!other.contains("branch protection"), "{other}");
1772        let body = automerge_failure_comment(NO_RULES);
1773        assert!(body.contains("enablePullRequestAutoMerge"), "{body}");
1774        assert!(body.contains("Action required"), "{body}");
1775    }
1776
1777    const CLEAN: &str = "gh pr merge --auto: GraphQL: Pull request Pull request is in clean \
1778                         status (enablePullRequestAutoMerge)";
1779
1780    #[test]
1781    fn clean_status_refusal_is_matched_narrowly() {
1782        assert!(is_clean_status_refusal(CLEAN));
1783        assert!(!is_clean_status_refusal(NO_RULES));
1784        assert!(!is_clean_status_refusal("gh: network unreachable"));
1785        assert!(!is_clean_status_refusal("Pull request is in clean status"));
1786        assert!(automerge_hint(CLEAN).contains("already green"));
1787    }
1788
1789    #[test]
1790    fn the_direct_merge_argv_matches_the_land_flags() {
1791        let a = bump_merge_argv("https://github.com/o/r/pull/9", "chore: release v1.0.0");
1792        let l = land::merge_argv(9, "chore: release v1.0.0");
1793        assert_eq!(a[..2], l[..2]);
1794        assert_eq!(a[3..], l[3..]);
1795        assert_eq!(a[2], "https://github.com/o/r/pull/9");
1796    }
1797
1798    #[test]
1799    fn a_direct_merge_is_judged_by_the_forge_not_the_exit_code() {
1800        let argv = bump_merge_argv("u", "t");
1801        let merged = |o: &AutomergeOutcome| matches!(o, AutomergeOutcome::MergedDirectly { .. });
1802        assert!(merged(&resolve_direct_merge(CLEAN, &argv, true, "", None)));
1803        let detached = "not on any branch";
1804        assert!(merged(&resolve_direct_merge(
1805            CLEAN,
1806            &argv,
1807            false,
1808            detached,
1809            Some(PrLifecycle::Merged)
1810        )));
1811        for after in [Some(PrLifecycle::Open), None] {
1812            let o = resolve_direct_merge(CLEAN, &argv, false, "boom", after);
1813            match o {
1814                AutomergeOutcome::Failed { reason } => {
1815                    assert!(
1816                        reason.contains("clean status") && reason.contains("boom"),
1817                        "{reason}"
1818                    )
1819                }
1820                other => panic!("expected Failed, got {other:?}"),
1821            }
1822        }
1823    }
1824
1825    #[test]
1826    fn a_directly_merged_bump_is_not_reported_as_pending_or_failed() {
1827        let mut state = merged_state();
1828        state.release_bump = Some(run::ReleaseBump {
1829            pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
1830            version: Some("1.0.0".to_owned()),
1831            merged_directly: true,
1832            ..run::ReleaseBump::default()
1833        });
1834        assert!(!state.needs_attention());
1835        let text = crate::report::run(&state);
1836        assert!(text.contains("merged directly"), "{text}");
1837        assert!(!text.contains("FAILED"), "{text}");
1838    }
1839
1840    #[test]
1841    fn an_automerge_failure_is_recorded_shown_and_filed_and_survives_settling() {
1842        let dir = tempfile::tempdir().unwrap();
1843        let store = ask::Questions::at(dir.path().join("questions"));
1844        let mut state = merged_state();
1845        let url = "https://github.com/o/r/pull/35";
1846
1847        let (q, comment) =
1848            surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
1849
1850        // The comment goes on the release PR and carries reason and fix.
1851        let comment = comment.expect("a PR was opened, so it gets a comment");
1852        assert!(comment.contains("branch protection"), "{comment}");
1853
1854        // The run is still Merged, but no longer reads as plain green.
1855        assert_eq!(state.status, RunStatus::Merged);
1856        assert!(state.needs_attention());
1857        let text = crate::report::run(&state);
1858        assert!(text.contains("release bump"), "{text}");
1859        assert!(text.contains("FAILED"), "{text}");
1860        assert!(text.contains(url), "{text}");
1861        assert!(text.contains("action required"), "{text}");
1862        assert!(crate::report::line(&state).contains("release needs a human"));
1863
1864        // A notice is open, and settling the merged run does not erase it.
1865        assert_eq!(q.node, NOTICE_NODE);
1866        assert_eq!(store.open_for(&state.id).len(), 1);
1867        assert_eq!(store.settle_run(&state.id, RunStatus::Merged).unwrap(), 0);
1868        assert!(store.get(&q.id).unwrap().status.open());
1869    }
1870
1871    #[test]
1872    fn a_bump_that_never_ran_is_surfaced_without_a_pr_comment() {
1873        let dir = tempfile::tempdir().unwrap();
1874        let store = ask::Questions::at(dir.path().join("questions"));
1875        let mut state = merged_state();
1876        let (_, comment) = surface_problem(&mut state, &store, None, None, "no agent").unwrap();
1877        assert!(comment.is_none());
1878        assert!(state.needs_attention());
1879    }
1880
1881    #[test]
1882    fn version_parses_and_bumps_each_digit() {
1883        let v = Version::parse("0.4.0").unwrap();
1884        assert_eq!(
1885            v,
1886            Version {
1887                major: 0,
1888                minor: 4,
1889                patch: 0
1890            }
1891        );
1892
1893        assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
1894        assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
1895        assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
1896    }
1897
1898    #[test]
1899    fn version_tolerates_a_prerelease_suffix_on_patch() {
1900        let v = Version::parse("1.2.3-rc1").unwrap();
1901        assert_eq!(
1902            v,
1903            Version {
1904                major: 1,
1905                minor: 2,
1906                patch: 3
1907            }
1908        );
1909    }
1910
1911    #[test]
1912    fn version_rejects_garbage() {
1913        assert!(Version::parse("not-a-version").is_err());
1914        assert!(Version::parse("1.2").is_err());
1915    }
1916
1917    #[test]
1918    fn decision_parses_each_level() {
1919        for (json, level) in [
1920            (
1921                r#"{"level":"major","reason":"drops a config key"}"#,
1922                BumpLevel::Major,
1923            ),
1924            (
1925                r#"{"level":"minor","reason":"adds a new flag"}"#,
1926                BumpLevel::Minor,
1927            ),
1928            (
1929                r#"{"level":"patch","reason":"fixes a race"}"#,
1930                BumpLevel::Patch,
1931            ),
1932        ] {
1933            let decision = parse_decision(json).unwrap();
1934            assert_eq!(decision.level, level);
1935            assert!(!decision.reason.is_empty());
1936        }
1937    }
1938
1939    #[test]
1940    fn decision_wrapped_in_a_fence_and_prose_still_parses() {
1941        let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
1942        let decision = parse_decision(text).unwrap();
1943        assert_eq!(decision.level, BumpLevel::Minor);
1944        assert_eq!(decision.reason, "new HTTP route");
1945    }
1946
1947    #[test]
1948    fn a_broken_reply_is_an_error_not_a_default() {
1949        assert!(parse_decision("I decline to answer.").is_err());
1950        assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
1951        assert!(
1952            parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
1953            "an empty reason must not pass either"
1954        );
1955        assert!(
1956            parse_decision(r#"{"level":"patch"}"#).is_err(),
1957            "a reply with no reason at all must not pass"
1958        );
1959    }
1960
1961    #[test]
1962    fn prompt_states_the_zero_x_rule_and_the_tie_break() {
1963        let prompt = decision_prompt(
1964            "feat: add a phone endpoint",
1965            "add POST /api/widgets",
1966            "1 file changed, 10 insertions(+)",
1967            &["src/web.rs".to_owned()],
1968            "0.8.0",
1969        );
1970        assert!(prompt.contains("0.8.0"), "the current version is stated");
1971        assert!(
1972            prompt.contains("below `1.0.0`")
1973                && prompt.contains("`minor` is the digit that carries a breaking change"),
1974            "the 0.x rule must be explicit: {prompt}"
1975        );
1976        assert!(
1977            prompt.contains("choose the larger"),
1978            "the tie-break toward the bigger digit must be explicit: {prompt}"
1979        );
1980    }
1981
1982    #[test]
1983    fn release_only_diffs_are_recognised() {
1984        assert!(is_release_only(&["Cargo.toml".to_owned()]));
1985        assert!(is_release_only(&[
1986            "Cargo.toml".to_owned(),
1987            "Cargo.lock".to_owned()
1988        ]));
1989        assert!(!is_release_only(&[]));
1990        assert!(!is_release_only(&[
1991            "Cargo.toml".to_owned(),
1992            "src/main.rs".to_owned()
1993        ]));
1994    }
1995
1996    #[test]
1997    fn cargo_version_rewrite_touches_only_the_package_table() {
1998        let toml = "\
1999[package]\n\
2000# a comment mentioning version on purpose\n\
2001name = \"magi-cli\"\n\
2002version = \"0.8.0\"\n\
2003edition = \"2024\"\n\
2004\n\
2005[dependencies]\n\
2006foo = { version = \"1.2.3\" }\n";
2007        let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
2008        assert!(out.contains("version = \"0.9.0\""));
2009        assert!(
2010            out.contains("foo = { version = \"1.2.3\" }"),
2011            "a dependency's own version pin must survive: {out}"
2012        );
2013        assert!(
2014            out.contains("# a comment mentioning version on purpose"),
2015            "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
2016        );
2017        assert_eq!(
2018            out.lines().count(),
2019            toml.lines().count(),
2020            "the rewrite replaces one line, it does not add or remove any"
2021        );
2022    }
2023
2024    #[test]
2025    fn cargo_version_rewrite_fails_without_a_package_table() {
2026        let toml = "[dependencies]\nfoo = \"1\"\n";
2027        assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
2028    }
2029
2030    /// The shape `kanadehq/kanade` has: a workspace root with member crates
2031    /// but no crate of its own, so `[package]` never exists and the version
2032    /// lives under `[workspace.package]` alone. Before this fell back,
2033    /// `rewrite_cargo_version` bailed on every such repository and no bump
2034    /// pull request was ever opened for it.
2035    #[test]
2036    fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
2037        let toml = "\
2038[workspace]\n\
2039members = [\"crates/a\", \"crates/b\"]\n\
2040\n\
2041[workspace.package]\n\
2042version = \"0.45.18\"\n\
2043edition = \"2024\"\n\
2044\n\
2045[workspace.dependencies]\n\
2046foo = { version = \"1.2.3\" }\n";
2047        let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
2048        assert!(out.contains("version = \"0.45.19\""));
2049        assert!(
2050            out.contains("foo = { version = \"1.2.3\" }"),
2051            "a workspace dependency's own version pin must survive: {out}"
2052        );
2053        assert_eq!(
2054            out.lines().count(),
2055            toml.lines().count(),
2056            "the rewrite replaces one line, it does not add or remove any"
2057        );
2058    }
2059
2060    #[test]
2061    fn current_version_prefers_the_package_table_when_both_exist() {
2062        let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
2063        assert_eq!(current_version(toml).unwrap(), "0.8.0");
2064    }
2065
2066    /// Same shape as `kanadehq/kanade`'s root `Cargo.toml`: no `[package]`
2067    /// at all, only `[workspace]` and `[workspace.package]`.
2068    #[test]
2069    fn current_version_falls_back_to_workspace_package_without_a_package_table() {
2070        let toml = "\
2071[workspace]\n\
2072members = [\"crates/a\", \"crates/b\"]\n\
2073\n\
2074[workspace.package]\n\
2075version = \"0.45.18\"\n";
2076        assert_eq!(current_version(toml).unwrap(), "0.45.18");
2077    }
2078
2079    #[test]
2080    fn coalesce_proceeds_with_nothing_pending() {
2081        assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
2082    }
2083
2084    /// A minimal, otherwise-plausible pending marker for tests that only
2085    /// care about one field.
2086    fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
2087        PendingBump {
2088            target_version: target_version.to_owned(),
2089            level,
2090            branch: format!("chore/release-v{target_version}"),
2091            pr_url: "https://example.invalid/pull/9".to_owned(),
2092        }
2093    }
2094
2095    #[test]
2096    fn coalesce_skips_while_the_pending_target_is_still_ahead() {
2097        let pending = test_pending("0.9.0", BumpLevel::Minor);
2098        assert_eq!(
2099            coalesce(Some(&pending), "0.8.0").unwrap(),
2100            Coalesce::Skip {
2101                target_version: "0.9.0".to_owned()
2102            }
2103        );
2104    }
2105
2106    #[test]
2107    fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
2108        let pending = test_pending("0.9.0", BumpLevel::Minor);
2109        // The pending bump landed exactly: proceed with a fresh decision.
2110        assert_eq!(
2111            coalesce(Some(&pending), "0.9.0").unwrap(),
2112            Coalesce::Proceed
2113        );
2114        // A human bumped further than what was pending: also proceed.
2115        assert_eq!(
2116            coalesce(Some(&pending), "1.0.0").unwrap(),
2117            Coalesce::Proceed
2118        );
2119    }
2120
2121    #[test]
2122    fn pending_action_escalates_only_for_a_more_severe_decision() {
2123        assert_eq!(
2124            pending_action(BumpLevel::Patch, BumpLevel::Patch),
2125            PendingAction::AlreadyCovered
2126        );
2127        assert_eq!(
2128            pending_action(BumpLevel::Patch, BumpLevel::Minor),
2129            PendingAction::Escalate
2130        );
2131        assert_eq!(
2132            pending_action(BumpLevel::Patch, BumpLevel::Major),
2133            PendingAction::Escalate
2134        );
2135        assert_eq!(
2136            pending_action(BumpLevel::Minor, BumpLevel::Patch),
2137            PendingAction::AlreadyCovered
2138        );
2139        assert_eq!(
2140            pending_action(BumpLevel::Major, BumpLevel::Minor),
2141            PendingAction::AlreadyCovered
2142        );
2143        assert_eq!(
2144            pending_action(BumpLevel::Major, BumpLevel::Major),
2145            PendingAction::AlreadyCovered
2146        );
2147    }
2148
2149    #[test]
2150    fn pr_state_parsing_reads_open_and_not_open() {
2151        assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
2152        assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
2153        assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
2154    }
2155
2156    #[test]
2157    fn a_lock_is_exclusive_until_dropped() {
2158        let dir = tempfile::tempdir().unwrap();
2159        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2160        let first = MarkerLock::acquire(&marker)
2161            .unwrap()
2162            .expect("first attempt takes the lock");
2163        assert!(
2164            MarkerLock::acquire(&marker).unwrap().is_none(),
2165            "a second attempt must be refused while the first holds it"
2166        );
2167        drop(first);
2168        assert!(
2169            MarkerLock::acquire(&marker).unwrap().is_some(),
2170            "dropping the guard releases the lock for the next attempt"
2171        );
2172    }
2173
2174    #[test]
2175    fn a_stale_lock_is_reclaimed() {
2176        let dir = tempfile::tempdir().unwrap();
2177        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2178        let lock_path = marker.with_extension("lock");
2179        std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
2180        std::fs::write(&lock_path, b"").unwrap();
2181        let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
2182        std::fs::OpenOptions::new()
2183            .write(true)
2184            .open(&lock_path)
2185            .unwrap()
2186            .set_modified(old)
2187            .unwrap();
2188        assert!(
2189            MarkerLock::acquire(&marker).unwrap().is_some(),
2190            "a lock older than the stale window must be reclaimed rather than block forever"
2191        );
2192    }
2193
2194    #[tokio::test]
2195    async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
2196        let dir = tempfile::tempdir().unwrap();
2197        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2198        let held = MarkerLock::acquire(&marker)
2199            .unwrap()
2200            .expect("seed the contention");
2201        let releaser = tokio::spawn(async move {
2202            tokio::time::sleep(Duration::from_millis(20)).await;
2203            drop(held);
2204        });
2205        let waited =
2206            wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
2207                .await
2208                .unwrap();
2209        assert!(
2210            waited.is_some(),
2211            "a merge landing behind another's still-running decision must not be dropped - it \
2212             must wait for that decision to finish and then judge against what it left behind"
2213        );
2214        releaser.await.unwrap();
2215    }
2216
2217    #[tokio::test]
2218    async fn a_lock_held_past_the_ceiling_gives_up() {
2219        let dir = tempfile::tempdir().unwrap();
2220        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2221        let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
2222        let waited =
2223            wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
2224                .await
2225                .unwrap();
2226        assert!(
2227            waited.is_none(),
2228            "a lock genuinely held past the ceiling must eventually give up rather than wait \
2229             forever"
2230        );
2231    }
2232
2233    #[test]
2234    fn level_between_reads_off_the_differing_digit() {
2235        assert_eq!(
2236            level_between(
2237                Version::parse("0.8.0").unwrap(),
2238                Version::parse("1.0.0").unwrap()
2239            ),
2240            Some(BumpLevel::Major)
2241        );
2242        assert_eq!(
2243            level_between(
2244                Version::parse("0.8.0").unwrap(),
2245                Version::parse("0.9.0").unwrap()
2246            ),
2247            Some(BumpLevel::Minor)
2248        );
2249        assert_eq!(
2250            level_between(
2251                Version::parse("0.8.0").unwrap(),
2252                Version::parse("0.8.1").unwrap()
2253            ),
2254            Some(BumpLevel::Patch)
2255        );
2256        assert_eq!(
2257            level_between(
2258                Version::parse("0.8.0").unwrap(),
2259                Version::parse("0.8.0").unwrap()
2260            ),
2261            None
2262        );
2263    }
2264
2265    #[test]
2266    fn open_release_pr_is_found_among_unrelated_pull_requests() {
2267        let json = r#"[
2268            {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
2269            {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
2270        ]"#;
2271        let found = parse_open_release_pr(json).unwrap();
2272        assert_eq!(
2273            found,
2274            Some((
2275                "chore/release-v0.9.0".to_owned(),
2276                "https://example.invalid/pull/2".to_owned()
2277            ))
2278        );
2279    }
2280
2281    #[test]
2282    fn no_open_release_pr_reads_as_none_not_an_error() {
2283        let json =
2284            r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
2285        assert_eq!(parse_open_release_pr(json).unwrap(), None);
2286        assert_eq!(parse_open_release_pr("[]").unwrap(), None);
2287    }
2288
2289    #[test]
2290    fn marker_round_trips_through_disk() {
2291        let dir = tempfile::tempdir().unwrap();
2292        let path = marker_path(dir.path(), Path::new("/repos/magi"));
2293        assert!(read_marker(&path).is_none());
2294
2295        let marker = test_pending("0.9.0", BumpLevel::Patch);
2296        write_marker(&path, &marker).unwrap();
2297        let read_back = read_marker(&path).unwrap();
2298        assert_eq!(read_back.target_version, "0.9.0");
2299        assert_eq!(read_back.level, BumpLevel::Patch);
2300        assert_eq!(read_back.pr_url, marker.pr_url);
2301
2302        clear_marker(&path);
2303        assert!(read_marker(&path).is_none());
2304    }
2305
2306    #[test]
2307    fn different_repos_get_different_marker_files() {
2308        let dir = tempfile::tempdir().unwrap();
2309        let a = marker_path(dir.path(), Path::new("/repos/a"));
2310        let b = marker_path(dir.path(), Path::new("/repos/b"));
2311        assert_ne!(a, b);
2312    }
2313
2314    /// A version-bump-only pull request must never trigger the next bump - see
2315    /// [`is_release_only`]'s own doc for why that shape is the trigger for
2316    /// "do not treat this as a change to react to".
2317    #[test]
2318    fn a_bump_pull_requests_own_merge_does_not_retrigger() {
2319        let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
2320        assert!(
2321            is_release_only(&files),
2322            "the bump pull request's own diff must read as release-only"
2323        );
2324    }
2325
2326    #[test]
2327    fn should_release_bump_reads_only_a_merged_status() {
2328        assert!(should_release_bump(RunStatus::Merged));
2329        for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
2330            assert!(!should_release_bump(other));
2331        }
2332    }
2333
2334    /// `land::Step::Done { merged: true }` - a pull request already merged
2335    /// underneath magi. `land::decide` reads that straight off the pull
2336    /// request's own lifecycle before it looks at checks or comments at all.
2337    #[test]
2338    fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
2339        let pr = land::PrState {
2340            url: "https://github.com/o/r/pull/1".to_owned(),
2341            number: 1,
2342            state: PrLifecycle::Merged,
2343            checks: land::Checks::Green,
2344            failing: Vec::new(),
2345            review_comments: Vec::new(),
2346            blocking: land::Blocking::No,
2347        };
2348        assert_eq!(
2349            land::decide(&pr, 0, 4, Duration::ZERO),
2350            land::Step::Done { merged: true }
2351        );
2352        assert!(should_release_bump(RunStatus::Merged));
2353    }
2354
2355    /// `land::Step::Merge`'s own `gh pr merge` succeeding: `land::land` then
2356    /// sets `pr.state = PrLifecycle::Merged` by hand before returning (see
2357    /// `land::land`'s `Step::Merge` arm), which is the same value the other
2358    /// two paths converge on.
2359    #[test]
2360    fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
2361        let pr = land::PrState {
2362            url: "https://github.com/o/r/pull/2".to_owned(),
2363            number: 2,
2364            state: PrLifecycle::Open,
2365            checks: land::Checks::Green,
2366            failing: Vec::new(),
2367            review_comments: Vec::new(),
2368            blocking: land::Blocking::No,
2369        };
2370        assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
2371        // land::land's Step::Merge arm sets this by hand on success; asserted
2372        // here as the value that then makes should_release_bump fire.
2373        assert!(should_release_bump(RunStatus::Merged));
2374    }
2375
2376    /// [`land::merged_after_all`] - `gh pr merge` exited non-zero but the
2377    /// forge confirms the pull request merged anyway.
2378    #[test]
2379    fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
2380        let argv = land::merge_argv(3, "feat: something");
2381        let outcome = land::merged_after_all(
2382            &argv,
2383            "could not determine current branch: not on any branch",
2384            Some(PrLifecycle::Merged),
2385        );
2386        assert!(outcome.is_some(), "the forge's confirmation must win");
2387        assert!(should_release_bump(RunStatus::Merged));
2388
2389        // The same recovery must not fabricate a merge when the forge does
2390        // not confirm one.
2391        assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
2392        assert!(land::merged_after_all(&argv, "network error", None).is_none());
2393    }
2394
2395    /// The paths that do *not* land must not read as merged either.
2396    #[test]
2397    fn a_close_or_a_give_up_does_not_trigger_a_bump() {
2398        let pr = land::PrState {
2399            url: "https://github.com/o/r/pull/4".to_owned(),
2400            number: 4,
2401            state: PrLifecycle::Closed,
2402            checks: land::Checks::Green,
2403            failing: Vec::new(),
2404            review_comments: Vec::new(),
2405            blocking: land::Blocking::No,
2406        };
2407        assert_eq!(
2408            land::decide(&pr, 0, 4, Duration::ZERO),
2409            land::Step::Done { merged: false }
2410        );
2411        assert!(!should_release_bump(RunStatus::Blocked));
2412    }
2413}