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