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