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, anyhow, 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`).
586pub(crate) fn 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/// The version a release pull request really carries: the manifest on its head
593/// branch (an escalation rewrites it but keeps the branch name), falling back
594/// to the branch name only when the manifest cannot be read.
595async fn branch_version(repo: &Path, remote: &str, branch: &str) -> Option<String> {
596    let from_name = branch.strip_prefix("chore/release-v").map(str::to_owned);
597    // A tracking ref of its own: FETCH_HEAD is shared with every other fetch in
598    // this repository (the release watcher runs them concurrently).
599    let tracking = format!("refs/remotes/{remote}/{branch}");
600    let spec = format!("+refs/heads/{branch}:{tracking}");
601    let fetched = git::git_raw(repo, &["fetch", "--quiet", remote, &spec]).await;
602    if fetched.is_ok_and(|o| o.code == Some(0))
603        && let Ok(out) = git::git_raw(repo, &["show", &format!("{tracking}:Cargo.toml")]).await
604        && out.code == Some(0)
605        && let Ok(v) = current_version(&out.stdout)
606    {
607        return Some(v);
608    }
609    from_name
610}
611
612/// Build the prompt asking an agent which digit of `major.minor.patch` a
613/// merged change earns.
614///
615/// Pure: every input is already known once a merge lands, so the whole
616/// decision policy - the "`minor` is the breaking digit below `1.0.0`" rule,
617/// what counts as a breaking surface, and the tie-break toward the larger
618/// digit - is asserted directly on the returned string, the same way
619/// [`crate::land::fix_prompt`] doc-comments its own rules rather than leaving
620/// them for a human to spot missing from a live reply.
621pub fn decision_prompt(
622    subject: &str,
623    instruction: &str,
624    diffstat: &str,
625    files: &[String],
626    current_version: &str,
627) -> String {
628    let mut s = format!(
629        "A pull request just merged into the base branch. Decide which digit \
630         of this project's `major.minor.patch` version this change earns, so \
631         a release bump can be opened for exactly it.\n\n\
632         Current version: {current_version}\n\n\
633         # Merge subject\n\n{subject}\n\n\
634         # The task that produced it\n\n{instruction}\n\n\
635         # Files changed ({} total)\n\n",
636        files.len()
637    );
638    const MAX_FILES: usize = 50;
639    for f in files.iter().take(MAX_FILES) {
640        let _ = writeln!(s, "- {f}");
641    }
642    if files.len() > MAX_FILES {
643        let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
644    }
645    let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
646
647    s.push_str(
648        "\n# How to decide\n\n\
649         This project is below version `1.0.0`. At that stage **`minor` is \
650         the digit that carries a breaking change** - do not spend `major` \
651         below `1.0.0`.\n\n\
652         A change is breaking, and earns `minor`, when it changes any of: \
653         the public API reachable from `src/lib.rs`, a CLI subcommand or \
654         flag, an HTTP API route or response shape, a configuration key, or \
655         the on-disk shape of persisted state.\n\n\
656         A user-visible new capability that breaks none of the above also \
657         earns `minor`.\n\n\
658         A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
659         **When it is not obvious which digit applies, choose the larger \
660         one.** An oversized bump costs nothing; a breaking change shipped as \
661         `patch` breaks every downstream update that pins a range.\n\n\
662         # Output\n\n\
663         Reply with exactly one fenced JSON object and nothing that matters \
664         outside it:\n\n\
665         ```json\n\
666         {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
667         ```\n",
668    );
669    // The reason is pasted into the release pull request's body.
670    let _ = write!(
671        s,
672        "\n{}\n\nThe `reason` goes into a GitHub pull request body, so write it \
673         in English.\n",
674        crate::prompt::GITHUB_ENGLISH_HEADING
675    );
676    s
677}
678
679/// magi's own record of a bump pull request it currently has open, so a
680/// burst of merges in quick succession does not each open a competing
681/// release.
682///
683/// **Chosen policy: serialize, not coalesce two independent decisions into
684/// one.** A bump branch touches only `Cargo.toml` / `Cargo.lock`, so `gh pr
685/// merge --squash` applies it onto whatever the base branch has become by
686/// the time it lands - every commit merged while it was open rides along
687/// for free, at no extra cost, once it merges. But the *digit* a still-open
688/// pull request targets was judged from only the first change, and a more
689/// severe change landing while it waits must not ship at the smaller digit
690/// just because it arrived second - so the serialization is at the pull
691/// request, not at the judgement: a later, more severe decision escalates
692/// the same open pull request (see [`pending_action`]) rather than opening a
693/// second one or being silently absorbed at the wrong digit.
694#[derive(Debug, Clone, Serialize, Deserialize)]
695pub struct PendingBump {
696    /// The version the open pull request bumps to.
697    pub target_version: String,
698    /// The digit that version was judged to need, so a later, more severe
699    /// merge can tell it needs to escalate rather than assume it is covered.
700    pub level: BumpLevel,
701    /// The branch the open pull request is built from, so an escalation
702    /// knows what to check out and push to.
703    pub branch: String,
704    /// The pull request's URL, so a later merge can confirm it is still
705    /// open before trusting it to block a fresh decision.
706    pub pr_url: String,
707}
708
709/// Where [`PendingBump`] is recorded for `repo` - one file per repository, so
710/// a machine running magi against more than one checkout does not confuse
711/// their releases with each other.
712pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
713    let key = repo.to_string_lossy();
714    home.join("bump")
715        .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
716}
717
718/// Read a recorded [`PendingBump`], if any. Missing or unreadable both read
719/// as "nothing pending" - a marker is bookkeeping, not a source of truth
720/// worth failing a merge over.
721pub fn read_marker(path: &Path) -> Option<PendingBump> {
722    let body = std::fs::read_to_string(path).ok()?;
723    serde_json::from_str(&body).ok()
724}
725
726/// Persist `marker`, atomically - the same tmp-then-rename shape
727/// [`crate::updater::write_progress`] uses, since this file is read by a
728/// later, unrelated process invocation and must never be seen half-written.
729pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
730    if let Some(parent) = path.parent() {
731        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
732    }
733    let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
734    let tmp = path.with_extension("json.tmp");
735    std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
736    std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
737    Ok(())
738}
739
740/// Drop a recorded marker. Best-effort: a marker that is already gone is not
741/// an error.
742pub fn clear_marker(path: &Path) {
743    let _ = std::fs::remove_file(path);
744}
745
746/// What a recorded [`PendingBump`] means for a fresh decision, given what the
747/// base branch's `Cargo.toml` says right now. No I/O: the caller reads both
748/// the marker and the version.
749#[derive(Debug, Clone, PartialEq, Eq)]
750pub enum Coalesce {
751    /// Nothing is pending, or the pending bump already landed (or was
752    /// superseded by a manual one) - safe to open a fresh decision.
753    Proceed,
754    /// A bump to `target_version` is already open; do not open a second one.
755    Skip {
756        /// The version the pending pull request already targets.
757        target_version: String,
758    },
759}
760
761/// Decide what a pending marker means against `current_version`.
762pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
763    let Some(pending) = pending else {
764        return Ok(Coalesce::Proceed);
765    };
766    let current = Version::parse(current_version)?;
767    let target = Version::parse(&pending.target_version)?;
768    if current >= target {
769        return Ok(Coalesce::Proceed);
770    }
771    Ok(Coalesce::Skip {
772        target_version: pending.target_version.clone(),
773    })
774}
775
776/// What a still-open pending bump means once a fresh decision is in hand.
777#[derive(Debug, Clone, Copy, PartialEq, Eq)]
778pub enum PendingAction {
779    /// The new decision is no more severe than what is already queued; the
780    /// open pull request covers it once it lands.
781    AlreadyCovered,
782    /// The new decision outranks the pending target - escalate the open
783    /// pull request instead of opening a second one or dropping it.
784    Escalate,
785}
786
787/// Compare a fresh decision against what a still-open pull request already
788/// targets.
789///
790/// A patch bump left pending while a breaking change lands does not become a
791/// breaking release just because the pull request that carries both is
792/// squashed into one commit: the *version number* still comes from whichever
793/// digit was judged, and a pending `patch` never widens itself to `minor` on
794/// its own. This is the check that decides an escalation is owed.
795pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
796    if decision_level.severity() > pending_level.severity() {
797        PendingAction::Escalate
798    } else {
799        PendingAction::AlreadyCovered
800    }
801}
802
803/// Parse `gh pr view --json state` output. No I/O.
804fn parse_pr_state(json: &str) -> Result<bool> {
805    #[derive(Deserialize)]
806    struct State {
807        state: String,
808    }
809    let parsed: State =
810        serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
811    Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
812}
813
814/// Is the pull request at `pr_url` still open?
815///
816/// Read fresh rather than trusted from the marker: a bump pull request can be
817/// closed without merging - CI that never goes green, an operator who
818/// decided against it - and nothing else in this module ever revisits a
819/// marker once it is written. Without this check, that close is invisible
820/// here forever: the marker still names a pending target, the base branch
821/// never reaches it because nothing ever merged the pull request, and every
822/// later merge skips in perpetuity. A `gh` failure (network, auth) answers
823/// `true` - the same "unreadable is not absent" rule `land::CHECKS_GRACE`
824/// uses - because guessing "closed" wrongly opens a second, competing pull
825/// request, while guessing "open" wrongly only costs one more merge's wait.
826async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
827    let out = tokio::process::Command::new("gh")
828        .args(["pr", "view", pr_url, "--json", "state"])
829        .current_dir(repo)
830        .quiet()
831        .stdin(std::process::Stdio::null())
832        .output()
833        .await
834        .context("spawn gh pr view")?;
835    if !out.status.success() {
836        bail!(
837            "gh pr view {pr_url}: {}",
838            String::from_utf8_lossy(&out.stderr).trim()
839        );
840    }
841    parse_pr_state(&String::from_utf8_lossy(&out.stdout))
842}
843
844/// How long a stale lock file is trusted to mean its owner is still working,
845/// before it is reclaimed.
846///
847/// Long enough to cover the slowest real step this module takes - the agent
848/// decision call ([`DECISION_TIMEOUT`]) plus `cargo build` and a `gh pr
849/// create` - so a lock is only ever stolen from a process that has actually
850/// gone (crashed, killed), never one still inside its own critical section.
851const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
852
853/// A host-local mutual exclusion for one repository's marker file.
854///
855/// Built on exclusive file creation rather than a locking crate: neither
856/// `flock` nor `fs2` is a dependency of this crate, and the constraints on
857/// this change forbid adding one. This is not a distributed lock and does
858/// not coordinate two machines racing the same repository - it exists to
859/// close the specific race two `after_merge` calls on the *same* host can
860/// hit landing within the same window (a human `magi run` alongside the
861/// daemon, or two review loops): both would otherwise read "nothing
862/// pending", judge independently, and open two competing pull requests, with
863/// whichever `write_marker` runs last silently erasing the other's record.
864struct MarkerLock {
865    path: PathBuf,
866}
867
868impl MarkerLock {
869    /// Try to take the lock for `marker`, stealing a stale one first if it is
870    /// old enough to mean its owner is gone rather than merely slow.
871    /// `Ok(None)` means someone else genuinely holds it right now.
872    fn acquire(marker: &Path) -> Result<Option<Self>> {
873        let path = marker.with_extension("lock");
874        if let Some(parent) = path.parent() {
875            std::fs::create_dir_all(parent)
876                .with_context(|| format!("create {}", parent.display()))?;
877        }
878        if Self::try_create(&path)? {
879            return Ok(Some(Self { path }));
880        }
881        if Self::is_stale(&path) {
882            let _ = std::fs::remove_file(&path);
883            if Self::try_create(&path)? {
884                return Ok(Some(Self { path }));
885            }
886        }
887        Ok(None)
888    }
889
890    fn try_create(path: &Path) -> Result<bool> {
891        match std::fs::OpenOptions::new()
892            .write(true)
893            .create_new(true)
894            .open(path)
895        {
896            Ok(_) => Ok(true),
897            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
898            Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
899        }
900    }
901
902    fn is_stale(path: &Path) -> bool {
903        std::fs::metadata(path)
904            .and_then(|m| m.modified())
905            .ok()
906            .and_then(|m| m.elapsed().ok())
907            .is_some_and(|age| age >= LOCK_STALE_AFTER)
908    }
909}
910
911impl Drop for MarkerLock {
912    fn drop(&mut self) {
913        let _ = std::fs::remove_file(&self.path);
914    }
915}
916
917/// How often a blocked caller checks whether [`MarkerLock`] has freed up.
918const LOCK_POLL: Duration = Duration::from_secs(5);
919
920/// How long a caller waits for a contended lock before giving up on this
921/// merge's own judgement entirely.
922///
923/// A first version of this gate gave up the instant the lock was taken,
924/// which meant a change landing while another host's decision call was
925/// still running was never judged at all - not even recorded as pending,
926/// not escalated later, just dropped. The lock is only ever held for one
927/// `after_merge` call, so waiting past it is what lets that call's own
928/// decision reach [`pending_action`] against a marker the other side just
929/// finished writing, instead of finding nothing to check against. Set just
930/// under [`LOCK_STALE_AFTER`]: a lock still held this long after that point
931/// is reclaimed as abandoned rather than waited on further.
932const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
933
934/// Wait for [`MarkerLock`] to free up, polling rather than blocking forever.
935/// `Ok(None)` means the ceiling passed with the lock still held.
936async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
937    wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
938}
939
940/// [`wait_for_marker_lock`] with the poll interval and ceiling as parameters,
941/// so the retry behaviour is testable without a test actually waiting out
942/// [`LOCK_WAIT_CEILING`].
943async fn wait_for_marker_lock_with(
944    marker: &Path,
945    poll: Duration,
946    ceiling: Duration,
947) -> Result<Option<MarkerLock>> {
948    let mut waited = Duration::ZERO;
949    loop {
950        if let Some(lock) = MarkerLock::acquire(marker)? {
951            return Ok(Some(lock));
952        }
953        if waited >= ceiling {
954            return Ok(None);
955        }
956        tokio::time::sleep(poll).await;
957        waited += poll;
958    }
959}
960
961/// Which digit differs between `from` and `to`? `None` when they are equal.
962///
963/// Used to recover the level a pull request found by [`find_open_release_pr`]
964/// was judged at: the forge has the resulting version (in the branch name and
965/// the title) but not the digit an agent chose to get there, and this is the
966/// one other host-independent fact every host can compute the same way from
967/// it.
968fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
969    if to.major != from.major {
970        Some(BumpLevel::Major)
971    } else if to.minor != from.minor {
972        Some(BumpLevel::Minor)
973    } else if to.patch != from.patch {
974        Some(BumpLevel::Patch)
975    } else {
976        None
977    }
978}
979
980/// Parse `gh pr list --state open --json url,headRefName` output, returning
981/// the first pull request whose branch is one of this module's own. No I/O.
982fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
983    #[derive(Deserialize)]
984    struct Pr {
985        url: String,
986        #[serde(rename = "headRefName")]
987        head_ref_name: String,
988    }
989    let list: Vec<Pr> =
990        serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
991    Ok(list
992        .into_iter()
993        .find(|p| p.head_ref_name.starts_with("chore/release-v"))
994        .map(|p| (p.head_ref_name, p.url)))
995}
996
997/// Every open pull request on one of this module's own branches, as
998/// `(branch, url)`. No I/O.
999pub(crate) fn parse_open_release_prs(json: &str) -> Result<Vec<(String, String)>> {
1000    #[derive(Deserialize)]
1001    struct Pr {
1002        url: String,
1003        #[serde(rename = "headRefName")]
1004        head_ref_name: String,
1005    }
1006    let list: Vec<Pr> =
1007        serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
1008    Ok(list
1009        .into_iter()
1010        .filter(|p| p.head_ref_name.starts_with("chore/release-v"))
1011        .map(|p| (p.head_ref_name, p.url))
1012        .collect())
1013}
1014
1015/// [`find_open_release_pr`], but every match rather than the first.
1016pub(crate) async fn list_open_release_prs(repo: &Path) -> Result<Vec<(String, String)>> {
1017    let out = tokio::process::Command::new("gh")
1018        .args([
1019            "pr",
1020            "list",
1021            "--state",
1022            "open",
1023            "--limit",
1024            "100",
1025            "--json",
1026            "url,headRefName",
1027        ])
1028        .current_dir(repo)
1029        .env_remove("GH_REPO")
1030        .quiet()
1031        .stdin(std::process::Stdio::null())
1032        .output()
1033        .await
1034        .context("spawn gh pr list")?;
1035    if !out.status.success() {
1036        bail!(
1037            "gh pr list: {}",
1038            String::from_utf8_lossy(&out.stderr).trim()
1039        );
1040    }
1041    parse_open_release_prs(&String::from_utf8_lossy(&out.stdout))
1042}
1043
1044/// Ask the forge directly whether a release bump is already open, for a host
1045/// that has never seen it.
1046///
1047/// [`MarkerLock`] and the marker file only ever coordinate *this* host - a
1048/// marker written on one machine is not visible to `run::home()` on another,
1049/// so two hosts landing runs against the same repository at the same time
1050/// can each read "nothing pending" and open a competing pull request no
1051/// local lock can see. `gh pr list` is the one place every host actually
1052/// shares a view, so it is consulted whenever this host's own marker says
1053/// there is nothing pending, before a fresh decision is allowed to open a
1054/// second pull request. This narrows the race to the gap between this call
1055/// and whichever host's `gh pr create` lands first - it does not close it -
1056/// because turning that into a real distributed lock would need coordination
1057/// this crate has no dependency for.
1058async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
1059    let out = tokio::process::Command::new("gh")
1060        .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
1061        .current_dir(repo)
1062        .quiet()
1063        .stdin(std::process::Stdio::null())
1064        .output()
1065        .await
1066        .context("spawn gh pr list")?;
1067    if !out.status.success() {
1068        bail!(
1069            "gh pr list: {}",
1070            String::from_utf8_lossy(&out.stderr).trim()
1071        );
1072    }
1073    parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
1074}
1075
1076/// After a merge lands, ask an agent how big the change was and open a
1077/// release bump sized to it.
1078///
1079/// Best-effort by construction, the same way `clean::fold_due` treats one
1080/// run's fold failure: this runs after the merge the run exists to produce
1081/// has already succeeded, so a failure here (the decision call, `gh`,
1082/// `cargo`) must never turn a landed run into a failed one. The caller logs
1083/// whatever this returns and moves on.
1084///
1085/// An `Err` is a bump that was tried and failed, never one that was not
1086/// eligible (every such case returns `Ok` after an event). When it fails
1087/// before any release pull request exists, the operator is notified here, so
1088/// the caller only has to record the event: nobody would otherwise learn that
1089/// a release silently never happened. A failure with a pull request in hand
1090/// goes through [`report_problem`] with its URL instead, and is not repeated.
1091/// A failure while a pending release pull request is in play (the decision or
1092/// an escalation of it) is notified here too, naming that pull request: the
1093/// escalation path itself never raises one, so suppressing it would leave the
1094/// operator with nothing.
1095pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
1096    after_merge_at(state, pr_url, None, None).await
1097}
1098
1099/// What one attempt had settled by the time it failed.
1100#[derive(Debug, Default)]
1101struct Progress {
1102    /// The target version, once the decision had been made.
1103    version: Option<String>,
1104    /// A release pull request already pending when this attempt started.
1105    pending_pr: Option<String>,
1106}
1107
1108/// The `(version, reason)` to notify with for a failed attempt. The reason is
1109/// the first line of the full error chain, so the operator can tell a stale
1110/// branch from a build failure. A pull request this attempt opened never reaches here: past
1111/// `gh pr create` nothing returns `Err`, and automerge trouble goes through
1112/// [`report_problem`] itself.
1113fn notice_for_failure(progress: &Progress, err: &anyhow::Error) -> (Option<String>, String) {
1114    let chain = format!("{err:#}");
1115    let first = chain.lines().next().unwrap_or_default().trim();
1116    (progress.version.clone(), first.to_owned())
1117}
1118
1119/// Does the base branch carry a `Cargo.toml` at its root? Release bumps read
1120/// and rewrite that file, so its absence means "not a Rust repository", which
1121/// is not a fault. `ls-tree` rather than `cat-file -e`, so an unresolvable
1122/// ref (a failed fetch, a misconfigured base) stays an error instead of being
1123/// reported as a non-Rust repository.
1124async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
1125    let out = git::git(
1126        repo,
1127        &[
1128            "ls-tree",
1129            "--name-only",
1130            &format!("{remote}/{base}"),
1131            "--",
1132            "Cargo.toml",
1133        ],
1134    )
1135    .await
1136    .context("look for Cargo.toml on the base branch")?;
1137    Ok(!out.trim().is_empty())
1138}
1139
1140/// [`after_merge`] with an optional magi home, so tests can point the
1141/// marker and its lock at a scratch directory.
1142/// `store` is where a notice is raised; `None` is the operator's own.
1143async fn after_merge_at(
1144    state: &mut RunState,
1145    pr_url: &str,
1146    home: Option<&Path>,
1147    store: Option<&Notices>,
1148) -> Result<()> {
1149    let mut progress = Progress::default();
1150    let result = after_merge_inner(state, pr_url, home, &mut progress).await;
1151    if let Err(e) = &result {
1152        let (version, reason) = notice_for_failure(&progress, e);
1153        let pending = progress.pending_pr.as_deref();
1154        let store = store.cloned().unwrap_or_else(Notices::open);
1155        report_problem_in(
1156            state,
1157            &store,
1158            pending,
1159            version.as_deref(),
1160            &reason,
1161            pending.is_some(),
1162        )
1163        .await;
1164    }
1165    result
1166}
1167
1168async fn after_merge_inner(
1169    state: &mut RunState,
1170    pr_url: &str,
1171    home: Option<&Path>,
1172    progress: &mut Progress,
1173) -> Result<()> {
1174    if !state.config.merge.release_bump {
1175        return Ok(());
1176    }
1177    let Some(winner) = state.winner().cloned() else {
1178        return Ok(());
1179    };
1180    let repo = state.repo.clone();
1181    let base = state.base_branch.clone();
1182    let remote = state.config.merge.remote.clone();
1183
1184    let files = git::changed_files(&winner.worktree, &base, &winner.branch)
1185        .await
1186        .unwrap_or_default();
1187    if is_release_only(&files) {
1188        state.event(
1189            "bump",
1190            "the merged change touches only the release manifest; not treating it as a trigger",
1191        );
1192        return Ok(());
1193    }
1194
1195    // Outside the lock, and before anything that assumes a Rust manifest.
1196    // Fetch first so a stale remote-tracking ref cannot misjudge the base.
1197    git::fetch(&repo, &remote, &base).await.ok();
1198    if !base_has_cargo_toml(&repo, &remote, &base).await? {
1199        state.event(
1200            "bump",
1201            "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping",
1202        );
1203        return Ok(());
1204    }
1205
1206    let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
1207    // Held for the rest of this function: the whole read-decide-write
1208    // sequence below is the critical section two `after_merge` calls landing
1209    // within the same window must not both be inside at once. See
1210    // `MarkerLock`'s own doc for why a second, unrelated bump PR is what
1211    // that race produces without it, and `wait_for_marker_lock`'s for why
1212    // this waits rather than giving up the instant it is contended.
1213    let Some(_lock) = wait_for_marker_lock(&marker).await? else {
1214        state.event(
1215            "bump",
1216            "another release bump decision held the lock past the wait ceiling; skipping this round",
1217        );
1218        return Ok(());
1219    };
1220
1221    git::fetch(&repo, &remote, &base).await.ok();
1222    let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
1223        .await
1224        .context("read Cargo.toml from the base branch")?;
1225    let base_version = current_version(&cargo_toml)?;
1226
1227    let mut pending = read_marker(&marker);
1228    if let Some(p) = &pending {
1229        match coalesce(Some(p), &base_version)? {
1230            Coalesce::Proceed => {
1231                // Landed, or superseded by a manual bump: free for a fresh
1232                // decision.
1233                clear_marker(&marker);
1234                pending = None;
1235            }
1236            Coalesce::Skip { target_version } => {
1237                if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
1238                    state.event(
1239                        "bump",
1240                        format!(
1241                            "the pending release bump to v{target_version} ({}) is no longer \
1242                             open; treating it as abandoned",
1243                            p.pr_url
1244                        ),
1245                    );
1246                    clear_marker(&marker);
1247                    pending = None;
1248                }
1249                // Otherwise still genuinely open: fall through and ask the
1250                // same question this merge would get on a fresh path, so a
1251                // more severe change landing while it waits can escalate it
1252                // instead of being silently absorbed at the wrong digit.
1253            }
1254        }
1255    }
1256
1257    if pending.is_none() {
1258        // This host's own marker has nothing to say - check the forge itself
1259        // before trusting that to mean a fresh pull request is safe to open.
1260        // See `find_open_release_pr`'s own doc for what this does and does
1261        // not close.
1262        if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
1263            && let Some(target) = branch_version(&repo, &remote, &branch)
1264                .await
1265                .and_then(|v| Version::parse(&v).ok())
1266        {
1267            let base_parsed = Version::parse(&base_version)?;
1268            if target > base_parsed
1269                && let Some(level) = level_between(base_parsed, target)
1270            {
1271                let adopted = PendingBump {
1272                    target_version: target.to_string(),
1273                    level,
1274                    branch,
1275                    pr_url: url,
1276                };
1277                // Best-effort: worst case this host asks the forge again
1278                // next time instead of finding its own record of it.
1279                let _ = write_marker(&marker, &adopted);
1280                pending = Some(adopted);
1281            }
1282        }
1283    }
1284
1285    progress.pending_pr = pending.as_ref().map(|p| p.pr_url.clone());
1286    let title = pr_title(&repo, pr_url).await.unwrap_or_default();
1287    let subject = land::merge_subject(
1288        crate::graph::landing_title(state, &title),
1289        &crate::graph::landing_subject_source(state),
1290    );
1291    let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
1292        .await
1293        .unwrap_or_default();
1294    let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
1295
1296    // No dedicated role for this one-off decision. Borrows `[roles] chatter`
1297    // - the nearest surviving single-agent-seat preference - rather than
1298    // falling straight to `agent::pick`'s own default order, so an operator
1299    // who has already named a preferred seat there is not silently
1300    // overridden for this decision too.
1301    let spec: AgentSpec = agent::pick(
1302        &state.config.agents,
1303        crate::config::primary(state.config.roles.chatter.as_ref()),
1304        &agent::installed,
1305    )
1306    .context("choose an agent for the release-bump decision")?;
1307    let mut seat = SeatState::new("bump", &spec.id, state.seed);
1308    let artifacts = agent::artifacts_dir(&state.dir());
1309    let out = agent::invoke(
1310        &spec,
1311        &mut seat,
1312        &Invocation {
1313            cwd: &repo,
1314            prompt: &prompt,
1315            timeout: DECISION_TIMEOUT,
1316            // The decision reads a diffstat and writes a verdict; it must
1317            // never touch a file.
1318            allow_write: false,
1319            unsandboxed: false,
1320            sessions: false,
1321            artifacts: &artifacts,
1322            stem: "bump-decision",
1323            run: &state.id,
1324            node: "bump",
1325            cache_dir: state.config.cache_dir().as_deref(),
1326            attachments: &[],
1327            writable: &[],
1328        },
1329    )
1330    .await
1331    .context("ask an agent how big the merged change was")?;
1332    if !out.usable() {
1333        bail!(
1334            "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
1335            out.exit_code,
1336            out.timed_out
1337        );
1338    }
1339    let mut decision = parse_decision(&out.text).context("parse the release-bump decision")?;
1340    let mut violations =
1341        crate::github_text::check_for(&state.config.graph, "", &decision.reason, None);
1342    // Sensitive-only: span redaction is enough, no rewrite needed.
1343    violations.retain(|v| *v != crate::github_text::Violation::SensitiveData);
1344    if violations.is_empty() {
1345        decision.reason = crate::scrub::scrub(&decision.reason, &crate::scrub::Identity::current());
1346    }
1347    if state.config.graph.github_text_guard && !violations.is_empty() {
1348        state.event(
1349            "github-text",
1350            format!("release reason rejected: {violations:?}; requesting one rewrite"),
1351        );
1352        let rewrite = format!(
1353            "{prompt}\n\nYour release reason failed the GitHub text gate: {violations:?}. Return the same decision JSON with an English reason containing no local identity or secrets. Previous reason: {}",
1354            decision.reason
1355        );
1356        let retry = agent::invoke(
1357            &spec,
1358            &mut seat,
1359            &Invocation {
1360                cwd: &repo,
1361                prompt: &rewrite,
1362                timeout: crate::graph::retry_budget(DECISION_TIMEOUT, true),
1363                allow_write: false,
1364                unsandboxed: false,
1365                sessions: false,
1366                artifacts: &artifacts,
1367                stem: "bump-rewrite",
1368                run: &state.id,
1369                node: "bump",
1370                cache_dir: state.config.cache_dir().as_deref(),
1371                attachments: &[],
1372                writable: &[],
1373            },
1374        )
1375        .await;
1376        let reason = retry
1377            .ok()
1378            .filter(|o| o.usable())
1379            .and_then(|o| parse_decision(&o.text).ok())
1380            .filter(|d| {
1381                d.level == decision.level
1382                    && crate::github_text::check_for(&state.config.graph, "", &d.reason, None)
1383                        .is_empty()
1384            })
1385            .map(|d| d.reason);
1386        if let Some(reason) = reason {
1387            decision.reason = reason;
1388            state.event("github-text", "release reason rewrite passed");
1389        } else {
1390            decision.reason = "The merged change requires a release.".to_owned();
1391            state.event(
1392                "github-text",
1393                "release reason rewrite unavailable or rejected; using neutral text",
1394            );
1395        }
1396    }
1397
1398    if let Some(p) = pending {
1399        return match pending_action(p.level, decision.level) {
1400            PendingAction::AlreadyCovered => {
1401                state.event(
1402                    "bump",
1403                    format!(
1404                        "a release bump to v{} ({}) already covers at least a {} change; not \
1405                         opening another",
1406                        p.target_version,
1407                        p.pr_url,
1408                        decision.level.as_str()
1409                    ),
1410                );
1411                Ok(())
1412            }
1413            PendingAction::Escalate => {
1414                progress.version = Some(
1415                    Version::parse(&base_version)?
1416                        .bump(decision.level)
1417                        .to_string(),
1418                );
1419                escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
1420            }
1421        };
1422    }
1423
1424    let next = Version::parse(&base_version)?
1425        .bump(decision.level)
1426        .to_string();
1427    progress.version = Some(next.clone());
1428    let branch = format!("chore/release-v{next}");
1429    let worktree = state.dir().join("bump");
1430    let (shared, branch_ref, next_ref, decision_ref) = (&mut *state, &branch, &next, &decision);
1431    let (pr_url_opened, outcome) =
1432        release_attempt(
1433            &repo,
1434            &remote,
1435            &base,
1436            &worktree,
1437            &branch,
1438            |head| {
1439                let repo = repo.clone();
1440                async move { gh_open_pr_for_head(&repo, &head).await }
1441            },
1442            |wt| async move {
1443                open_bump_pr(shared, &wt, branch_ref, next_ref, decision_ref, pr_url).await
1444            },
1445        )
1446        .await?;
1447    let local = outcome == AutomergeOutcome::LocalGate;
1448    let (automerge_warning, merged_detail) = match outcome {
1449        AutomergeOutcome::Enabled | AutomergeOutcome::LocalGate => (None, None),
1450        AutomergeOutcome::MergedDirectly { detail } => (None, Some(detail)),
1451        AutomergeOutcome::Failed { reason } => (Some(reason), None),
1452    };
1453
1454    // The pull request exists on the forge the moment `open_bump_pr` returns
1455    // its URL, regardless of what happens next - so the event that names it
1456    // is unconditional, and a marker write failing (a full disk, a missing
1457    // `home/bump` directory) is reported as its own warning rather than
1458    // swallowing that URL entirely the way propagating it with `?` would.
1459    // `find_open_release_pr` is the fallback if this leaves no local record:
1460    // the next merge that finds no marker still finds this pull request on
1461    // the forge before opening a second one.
1462    let marker_write = write_marker(
1463        &marker,
1464        &PendingBump {
1465            target_version: next.clone(),
1466            level: decision.level,
1467            branch,
1468            pr_url: pr_url_opened.clone(),
1469        },
1470    );
1471    state.event(
1472        "bump",
1473        format!(
1474            "opened a {} release bump to v{next} ({}): {pr_url_opened}",
1475            decision.level.as_str(),
1476            decision.reason
1477        ),
1478    );
1479    if let Err(e) = marker_write {
1480        state.event(
1481            "bump",
1482            format!(
1483                "could not record the pending release bump marker for v{next}: {e:#}; a later \
1484                 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
1485                 the forge either"
1486            ),
1487        );
1488    }
1489    state.release_bump = Some(run::ReleaseBump {
1490        pr_url: Some(pr_url_opened.clone()),
1491        version: Some(next.clone()),
1492        automerge_enabled: automerge_warning.is_none() && merged_detail.is_none() && !local,
1493        merged_directly: merged_detail.is_some(),
1494        local,
1495        ..run::ReleaseBump::default()
1496    });
1497    if let Some(detail) = merged_detail {
1498        // Merged already: a pending marker would make the next merge wait on
1499        // a pull request that is gone.
1500        clear_marker(&marker);
1501        state.event("bump", format!("merged v{next} directly: {detail}"));
1502    }
1503    if let Some(warning) = automerge_warning {
1504        state.event(
1505            "bump",
1506            format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
1507        );
1508        report_problem(state, Some(&pr_url_opened), Some(&next), &warning).await;
1509    }
1510    Ok(())
1511}
1512
1513/// The node name post-merge notices were filed under, back when they were
1514/// questions. Kept because [`ask::Questions::settle_run`] must go on exempting
1515/// any such question still open on disk; new problems go to
1516/// [`crate::notices`] instead - nothing here is something to answer.
1517pub const NOTICE_NODE: &str = "release-bump";
1518
1519/// What to tell the operator to do about a refused `gh pr merge --auto`.
1520///
1521/// Keys on the wording GitHub is known to use when the base branch has no
1522/// required status checks (`enablePullRequestAutoMerge` / "protected branch
1523/// rules"); anything else falls back to the generic advice, with the reason
1524/// carried verbatim next to it.
1525fn automerge_hint(reason: &str) -> &'static str {
1526    let r = reason.to_lowercase();
1527    if is_clean_status_refusal(reason) {
1528        "merge the release pull request by hand; CI is already green"
1529    } else if r.contains("enablepullrequestautomerge") || r.contains("protected branch rules") {
1530        "merge the release pull request by hand, and enable branch protection with required \
1531         status checks on the base branch so automerge can work next time"
1532    } else {
1533        "merge the release pull request by hand"
1534    }
1535}
1536
1537/// The comment left on the release pull request itself.
1538fn automerge_failure_comment(reason: &str) -> String {
1539    format!(
1540        "magi could not enable automerge on this pull request: {reason}\n\n\
1541         Action required: {}. Until then the release does not happen.",
1542        automerge_hint(reason)
1543    )
1544}
1545
1546/// Record a post-merge problem on the run and raise the operator-facing
1547/// notification, without touching the forge. Returns the notice and the
1548/// comment body meant for the release pull request when there is one.
1549///
1550/// Split from [`report_problem`] so the state, the notice and the wording
1551/// can be asserted without a `gh`. The notice is keyed on the run, so a retry
1552/// of the same failed bump folds into one entry instead of flooding the bell.
1553#[cfg(test)]
1554fn surface_problem(
1555    state: &mut RunState,
1556    store: &Notices,
1557    pr_url: Option<&str>,
1558    version: Option<&str>,
1559    reason: &str,
1560) -> Result<(Notice, Option<String>)> {
1561    surface_problem_in(state, store, pr_url, version, reason, false)
1562}
1563
1564/// [`surface_problem`], where `pending` says `pr_url` is a release pull
1565/// request that already existed and could not be raised to `version` - not one
1566/// whose automerge failed. It is linked and recorded, but gets no comment.
1567fn surface_problem_in(
1568    state: &mut RunState,
1569    store: &Notices,
1570    pr_url: Option<&str>,
1571    version: Option<&str>,
1572    reason: &str,
1573    pending: bool,
1574) -> Result<(Notice, Option<String>)> {
1575    let action = if let (true, Some(url)) = (pending, pr_url) {
1576        let ja = crate::lang::is_japanese(&state.config.graph.language);
1577        let first = reason.lines().next().filter(|l| !l.is_empty());
1578        let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1579        if ja {
1580            let target = version.map(|v| format!(" v{v}")).unwrap_or_default();
1581            format!(
1582                "既存のリリース PR {url} を{target}へ更新できませんでした{cause}。PR を手で更新してください"
1583            )
1584        } else {
1585            let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1586            format!(
1587                "the pending release pull request {url} could not be updated{target}{cause}; update it by hand"
1588            )
1589        }
1590    } else if pr_url.is_some() {
1591        automerge_hint(reason).to_owned()
1592    } else {
1593        let ja = crate::lang::is_japanese(&state.config.graph.language);
1594        let first = reason.lines().next().filter(|l| !l.is_empty());
1595        if ja {
1596            let target = version.map(|v| format!(" (v{v})")).unwrap_or_default();
1597            let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1598            format!(
1599                "リリースバンプ{target}は実行されませんでした{cause}。リリース PR を手で開いてください"
1600            )
1601        } else {
1602            let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1603            let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1604            format!(
1605                "the release bump{target} did not run{cause}; open the release pull request by hand"
1606            )
1607        }
1608    };
1609    let record = state.release_bump.get_or_insert_with(Default::default);
1610    record.pr_url = pr_url.map(str::to_owned).or(record.pr_url.take());
1611    record.version = version.map(str::to_owned).or(record.version.take());
1612    record.automerge_enabled = false;
1613    record.problem = Some(reason.to_owned());
1614    record.action_required = Some(action.clone());
1615
1616    let mut notice = Notice::error(
1617        &format!("release-bump:{}", state.id),
1618        format!(
1619            "Run {} merged, but its release step failed: {action}.",
1620            state.id
1621        ),
1622    );
1623    notice = match pr_url {
1624        Some(url) => notice.link(Link::Url {
1625            url: url.to_owned(),
1626        }),
1627        None => notice.link(Link::Run {
1628            id: state.id.clone(),
1629        }),
1630    };
1631    let notice = store
1632        .raise(notice)
1633        .context("raise the release-bump notification")?;
1634    state.event("bump", format!("needs attention: {action}"));
1635    let comment = pr_url
1636        .filter(|_| !pending)
1637        .map(|_| automerge_failure_comment(reason));
1638    Ok((notice, comment))
1639}
1640
1641/// Make a post-merge problem visible: record it, comment on the release pull
1642/// request, raise a notification and run the configured notifier. Every step
1643/// is best-effort - a failed comment or webhook is an event, never a reason to
1644/// lose the record or the run.
1645pub async fn report_problem(
1646    state: &mut RunState,
1647    pr_url: Option<&str>,
1648    version: Option<&str>,
1649    reason: &str,
1650) {
1651    report_problem_in(state, &Notices::open(), pr_url, version, reason, false).await;
1652}
1653
1654/// [`report_problem`] against an explicit notice store.
1655async fn report_problem_in(
1656    state: &mut RunState,
1657    store: &Notices,
1658    pr_url: Option<&str>,
1659    version: Option<&str>,
1660    reason: &str,
1661    pending: bool,
1662) {
1663    match surface_problem_in(state, store, pr_url, version, reason, pending) {
1664        Ok((notice, comment)) => {
1665            if let (Some(url), Some(body)) = (pr_url, comment)
1666                && let Err(e) = gh_pr_comment(state, url, &body).await
1667            {
1668                state.event("bump", format!("could not comment on {url}: {e:#}"));
1669            }
1670            // The webhook still speaks in question terms; a transient,
1671            // never-stored one carries the text so it is not lost.
1672            let summary = match pr_url {
1673                Some(url) if pending => format!("Release PR could not be updated: {url}"),
1674                Some(url) => format!("Release PR needs a human: {url}"),
1675                None if crate::lang::is_japanese(&state.config.graph.language) => {
1676                    "リリースバンプが実行されませんでした".to_owned()
1677                }
1678                None => "Release bump did not run".to_owned(),
1679            };
1680            let q = ask::Question::new(
1681                state.id.clone(),
1682                NOTICE_NODE.to_owned(),
1683                "bump".to_owned(),
1684                summary,
1685                notice.message.clone(),
1686                Vec::new(),
1687            );
1688            if let Err(e) = ask::notify(&state.config.notify, &q).await {
1689                tracing::warn!(
1690                    "could not notify about the release bump of {}: {e:#}",
1691                    state.id
1692                );
1693            }
1694        }
1695        Err(e) => state.event("bump", format!("could not raise a notice: {e:#}")),
1696    }
1697}
1698
1699pub(crate) async fn gh_pr_comment(state: &mut RunState, pr_url: &str, body: &str) -> Result<()> {
1700    let (_, body) = crate::github_text::prepare(state, "", body);
1701    let cwd = &state.repo;
1702    let out = tokio::process::Command::new("gh")
1703        .args(["pr", "comment", pr_url, "--body", &body])
1704        .current_dir(cwd)
1705        .quiet()
1706        .stdin(std::process::Stdio::null())
1707        .output()
1708        .await
1709        .context("spawn gh pr comment")?;
1710    if out.status.success() {
1711        Ok(())
1712    } else {
1713        bail!(
1714            "gh pr comment: {}",
1715            String::from_utf8_lossy(&out.stderr).trim()
1716        )
1717    }
1718}
1719
1720/// Bump an already-open release pull request further, because a change more
1721/// severe than what it already covers landed while it waited on CI or
1722/// automerge - see [`pending_action`].
1723///
1724/// Adds a second commit rather than rewriting the first: `gh pr merge
1725/// --squash` prefers a single commit's own message over the pull request's
1726/// title, and falls back to the title once there is more than one commit -
1727/// so the title is what is kept honest here, via `gh pr edit`.
1728async fn escalate_pending(
1729    state: &mut RunState,
1730    repo: &Path,
1731    remote: &str,
1732    pending: &PendingBump,
1733    decision: &BumpDecision,
1734    base_version: &str,
1735    marker: &Path,
1736) -> Result<()> {
1737    let next = Version::parse(base_version)?
1738        .bump(decision.level)
1739        .to_string();
1740    let worktree = state.dir().join("bump");
1741    git::worktree_remove(repo, &worktree).await.ok();
1742    let checked_out = git::git_raw(
1743        repo,
1744        &[
1745            "worktree",
1746            "add",
1747            "--force",
1748            &worktree.to_string_lossy(),
1749            &pending.branch,
1750        ],
1751    )
1752    .await?;
1753    if !checked_out.ok() {
1754        bail!(
1755            "checking out the pending release branch {} failed: {}",
1756            pending.branch,
1757            checked_out.stderr
1758        );
1759    }
1760
1761    // Only the substantive change - the commit landing on the remote branch
1762    // - has to succeed for the escalation to have happened at all. Anything
1763    // after the push is a follow-up, not a precondition: the branch already
1764    // carries the new version whether or not it succeeds.
1765    let pushed: Result<()> = async {
1766        let cargo_toml_path = worktree.join("Cargo.toml");
1767        let toml = tokio::fs::read_to_string(&cargo_toml_path)
1768            .await
1769            .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1770        let rewritten = rewrite_cargo_version(&toml, &next)?;
1771        tokio::fs::write(&cargo_toml_path, rewritten)
1772            .await
1773            .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1774        sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1775        let committed = git::commit_all(
1776            &worktree,
1777            &format!(
1778                "chore: release v{next} (supersedes v{})",
1779                pending.target_version
1780            ),
1781        )
1782        .await
1783        .context("commit the escalated version bump")?;
1784        if !committed {
1785            bail!("escalating the version bump left nothing to commit");
1786        }
1787        let pushed = git::push(&worktree, remote, &pending.branch).await?;
1788        if !pushed.ok() {
1789            bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1790        }
1791        Ok(())
1792    }
1793    .await;
1794    if let Err(e) = pushed {
1795        git::worktree_remove(repo, &worktree).await.ok();
1796        return Err(e);
1797    }
1798
1799    // The commit is on the remote branch now regardless of what happens
1800    // below - the title edit is cosmetic, and the marker and the event must
1801    // both reflect the real, already-pushed state even if it fails.
1802    let (title, _) = crate::github_text::prepare(
1803        state,
1804        &format!("chore: release v{next} ({} bump)", decision.level.as_str()),
1805        "",
1806    );
1807    let title_warning = match gh_pr_edit_title(&worktree, &pending.pr_url, &title).await {
1808        Ok(()) => None,
1809        Err(e) => Some(e.to_string()),
1810    };
1811    git::worktree_remove(repo, &worktree).await.ok();
1812
1813    let marker_write = write_marker(
1814        marker,
1815        &PendingBump {
1816            target_version: next.clone(),
1817            level: decision.level,
1818            branch: pending.branch.clone(),
1819            pr_url: pending.pr_url.clone(),
1820        },
1821    );
1822    state.event(
1823        "bump",
1824        format!(
1825            "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1826            pending.target_version,
1827            decision.level.as_str(),
1828            decision.reason,
1829            pending.pr_url
1830        ),
1831    );
1832    if let Err(e) = marker_write {
1833        state.event(
1834            "bump",
1835            format!(
1836                "could not update the pending release bump marker to v{next}: {e:#}; a later \
1837                 merge may misjudge whether it is already covered"
1838            ),
1839        );
1840    }
1841    if let Some(warning) = title_warning {
1842        state.event(
1843            "bump",
1844            format!(
1845                "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1846                 subject may still read the superseded version",
1847                pending.pr_url
1848            ),
1849        );
1850    }
1851    Ok(())
1852}
1853
1854/// Does an open pull request have `branch` as its head? Any head repository
1855/// counts: the question is only whether the branch name is spoken for.
1856async fn gh_open_pr_for_head(repo: &Path, branch: &str) -> Result<bool> {
1857    let out = tokio::process::Command::new("gh")
1858        .args([
1859            "pr", "list", "--head", branch, "--state", "open", "--json", "url",
1860        ])
1861        .current_dir(repo)
1862        .quiet()
1863        .stdin(std::process::Stdio::null())
1864        .output()
1865        .await
1866        .context("spawn gh pr list")?;
1867    if !out.status.success() {
1868        bail!(
1869            "gh pr list --head {branch}: {}",
1870            String::from_utf8_lossy(&out.stderr).trim()
1871        );
1872    }
1873    let prs: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout)
1874        .with_context(|| format!("parse the pull requests headed by {branch}"))?;
1875    Ok(!prs.is_empty())
1876}
1877
1878/// Create the release branch and worktree, run `fill` in it, and leave nothing
1879/// behind unless a pull request came out of it.
1880///
1881/// A leftover `chore/release-vX.Y.Z` from an earlier failed attempt is
1882/// inspected rather than fatal ([`reclaim_stale_branch`]), once per attempt. A
1883/// failure of `fill` - or of creating the worktree - removes the worktree and
1884/// the branch this attempt made, except a branch that reached the remote,
1885/// which is kept and said so in the returned error.
1886async fn release_attempt<T, F, Fut, P, PFut>(
1887    repo: &Path,
1888    remote: &str,
1889    base: &str,
1890    worktree: &Path,
1891    branch: &str,
1892    open_pr: P,
1893    fill: F,
1894) -> Result<T>
1895where
1896    F: FnOnce(PathBuf) -> Fut,
1897    Fut: std::future::Future<Output = Result<T>>,
1898    P: Fn(String) -> PFut,
1899    PFut: std::future::Future<Output = Result<bool>>,
1900{
1901    let start = format!("{remote}/{base}");
1902    git::worktree_remove(repo, worktree).await.ok();
1903    let mut retried = false;
1904    if git::branch_exists(repo, branch).await? {
1905        reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await?;
1906        retried = true;
1907    }
1908    loop {
1909        // Two steps instead of `worktree add -b`, so that who made the branch
1910        // is known from which step failed rather than guessed from git's
1911        // wording or the branch's tip: `git branch` creates the ref atomically
1912        // or fails, and only a branch it created is this attempt's.
1913        if let Err(e) = git::git(repo, &["branch", branch, &start]).await {
1914            let e = e.context("create the release branch");
1915            if !git::branch_exists(repo, branch).await.unwrap_or(false) {
1916                return Err(e);
1917            }
1918            // It appeared since the check above: somebody else's.
1919            if retried {
1920                return Err(anyhow!(
1921                    "{e:#}; left {branch} in place: it is not one this attempt created"
1922                ));
1923            }
1924            retried = true;
1925            if let Err(r) = reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await {
1926                return Err(anyhow!("{r:#} (after: {e:#})"));
1927            }
1928            continue;
1929        }
1930        if let Some(parent) = worktree.parent() {
1931            tokio::fs::create_dir_all(parent).await.ok();
1932        }
1933        let path = worktree.to_string_lossy();
1934        if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
1935            let e = e.context("create the release-bump worktree");
1936            return Err(discard_attempt(repo, remote, worktree, branch, e).await);
1937        }
1938        break;
1939    }
1940    match fill(worktree.to_path_buf()).await {
1941        Ok(v) => {
1942            // The branch lives on in the pull request; the worktree is
1943            // throwaway and a stale one would collide with the next attempt.
1944            git::worktree_remove(repo, worktree).await.ok();
1945            Ok(v)
1946        }
1947        Err(e) => Err(discard_attempt(repo, remote, worktree, branch, e).await),
1948    }
1949}
1950
1951/// Undo what a failed attempt created and fold the outcome into its error.
1952async fn discard_attempt(
1953    repo: &Path,
1954    remote: &str,
1955    worktree: &Path,
1956    branch: &str,
1957    cause: anyhow::Error,
1958) -> anyhow::Error {
1959    git::worktree_remove(repo, worktree).await.ok();
1960    if !git::branch_exists(repo, branch).await.unwrap_or(false) {
1961        return cause;
1962    }
1963    // The remote's own answer decides, not whether a push was attempted: a
1964    // push that failed half way may still have landed the ref.
1965    let note = match git::remote_has_branch(repo, remote, branch).await {
1966        Ok(false) => match git::branch_delete(repo, branch).await {
1967            Ok(true) => return cause,
1968            _ => format!("could not delete the local branch {branch}"),
1969        },
1970        Ok(true) => format!("left {branch} in place: it was pushed to {remote}"),
1971        Err(e) => format!(
1972            "left {branch} in place: could not tell whether it was pushed to {remote} ({e:#})"
1973        ),
1974    };
1975    anyhow!("{cause:#}; {note}")
1976}
1977
1978/// A branch of the release's name already exists. Delete it so the attempt can
1979/// go on if and only if it holds nothing: not on the remote, no open pull
1980/// request, and no commit that `start` (`<remote>/<base>`) lacks. Anything
1981/// unproven fails closed with a reason that names the branch.
1982async fn reclaim_stale_branch<P, PFut>(
1983    repo: &Path,
1984    remote: &str,
1985    start: &str,
1986    branch: &str,
1987    open_pr: &P,
1988) -> Result<()>
1989where
1990    P: Fn(String) -> PFut,
1991    PFut: std::future::Future<Output = Result<bool>>,
1992{
1993    match git::remote_has_branch(repo, remote, branch).await {
1994        Ok(false) => {}
1995        Ok(true) => bail!("the branch {branch} already exists and is on {remote}; left alone"),
1996        Err(e) => bail!(
1997            "the branch {branch} already exists and could not check whether {remote} has it              ({e:#}); left alone"
1998        ),
1999    }
2000    match open_pr(branch.to_string()).await {
2001        Ok(false) => {}
2002        Ok(true) => {
2003            bail!("the branch {branch} already exists and has an open pull request; left alone")
2004        }
2005        Err(e) => bail!(
2006            "the branch {branch} already exists and could not check for an open pull request \
2007             ({e:#}); left alone"
2008        ),
2009    }
2010    match git::commits_ahead(repo, start, branch).await {
2011        Ok(0) => {}
2012        Ok(n) => bail!(
2013            "the branch {branch} already exists with {n} commit(s) not in {start}; left alone"
2014        ),
2015        Err(e) => bail!(
2016            "the branch {branch} already exists and could not compare it with {start} ({e:#}); \
2017             left alone"
2018        ),
2019    }
2020    if let Some(held) = git::worktree_holding(repo, branch).await? {
2021        let same = |a: &Path, b: &Path| match (a.canonicalize(), b.canonicalize()) {
2022            (Ok(a), Ok(b)) => a == b,
2023            _ => a == b,
2024        };
2025        if same(&held, repo) {
2026            bail!(
2027                "the branch {branch} already exists and is checked out in the main checkout; left alone"
2028            );
2029        }
2030        git::worktree_remove(repo, &held).await.ok();
2031    }
2032    if !git::branch_delete(repo, branch).await? {
2033        bail!("the branch {branch} already exists and could not be deleted; left alone");
2034    }
2035    Ok(())
2036}
2037
2038/// Edit the version, let the lockfile follow, commit, push, and open the pull
2039/// request with automerge enabled. Returns the opened pull request's URL and,
2040/// when enabling automerge itself failed, a note of why - the pull request
2041/// still exists on the forge either way, and the caller must not lose track
2042/// of its URL over that failure alone.
2043async fn open_bump_pr(
2044    state: &mut RunState,
2045    worktree: &Path,
2046    branch: &str,
2047    next_version: &str,
2048    decision: &BumpDecision,
2049    source_pr_url: &str,
2050) -> Result<(String, AutomergeOutcome)> {
2051    let cargo_toml_path = worktree.join("Cargo.toml");
2052    let toml = tokio::fs::read_to_string(&cargo_toml_path)
2053        .await
2054        .with_context(|| format!("read {}", cargo_toml_path.display()))?;
2055    let rewritten = rewrite_cargo_version(&toml, next_version)?;
2056    tokio::fs::write(&cargo_toml_path, rewritten)
2057        .await
2058        .with_context(|| format!("write {}", cargo_toml_path.display()))?;
2059
2060    sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
2061
2062    let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
2063        .await
2064        .context("commit the version bump")?;
2065    if !committed {
2066        bail!("the version bump left nothing to commit");
2067    }
2068
2069    let remote = state.config.merge.remote.clone();
2070    let pushed = git::push(worktree, &remote, branch).await?;
2071    if !pushed.ok() {
2072        bail!("pushing {branch} failed: {}", pushed.stderr);
2073    }
2074
2075    let (title, body) = release_pr(
2076        decision.level.as_str(),
2077        &decision.reason,
2078        next_version,
2079        &state.id,
2080        source_pr_url,
2081    );
2082    let (title, body) = crate::github_text::prepare(state, &title, &body);
2083    let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
2084    // Without Actions nothing would ever merge it on green, and a direct merge
2085    // here would skip the owner's approval: leave it open for the release
2086    // watcher, which asks and then releases.
2087    if state.config.release.is_local() {
2088        // Watched from now on, so a pull request merged before the watcher's
2089        // first lap still gets released.
2090        crate::release_watch::register(&crate::run::home(), &state.repo, &url, &state.id);
2091        return Ok((url, AutomergeOutcome::LocalGate));
2092    }
2093    let outcome = match gh_enable_automerge(worktree, &url).await {
2094        Ok(()) => AutomergeOutcome::Enabled,
2095        Err(e) => {
2096            let reason = e.to_string();
2097            if is_clean_status_refusal(&reason) {
2098                gh_merge_directly(worktree, &url, &title, reason).await
2099            } else {
2100                AutomergeOutcome::Failed { reason }
2101            }
2102        }
2103    };
2104    Ok((url, outcome))
2105}
2106
2107/// What became of the release pull request's path to `main`.
2108#[derive(Debug, Clone, PartialEq, Eq)]
2109enum AutomergeOutcome {
2110    /// Automerge is armed; CI green will merge it.
2111    Enabled,
2112    /// CI beat us to it, so magi merged the pull request itself.
2113    MergedDirectly { detail: String },
2114    /// `[release] mode = "local"`: automerge was deliberately not armed.
2115    LocalGate,
2116    /// Neither worked; a human has to merge it.
2117    Failed { reason: String },
2118}
2119
2120/// Is this GitHub's refusal to arm automerge on a pull request that is already
2121/// mergeable? Automerge can only be enabled while something is still pending,
2122/// so a fast CI that finishes first gets `Pull request is in clean status`.
2123/// Both fragments are required so an unrelated "clean status" wording or a
2124/// branch-protection refusal does not trigger a direct merge.
2125fn is_clean_status_refusal(reason: &str) -> bool {
2126    let r = reason.to_lowercase();
2127    r.contains("is in clean status") && r.contains("enablepullrequestautomerge")
2128}
2129
2130/// The direct merge, in the same shape [`land::merge_argv`] uses but addressed
2131/// by URL: squash under the pull request's own title, delete the branch.
2132pub(crate) fn bump_merge_argv(pr_url: &str, subject: &str) -> Vec<String> {
2133    [
2134        "pr",
2135        "merge",
2136        pr_url,
2137        "--squash",
2138        "--delete-branch",
2139        "--subject",
2140        subject,
2141    ]
2142    .map(str::to_owned)
2143    .to_vec()
2144}
2145
2146/// Decide what a direct merge amounted to. No I/O. A zero exit is a merge; a
2147/// non-zero one is judged by the forge (`after`), never by the exit code, and
2148/// an unreadable forge is not evidence of success.
2149fn resolve_direct_merge(
2150    refusal: &str,
2151    argv: &[String],
2152    merge_ok: bool,
2153    stderr: &str,
2154    after: Option<land::PrLifecycle>,
2155) -> AutomergeOutcome {
2156    if merge_ok {
2157        return AutomergeOutcome::MergedDirectly {
2158            detail: format!("automerge was refused ({refusal}); gh {}", argv.join(" ")),
2159        };
2160    }
2161    match land::merged_after_all(argv, stderr, after) {
2162        Some(m) => AutomergeOutcome::MergedDirectly { detail: m.detail },
2163        None => AutomergeOutcome::Failed {
2164            reason: format!("{refusal}; merging directly failed too: {}", stderr.trim()),
2165        },
2166    }
2167}
2168
2169/// Merge the pull request directly after automerge was refused as clean.
2170/// Every failure lands in [`AutomergeOutcome::Failed`] so the caller keeps the
2171/// warning-and-comment path.
2172async fn gh_merge_directly(
2173    cwd: &Path,
2174    pr_url: &str,
2175    subject: &str,
2176    refusal: String,
2177) -> AutomergeOutcome {
2178    let argv = bump_merge_argv(pr_url, subject);
2179    let out = match tokio::process::Command::new("gh")
2180        .args(&argv)
2181        .current_dir(cwd)
2182        .quiet()
2183        .stdin(std::process::Stdio::null())
2184        .output()
2185        .await
2186    {
2187        Ok(o) => o,
2188        Err(e) => {
2189            return AutomergeOutcome::Failed {
2190                reason: format!("{refusal}; could not spawn gh to merge directly: {e}"),
2191            };
2192        }
2193    };
2194    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
2195    // jj keeps HEAD detached, so `--delete-branch` exits non-zero after the
2196    // merge has happened; ask the forge.
2197    let after = if out.status.success() {
2198        None
2199    } else {
2200        land::lifecycle(cwd, pr_url).await.ok()
2201    };
2202    resolve_direct_merge(&refusal, &argv, out.status.success(), &stderr, after)
2203}
2204
2205/// Run `cargo build` so `Cargo.lock` follows the version bump, the same step
2206/// `AGENTS.md`'s hand-driven release recipe calls for.
2207///
2208/// Not exercised by a test: it is the one step in this module that runs the
2209/// real `cargo`, which the constraints on this change rule out doing from a
2210/// test (no network, no writing outside a throwaway worktree the test itself
2211/// does not have).
2212async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
2213    let mut cmd = tokio::process::Command::new("cargo");
2214    cmd.arg("build").current_dir(worktree).quiet();
2215    if let Some(dir) = cache_dir {
2216        cmd.env("CARGO_TARGET_DIR", dir);
2217    }
2218    let out = cmd
2219        .stdin(std::process::Stdio::null())
2220        .output()
2221        .await
2222        .context("spawn cargo build")?;
2223    if !out.status.success() {
2224        bail!(
2225            "cargo build failed while syncing Cargo.lock: {}",
2226            String::from_utf8_lossy(&out.stderr).trim()
2227        );
2228    }
2229    Ok(())
2230}
2231
2232/// Title and body of a release bump pull request. Fixed English whatever
2233/// `[graph] language` says: it lands on GitHub. Pure so a test can hold it to
2234/// that. (`reason` comes from the decision seat, which the prompt tells to
2235/// write English.)
2236fn release_pr(
2237    level: &str,
2238    reason: &str,
2239    next_version: &str,
2240    run_id: &str,
2241    source_pr_url: &str,
2242) -> (String, String) {
2243    let title = format!("chore: release v{next_version} ({level} bump)");
2244    let body = format!(
2245        "## Background\n\n\
2246         A change that was just merged is a `{level}` change, so the crate needs a new \
2247         release: {reason}\n\n\
2248         Triggered by magi run `{run_id}`, which landed {source}.\n\n\
2249         ## Change\n\n\
2250         Raises the package version to `v{next_version}` in `Cargo.toml`, with \
2251         `Cargo.lock` following it. Nothing else changes.\n\n\
2252         ## Risk\n\n\
2253         Version-bump-only, so there is nothing here for a reviewer to find. Merging \
2254         it starts the release pipeline (auto-tag, then the release workflow).",
2255        source = source_pr_url,
2256    );
2257    (title, body)
2258}
2259
2260/// The merged pull request's title, for [`land::merge_subject`].
2261async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
2262    let out = tokio::process::Command::new("gh")
2263        .args(["pr", "view", pr_url, "--json", "title"])
2264        .current_dir(repo)
2265        .quiet()
2266        .stdin(std::process::Stdio::null())
2267        .output()
2268        .await
2269        .context("spawn gh pr view")?;
2270    if !out.status.success() {
2271        bail!(
2272            "gh pr view {pr_url}: {}",
2273            String::from_utf8_lossy(&out.stderr).trim()
2274        );
2275    }
2276    #[derive(Deserialize)]
2277    struct Title {
2278        title: String,
2279    }
2280    let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
2281        .context("parse `gh pr view --json title` output")?;
2282    Ok(parsed.title)
2283}
2284
2285async fn gh_pr_create(
2286    cwd: &Path,
2287    base: &str,
2288    head: &str,
2289    title: &str,
2290    body: &str,
2291) -> Result<String> {
2292    let out = tokio::process::Command::new("gh")
2293        .args([
2294            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
2295        ])
2296        .current_dir(cwd)
2297        .quiet()
2298        .stdin(std::process::Stdio::null())
2299        .output()
2300        .await
2301        .context("spawn gh pr create")?;
2302    if out.status.success() {
2303        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2304    } else {
2305        bail!(
2306            "gh pr create: {}",
2307            String::from_utf8_lossy(&out.stderr).trim()
2308        )
2309    }
2310}
2311
2312/// Enable automerge, mirroring `AGENTS.md`'s `gh pr merge --auto --squash
2313/// --delete-branch`. Never `git tag`: `auto-tag.yml` mints the tag once this
2314/// merges, and a manual tag would collide with its push.
2315async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
2316    let out = tokio::process::Command::new("gh")
2317        .args([
2318            "pr",
2319            "merge",
2320            pr_url,
2321            "--auto",
2322            "--squash",
2323            "--delete-branch",
2324        ])
2325        .current_dir(cwd)
2326        .quiet()
2327        .stdin(std::process::Stdio::null())
2328        .output()
2329        .await
2330        .context("spawn gh pr merge --auto")?;
2331    if out.status.success() {
2332        Ok(())
2333    } else {
2334        bail!(
2335            "gh pr merge --auto: {}",
2336            String::from_utf8_lossy(&out.stderr).trim()
2337        )
2338    }
2339}
2340
2341/// Rewrite a pull request's title, used when [`escalate_pending`] adds a
2342/// second commit: `gh pr merge --squash` only prefers a single commit's own
2343/// message over the title, so once there are two the title is what lands.
2344async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
2345    let out = tokio::process::Command::new("gh")
2346        .args(["pr", "edit", pr_url, "--title", title])
2347        .current_dir(cwd)
2348        .quiet()
2349        .stdin(std::process::Stdio::null())
2350        .output()
2351        .await
2352        .context("spawn gh pr edit")?;
2353    if out.status.success() {
2354        Ok(())
2355    } else {
2356        bail!(
2357            "gh pr edit --title: {}",
2358            String::from_utf8_lossy(&out.stderr).trim()
2359        )
2360    }
2361}
2362
2363#[cfg(test)]
2364mod tests {
2365    use super::*;
2366    use crate::notices::Severity;
2367
2368    #[test]
2369    fn github_facing_bump_text_is_english() {
2370        let (title, body) =
2371            release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
2372        assert!(crate::github_text::check(&title, &body).is_empty());
2373        assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
2374        assert_eq!(title, "chore: release v0.37.0 (minor bump)");
2375        assert!(
2376            body.contains("## Background") && body.contains("## Change"),
2377            "{body}"
2378        );
2379        let p = decision_prompt("s", "i", "d", &[], "0.36.5");
2380        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
2381    }
2382    use crate::config::Config;
2383    use crate::land::PrLifecycle;
2384
2385    /// `[merge] release_bump = false` must short-circuit before any I/O -
2386    /// `after_merge` is reached from a live run with a real repo and a real
2387    /// `gh`, so the disabled case is asserted with a `RunState` that would
2388    /// fail loudly (an unresolvable `/no/such/repo`) the moment anything past
2389    /// the flag check tried to touch it.
2390    #[tokio::test]
2391    async fn a_disabled_config_does_nothing() {
2392        let config = Config {
2393            merge: crate::config::Merge {
2394                release_bump: false,
2395                ..crate::config::Merge::default()
2396            },
2397            ..Config::default()
2398        };
2399        let mut state = RunState::new(
2400            PathBuf::from("/no/such/repo"),
2401            "main".to_owned(),
2402            "0000000000000000000000000000000000000000".to_owned(),
2403            "irrelevant".to_owned(),
2404            config,
2405        );
2406        after_merge(&mut state, "https://example.invalid/pull/1")
2407            .await
2408            .expect("a disabled config must return Ok without touching anything");
2409        assert!(
2410            state.events.is_empty(),
2411            "nothing should happen at all, not even a logged event"
2412        );
2413    }
2414
2415    /// A bare `origin` plus a clone of it, `main` pushed with `files`.
2416    async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
2417        let dir = tempfile::tempdir().unwrap();
2418        let origin = dir.path().join("origin.git");
2419        let repo = dir.path().join("repo");
2420        let o = origin.to_string_lossy().into_owned();
2421        git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
2422            .await
2423            .unwrap();
2424        tokio::fs::create_dir_all(&repo).await.unwrap();
2425        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
2426        git::git(&repo, &["config", "user.name", "test"])
2427            .await
2428            .unwrap();
2429        git::git(&repo, &["config", "user.email", "test@example.com"])
2430            .await
2431            .unwrap();
2432        for (name, body) in files {
2433            tokio::fs::write(repo.join(name), body).await.unwrap();
2434        }
2435        git::git(&repo, &["add", "-A"]).await.unwrap();
2436        git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
2437        git::git(&repo, &["remote", "add", "origin", &o])
2438            .await
2439            .unwrap();
2440        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
2441        (dir, repo)
2442    }
2443
2444    async fn no_pr(_: String) -> Result<bool> {
2445        Ok(false)
2446    }
2447
2448    /// `origin_with` plus a branch `b` made from `main`, optionally one commit
2449    /// ahead of it.
2450    async fn with_branch(ahead: bool) -> (tempfile::TempDir, PathBuf) {
2451        let (d, repo) = origin_with(&[("f", "x\n")]).await;
2452        git::git(&repo, &["branch", "b"]).await.unwrap();
2453        if ahead {
2454            git::git(&repo, &["checkout", "-q", "b"]).await.unwrap();
2455            git::git(&repo, &["commit", "--allow-empty", "-m", "wip"])
2456                .await
2457                .unwrap();
2458            git::git(&repo, &["checkout", "-q", "main"]).await.unwrap();
2459        }
2460        (d, repo)
2461    }
2462
2463    async fn attempt(repo: &Path, open_pr: bool, fail_after_push: Option<bool>) -> Result<()> {
2464        let wt = repo.parent().unwrap().join("bump");
2465        release_attempt(
2466            repo,
2467            "origin",
2468            "main",
2469            &wt,
2470            "b",
2471            |_| async move { Ok(open_pr) },
2472            |w| async move {
2473                if fail_after_push == Some(true) {
2474                    git::push(&w, "origin", "b").await?;
2475                }
2476                if fail_after_push.is_some() {
2477                    bail!("cargo build failed");
2478                }
2479                Ok(())
2480            },
2481        )
2482        .await
2483    }
2484
2485    #[tokio::test]
2486    async fn a_stale_ancestor_branch_is_deleted_and_the_attempt_proceeds() {
2487        let (_d, repo) = with_branch(false).await;
2488        attempt(&repo, false, None).await.unwrap();
2489        // Success keeps the branch (it backs the pull request).
2490        assert!(git::branch_exists(&repo, "b").await.unwrap());
2491    }
2492
2493    #[tokio::test]
2494    async fn a_stale_branch_holding_a_leftover_worktree_is_reclaimed() {
2495        let (_d, repo) = with_branch(false).await;
2496        let old = repo.parent().unwrap().join("old");
2497        git::git(&repo, &["worktree", "add", &old.to_string_lossy(), "b"])
2498            .await
2499            .unwrap();
2500        attempt(&repo, false, None).await.unwrap();
2501        assert!(!old.exists());
2502    }
2503
2504    #[tokio::test]
2505    async fn a_stale_branch_with_an_unmerged_commit_is_kept_with_a_reason() {
2506        let (_d, repo) = with_branch(true).await;
2507        let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2508        assert!(e.contains("`b`") || e.contains("branch b"), "{e}");
2509        assert!(e.contains("1 commit(s) not in origin/main"), "{e}");
2510        assert!(git::branch_exists(&repo, "b").await.unwrap());
2511    }
2512
2513    #[tokio::test]
2514    async fn a_stale_branch_on_the_remote_is_kept() {
2515        let (_d, repo) = with_branch(false).await;
2516        git::git(&repo, &["push", "origin", "b"]).await.unwrap();
2517        let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2518        assert!(e.contains("branch b") && e.contains("is on origin"), "{e}");
2519        assert!(git::branch_exists(&repo, "b").await.unwrap());
2520    }
2521
2522    #[tokio::test]
2523    async fn a_stale_branch_with_an_open_pull_request_is_kept() {
2524        let (_d, repo) = with_branch(false).await;
2525        let e = attempt(&repo, true, None).await.unwrap_err().to_string();
2526        assert!(e.contains("open pull request"), "{e}");
2527        assert!(git::branch_exists(&repo, "b").await.unwrap());
2528    }
2529
2530    #[tokio::test]
2531    async fn an_unanswerable_pull_request_check_keeps_the_branch() {
2532        let (_d, repo) = with_branch(false).await;
2533        let wt = repo.parent().unwrap().join("bump");
2534        let e = release_attempt(
2535            &repo,
2536            "origin",
2537            "main",
2538            &wt,
2539            "b",
2540            |_| async { bail!("offline") },
2541            |_| async { Ok(()) },
2542        )
2543        .await
2544        .unwrap_err()
2545        .to_string();
2546        assert!(
2547            e.contains("could not check for an open pull request"),
2548            "{e}"
2549        );
2550        assert!(git::branch_exists(&repo, "b").await.unwrap());
2551    }
2552
2553    #[tokio::test]
2554    async fn a_failed_attempt_removes_its_own_worktree_and_branch() {
2555        let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2556        let wt = repo.parent().unwrap().join("bump");
2557        let e = attempt(&repo, false, Some(false)).await.unwrap_err();
2558        assert!(format!("{e:#}").contains("cargo build failed"));
2559        assert!(!wt.exists());
2560        assert!(!git::branch_exists(&repo, "b").await.unwrap());
2561    }
2562
2563    #[tokio::test]
2564    async fn a_pushed_branch_survives_a_failed_attempt_and_the_reason_says_so() {
2565        let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2566        let e = attempt(&repo, false, Some(true)).await.unwrap_err();
2567        let e = format!("{e:#}");
2568        assert!(
2569            e.contains("cargo build failed") && e.contains("pushed to origin"),
2570            "{e}"
2571        );
2572        assert!(git::branch_exists(&repo, "b").await.unwrap());
2573    }
2574
2575    #[tokio::test]
2576    async fn a_branch_left_by_a_half_done_worktree_add_is_removed() {
2577        // No pre-existing branch; the worktree path sits under a regular
2578        // file, so `worktree add -b` may create the branch and then fail.
2579        let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2580        let blocker = repo.parent().unwrap().join("blocker");
2581        tokio::fs::write(&blocker, "file").await.unwrap();
2582        let wt = blocker.join("bump");
2583        let r = release_attempt(
2584            &repo,
2585            "origin",
2586            "main",
2587            &wt,
2588            "b",
2589            |_| async { bail!("offline") },
2590            |_| async { Ok(()) },
2591        )
2592        .await;
2593        assert!(r.is_err());
2594        assert!(!git::branch_exists(&repo, "b").await.unwrap());
2595    }
2596
2597    #[tokio::test]
2598    async fn the_retry_is_taken_at_most_once() {
2599        // The branch is reclaimed, but the worktree path cannot be created, so
2600        // the second creation fails too: no third try, and no loop.
2601        let (_d, repo) = with_branch(false).await;
2602        let blocker = repo.parent().unwrap().join("blocker");
2603        tokio::fs::write(&blocker, "file").await.unwrap();
2604        let wt = blocker.join("bump");
2605        let r = release_attempt(&repo, "origin", "main", &wt, "b", no_pr, |_| async {
2606            Ok(())
2607        })
2608        .await;
2609        assert!(r.is_err());
2610        assert!(!git::branch_exists(&repo, "b").await.unwrap());
2611    }
2612
2613    #[tokio::test]
2614    async fn base_has_cargo_toml_tells_rust_from_non_rust() {
2615        let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
2616        assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
2617        let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
2618        assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
2619        // An unresolvable ref is a fault, not "not Rust".
2620        assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
2621    }
2622
2623    #[tokio::test]
2624    async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
2625        let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2626        let home = tempfile::tempdir().unwrap();
2627        let mut state = RunState::new(
2628            repo.clone(),
2629            "main".to_owned(),
2630            "0000000000000000000000000000000000000000".to_owned(),
2631            "task".to_owned(),
2632            Config::default(),
2633        );
2634        state.candidates.push(crate::run::Candidate {
2635            index: 0,
2636            label: 'A',
2637            agent: "x".to_owned(),
2638            branch: "main".to_owned(),
2639            worktree: repo.clone(),
2640            summary: String::new(),
2641            stat: String::new(),
2642            files: 1,
2643            commits: 1,
2644            empty: false,
2645            failed: None,
2646            verified_noop: None,
2647            duration_ms: 0,
2648            folded: false,
2649        });
2650        state.tally = Some(
2651            serde_json::from_value(serde_json::json!({
2652                "first_choice": {}, "borda": {}, "winner": "A",
2653                "unanimous_initial": true, "deliberated": false,
2654                "changed_votes": 0, "unanimous_final": true,
2655            }))
2656            .unwrap(),
2657        );
2658        after_merge_at(
2659            &mut state,
2660            "https://example.invalid/pull/1",
2661            Some(home.path()),
2662            None,
2663        )
2664        .await
2665        .expect("a non-Rust repository is not an error");
2666        let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
2667        assert_eq!(bumps.len(), 1, "{:?}", state.events);
2668        assert_eq!(
2669            bumps[0].message,
2670            "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
2671        );
2672        assert!(
2673            std::fs::read_dir(home.path()).unwrap().next().is_none(),
2674            "no marker and no lock may be created"
2675        );
2676    }
2677
2678    fn winner_state(repo: &Path, base: &str) -> RunState {
2679        let mut state = RunState::new(
2680            repo.to_path_buf(),
2681            base.to_owned(),
2682            "0000000000000000000000000000000000000000".to_owned(),
2683            "task".to_owned(),
2684            Config::default(),
2685        );
2686        state.candidates.push(crate::run::Candidate {
2687            index: 0,
2688            label: 'A',
2689            agent: "x".to_owned(),
2690            branch: "main".to_owned(),
2691            worktree: repo.to_path_buf(),
2692            summary: String::new(),
2693            stat: String::new(),
2694            files: 1,
2695            commits: 1,
2696            empty: false,
2697            failed: None,
2698            verified_noop: None,
2699            duration_ms: 0,
2700            folded: false,
2701        });
2702        state.tally = Some(
2703            serde_json::from_value(serde_json::json!({
2704                "first_choice": {}, "borda": {}, "winner": "A",
2705                "unanimous_initial": true, "deliberated": false,
2706                "changed_votes": 0, "unanimous_final": true,
2707            }))
2708            .unwrap(),
2709        );
2710        state
2711    }
2712
2713    #[tokio::test]
2714    async fn a_real_failure_without_a_pr_raises_one_notice_and_a_retry_folds_into_it() {
2715        let (_d, repo) =
2716            origin_with(&[("Cargo.toml", "[package]\nname=\"x\"\nversion=\"0.1.0\"\n")]).await;
2717        let home = tempfile::tempdir().unwrap();
2718        let store = Notices::at(home.path().join("notifications"));
2719        // An unresolvable base is a fault, not "not eligible".
2720        let mut state = winner_state(&repo, "nope");
2721        let url = "https://example.invalid/pull/1";
2722        after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2723            .await
2724            .expect_err("an unresolvable base is a failure");
2725        let listed = store.list();
2726        assert_eq!(listed.len(), 1, "{listed:?}");
2727        assert!(
2728            listed[0].message.contains("did not run"),
2729            "{}",
2730            listed[0].message
2731        );
2732        assert!(
2733            listed[0].message.contains("Cargo.toml"),
2734            "{}",
2735            listed[0].message
2736        );
2737        assert!(state.events.iter().any(|e| e.node == "bump"));
2738        after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2739            .await
2740            .expect_err("still failing");
2741        let listed = store.list();
2742        assert_eq!(listed.len(), 1, "{listed:?}");
2743        assert_eq!(listed[0].count, 2);
2744    }
2745
2746    #[tokio::test]
2747    async fn not_eligible_cases_raise_no_notice() {
2748        let home = tempfile::tempdir().unwrap();
2749        let store = Notices::at(home.path().join("notifications"));
2750        let url = "https://example.invalid/pull/1";
2751        // No Cargo.toml on the base branch.
2752        let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2753        let mut state = winner_state(&repo, "main");
2754        after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2755            .await
2756            .unwrap();
2757        // Disabled.
2758        let mut state = winner_state(&repo, "nope");
2759        state.config.merge.release_bump = false;
2760        after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2761            .await
2762            .unwrap();
2763        // No winner.
2764        let mut state = winner_state(&repo, "nope");
2765        state.tally = None;
2766        after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2767            .await
2768            .unwrap();
2769        assert!(store.list().is_empty(), "{:?}", store.list());
2770    }
2771
2772    #[test]
2773    fn the_no_pr_notice_follows_the_configured_language() {
2774        let dir = tempfile::tempdir().unwrap();
2775        let store = Notices::at(dir.path().join("notifications"));
2776        let mut state = merged_state();
2777        state.config.graph.language = "ja".to_owned();
2778        let (n, _) = surface_problem(&mut state, &store, None, Some("0.2.0"), "boom").unwrap();
2779        assert!(n.message.contains("実行されませんでした"), "{}", n.message);
2780        assert!(n.message.contains("boom") && n.message.contains("v0.2.0"));
2781    }
2782
2783    #[test]
2784    fn a_failed_escalation_points_at_the_pending_pr_without_commenting() {
2785        let dir = tempfile::tempdir().unwrap();
2786        let store = Notices::at(dir.path().join("notifications"));
2787        let mut state = merged_state();
2788        let url = "https://example.invalid/pull/9";
2789        let (n, comment) = surface_problem_in(
2790            &mut state,
2791            &store,
2792            Some(url),
2793            Some("0.3.0"),
2794            "push failed",
2795            true,
2796        )
2797        .unwrap();
2798        assert!(comment.is_none());
2799        assert!(
2800            n.message.contains(url) && n.message.contains("v0.3.0"),
2801            "{}",
2802            n.message
2803        );
2804        assert!(!n.message.contains("open the release pull request by hand"));
2805        assert!(matches!(n.link, Some(Link::Url { .. })));
2806    }
2807
2808    #[test]
2809    fn a_failure_notice_names_the_pending_pr_and_the_first_line_of_the_cause() {
2810        let err = anyhow!("outer context").context("cargo build failed\nsecond line");
2811        let with_pr = Progress {
2812            version: Some("0.2.0".into()),
2813            pending_pr: Some("https://example.invalid/pull/9".into()),
2814        };
2815        let (v, reason) = notice_for_failure(&with_pr, &err);
2816        assert_eq!(v.as_deref(), Some("0.2.0"));
2817        assert_eq!(reason, "cargo build failed");
2818        let (v, reason) = notice_for_failure(&Progress::default(), &err);
2819        assert_eq!(v, None);
2820        assert_eq!(reason, "cargo build failed");
2821    }
2822
2823    const NO_RULES: &str = "gh pr merge --auto: GraphQL: Pull request Branch does not have \
2824                            required protected branch rules (enablePullRequestAutoMerge)";
2825
2826    fn merged_state() -> RunState {
2827        // `report::run` prints `state.dir()`; pin the global home like the
2828        // report tests do so nothing reaches the operator's real one.
2829        run::pin_test_home();
2830        let mut s = RunState::new(
2831            PathBuf::from("/no/such/repo"),
2832            "main".to_owned(),
2833            "0000000000000000000000000000000000000000".to_owned(),
2834            "task".to_owned(),
2835            Config::default(),
2836        );
2837        s.status = RunStatus::Merged;
2838        s
2839    }
2840
2841    #[test]
2842    fn the_known_automerge_refusal_names_branch_protection() {
2843        assert!(automerge_hint(NO_RULES).contains("branch protection with required"));
2844        let other = automerge_hint("gh: network unreachable");
2845        assert!(!other.contains("branch protection"), "{other}");
2846        let body = automerge_failure_comment(NO_RULES);
2847        assert!(body.contains("enablePullRequestAutoMerge"), "{body}");
2848        assert!(body.contains("Action required"), "{body}");
2849    }
2850
2851    const CLEAN: &str = "gh pr merge --auto: GraphQL: Pull request Pull request is in clean \
2852                         status (enablePullRequestAutoMerge)";
2853
2854    #[test]
2855    fn clean_status_refusal_is_matched_narrowly() {
2856        assert!(is_clean_status_refusal(CLEAN));
2857        assert!(!is_clean_status_refusal(NO_RULES));
2858        assert!(!is_clean_status_refusal("gh: network unreachable"));
2859        assert!(!is_clean_status_refusal("Pull request is in clean status"));
2860        assert!(automerge_hint(CLEAN).contains("already green"));
2861    }
2862
2863    #[test]
2864    fn the_direct_merge_argv_matches_the_land_flags() {
2865        let a = bump_merge_argv("https://github.com/o/r/pull/9", "chore: release v1.0.0");
2866        let l = land::merge_argv(9, "chore: release v1.0.0");
2867        assert_eq!(a[..2], l[..2]);
2868        assert_eq!(a[3..], l[3..]);
2869        assert_eq!(a[2], "https://github.com/o/r/pull/9");
2870    }
2871
2872    #[test]
2873    fn a_direct_merge_is_judged_by_the_forge_not_the_exit_code() {
2874        let argv = bump_merge_argv("u", "t");
2875        let merged = |o: &AutomergeOutcome| matches!(o, AutomergeOutcome::MergedDirectly { .. });
2876        assert!(merged(&resolve_direct_merge(CLEAN, &argv, true, "", None)));
2877        let detached = "not on any branch";
2878        assert!(merged(&resolve_direct_merge(
2879            CLEAN,
2880            &argv,
2881            false,
2882            detached,
2883            Some(PrLifecycle::Merged)
2884        )));
2885        for after in [Some(PrLifecycle::Open), None] {
2886            let o = resolve_direct_merge(CLEAN, &argv, false, "boom", after);
2887            match o {
2888                AutomergeOutcome::Failed { reason } => {
2889                    assert!(
2890                        reason.contains("clean status") && reason.contains("boom"),
2891                        "{reason}"
2892                    )
2893                }
2894                other => panic!("expected Failed, got {other:?}"),
2895            }
2896        }
2897    }
2898
2899    #[test]
2900    fn a_local_mode_bump_reads_back_and_old_records_are_not_local() {
2901        let old: run::ReleaseBump =
2902            serde_json::from_str(r#"{"pr_url":"u","automerge_enabled":true}"#).unwrap();
2903        assert!(!old.local);
2904        let mut state = merged_state();
2905        state.release_bump = Some(run::ReleaseBump {
2906            pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
2907            version: Some("1.0.0".to_owned()),
2908            local: true,
2909            ..run::ReleaseBump::default()
2910        });
2911        // Not a failure: nothing was supposed to be armed.
2912        assert!(!state.needs_attention());
2913        let text = crate::report::run(&state);
2914        assert!(text.contains("release.mode = local"), "{text}");
2915        assert!(!text.contains("FAILED"), "{text}");
2916    }
2917
2918    #[test]
2919    fn a_directly_merged_bump_is_not_reported_as_pending_or_failed() {
2920        let mut state = merged_state();
2921        state.release_bump = Some(run::ReleaseBump {
2922            pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
2923            version: Some("1.0.0".to_owned()),
2924            merged_directly: true,
2925            ..run::ReleaseBump::default()
2926        });
2927        assert!(!state.needs_attention());
2928        let text = crate::report::run(&state);
2929        assert!(text.contains("merged directly"), "{text}");
2930        assert!(!text.contains("FAILED"), "{text}");
2931    }
2932
2933    #[test]
2934    fn an_automerge_failure_is_recorded_shown_and_filed_and_survives_settling() {
2935        let dir = tempfile::tempdir().unwrap();
2936        let store = Notices::at(dir.path().join("notifications"));
2937        let questions = ask::Questions::at(dir.path().join("questions"));
2938        let mut state = merged_state();
2939        let url = "https://github.com/o/r/pull/35";
2940
2941        let (n, comment) =
2942            surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
2943
2944        // The comment goes on the release PR and carries reason and fix.
2945        let comment = comment.expect("a PR was opened, so it gets a comment");
2946        assert!(comment.contains("branch protection"), "{comment}");
2947
2948        // The run is still Merged, but no longer reads as plain green.
2949        assert_eq!(state.status, RunStatus::Merged);
2950        assert!(state.needs_attention());
2951        let text = crate::report::run(&state);
2952        assert!(text.contains("release bump"), "{text}");
2953        assert!(text.contains("FAILED"), "{text}");
2954        assert!(text.contains(url), "{text}");
2955        assert!(text.contains("action required"), "{text}");
2956        assert!(crate::report::line(&state).contains("release needs a human"));
2957
2958        // Exactly one notification, linked to the PR, and no question.
2959        assert_eq!(n.severity, Severity::Error);
2960        assert_eq!(
2961            n.link,
2962            Some(Link::Url {
2963                url: url.to_owned()
2964            })
2965        );
2966        assert_eq!(store.list().len(), 1);
2967        assert!(questions.open_for(&state.id).is_empty());
2968
2969        // A retry of the same failure folds into the same entry.
2970        surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
2971        let listed = store.list();
2972        assert_eq!(listed.len(), 1);
2973        assert_eq!(listed[0].count, 2);
2974    }
2975
2976    #[test]
2977    fn a_bump_that_never_ran_is_surfaced_without_a_pr_comment() {
2978        let dir = tempfile::tempdir().unwrap();
2979        let store = Notices::at(dir.path().join("notifications"));
2980        let mut state = merged_state();
2981        let (n, comment) = surface_problem(&mut state, &store, None, None, "no agent").unwrap();
2982        assert!(comment.is_none());
2983        assert!(matches!(n.link, Some(Link::Run { .. })));
2984        assert!(state.needs_attention());
2985    }
2986
2987    #[test]
2988    fn version_parses_and_bumps_each_digit() {
2989        let v = Version::parse("0.4.0").unwrap();
2990        assert_eq!(
2991            v,
2992            Version {
2993                major: 0,
2994                minor: 4,
2995                patch: 0
2996            }
2997        );
2998
2999        assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
3000        assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
3001        assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
3002    }
3003
3004    #[test]
3005    fn version_tolerates_a_prerelease_suffix_on_patch() {
3006        let v = Version::parse("1.2.3-rc1").unwrap();
3007        assert_eq!(
3008            v,
3009            Version {
3010                major: 1,
3011                minor: 2,
3012                patch: 3
3013            }
3014        );
3015    }
3016
3017    #[test]
3018    fn version_rejects_garbage() {
3019        assert!(Version::parse("not-a-version").is_err());
3020        assert!(Version::parse("1.2").is_err());
3021    }
3022
3023    #[test]
3024    fn decision_parses_each_level() {
3025        for (json, level) in [
3026            (
3027                r#"{"level":"major","reason":"drops a config key"}"#,
3028                BumpLevel::Major,
3029            ),
3030            (
3031                r#"{"level":"minor","reason":"adds a new flag"}"#,
3032                BumpLevel::Minor,
3033            ),
3034            (
3035                r#"{"level":"patch","reason":"fixes a race"}"#,
3036                BumpLevel::Patch,
3037            ),
3038        ] {
3039            let decision = parse_decision(json).unwrap();
3040            assert_eq!(decision.level, level);
3041            assert!(!decision.reason.is_empty());
3042        }
3043    }
3044
3045    #[test]
3046    fn decision_wrapped_in_a_fence_and_prose_still_parses() {
3047        let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
3048        let decision = parse_decision(text).unwrap();
3049        assert_eq!(decision.level, BumpLevel::Minor);
3050        assert_eq!(decision.reason, "new HTTP route");
3051    }
3052
3053    #[test]
3054    fn a_broken_reply_is_an_error_not_a_default() {
3055        assert!(parse_decision("I decline to answer.").is_err());
3056        assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
3057        assert!(
3058            parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
3059            "an empty reason must not pass either"
3060        );
3061        assert!(
3062            parse_decision(r#"{"level":"patch"}"#).is_err(),
3063            "a reply with no reason at all must not pass"
3064        );
3065    }
3066
3067    #[test]
3068    fn prompt_states_the_zero_x_rule_and_the_tie_break() {
3069        let prompt = decision_prompt(
3070            "feat: add a phone endpoint",
3071            "add POST /api/widgets",
3072            "1 file changed, 10 insertions(+)",
3073            &["src/web.rs".to_owned()],
3074            "0.8.0",
3075        );
3076        assert!(prompt.contains("0.8.0"), "the current version is stated");
3077        assert!(
3078            prompt.contains("below `1.0.0`")
3079                && prompt.contains("`minor` is the digit that carries a breaking change"),
3080            "the 0.x rule must be explicit: {prompt}"
3081        );
3082        assert!(
3083            prompt.contains("choose the larger"),
3084            "the tie-break toward the bigger digit must be explicit: {prompt}"
3085        );
3086    }
3087
3088    #[test]
3089    fn release_only_diffs_are_recognised() {
3090        assert!(is_release_only(&["Cargo.toml".to_owned()]));
3091        assert!(is_release_only(&[
3092            "Cargo.toml".to_owned(),
3093            "Cargo.lock".to_owned()
3094        ]));
3095        assert!(!is_release_only(&[]));
3096        assert!(!is_release_only(&[
3097            "Cargo.toml".to_owned(),
3098            "src/main.rs".to_owned()
3099        ]));
3100    }
3101
3102    #[test]
3103    fn cargo_version_rewrite_touches_only_the_package_table() {
3104        let toml = "\
3105[package]\n\
3106# a comment mentioning version on purpose\n\
3107name = \"magi-cli\"\n\
3108version = \"0.8.0\"\n\
3109edition = \"2024\"\n\
3110\n\
3111[dependencies]\n\
3112foo = { version = \"1.2.3\" }\n";
3113        let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
3114        assert!(out.contains("version = \"0.9.0\""));
3115        assert!(
3116            out.contains("foo = { version = \"1.2.3\" }"),
3117            "a dependency's own version pin must survive: {out}"
3118        );
3119        assert!(
3120            out.contains("# a comment mentioning version on purpose"),
3121            "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
3122        );
3123        assert_eq!(
3124            out.lines().count(),
3125            toml.lines().count(),
3126            "the rewrite replaces one line, it does not add or remove any"
3127        );
3128    }
3129
3130    #[test]
3131    fn cargo_version_rewrite_fails_without_a_package_table() {
3132        let toml = "[dependencies]\nfoo = \"1\"\n";
3133        assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
3134    }
3135
3136    /// The shape `kanadehq/kanade` has: a workspace root with member crates
3137    /// but no crate of its own, so `[package]` never exists and the version
3138    /// lives under `[workspace.package]` alone. Before this fell back,
3139    /// `rewrite_cargo_version` bailed on every such repository and no bump
3140    /// pull request was ever opened for it.
3141    #[test]
3142    fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
3143        let toml = "\
3144[workspace]\n\
3145members = [\"crates/a\", \"crates/b\"]\n\
3146\n\
3147[workspace.package]\n\
3148version = \"0.45.18\"\n\
3149edition = \"2024\"\n\
3150\n\
3151[workspace.dependencies]\n\
3152foo = { version = \"1.2.3\" }\n";
3153        let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
3154        assert!(out.contains("version = \"0.45.19\""));
3155        assert!(
3156            out.contains("foo = { version = \"1.2.3\" }"),
3157            "a workspace dependency's own version pin must survive: {out}"
3158        );
3159        assert_eq!(
3160            out.lines().count(),
3161            toml.lines().count(),
3162            "the rewrite replaces one line, it does not add or remove any"
3163        );
3164    }
3165
3166    /// The exact failure that happened on `kanadehq/kanade`: an internal
3167    /// member referenced by both `path` and `version` must have its pin
3168    /// bumped alongside `[workspace.package]`, in the same edit.
3169    #[test]
3170    fn cargo_version_rewrite_bumps_an_internal_workspace_dependency_pin() {
3171        let toml = "\
3172[workspace]\n\
3173members = [\"crates/kanade-shared\"]\n\
3174\n\
3175[workspace.package]\n\
3176version = \"0.48.2\"\n\
3177\n\
3178[workspace.dependencies]\n\
3179kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n";
3180        let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3181        assert!(out.contains("[workspace.package]\nversion = \"0.48.3\"\n"));
3182        assert!(
3183            out.contains(
3184                "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3185            ),
3186            "the internal pin must move with the workspace version: {out}"
3187        );
3188    }
3189
3190    /// Key order inside the inline table must not matter.
3191    #[test]
3192    fn cargo_version_rewrite_bumps_an_internal_pin_with_version_before_path() {
3193        let toml = "\
3194[workspace.package]\n\
3195version = \"1.0.0\"\n\
3196\n\
3197[workspace.dependencies]\n\
3198inner = { version = \"1.0.0\", path = \"crates/inner\" }\n";
3199        let out = rewrite_cargo_version(toml, "1.0.1").unwrap();
3200        assert!(out.contains("inner = { version = \"1.0.1\", path = \"crates/inner\" }"));
3201    }
3202
3203    /// The dotted-table form (`[workspace.dependencies.name]`) must be
3204    /// rewritten too, not only the inline-table form.
3205    #[test]
3206    fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_table_form() {
3207        let toml = "\
3208[workspace.package]\n\
3209version = \"2.3.0\"\n\
3210\n\
3211[workspace.dependencies.inner]\n\
3212path = \"crates/inner\"\n\
3213version = \"2.3.0\"\n";
3214        let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3215        assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3216        assert!(out.contains(
3217            "[workspace.dependencies.inner]\npath = \"crates/inner\"\nversion = \"2.4.0\"\n"
3218        ));
3219    }
3220
3221    /// The dotted-key form written as two separate lines directly under the
3222    /// flat `[workspace.dependencies]` table - no inline table, no
3223    /// `[workspace.dependencies.name]` sub-header - is also valid TOML and
3224    /// must have its `version` line rewritten.
3225    #[test]
3226    fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_key_form() {
3227        let toml = "\
3228[workspace.package]\n\
3229version = \"2.3.0\"\n\
3230\n\
3231[workspace.dependencies]\n\
3232inner.path = \"crates/inner\"\n\
3233inner.version = \"2.3.0\"\n";
3234        let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3235        assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3236        assert!(out.contains("inner.path = \"crates/inner\"\ninner.version = \"2.4.0\"\n"));
3237    }
3238
3239    /// A workspace-only member with `path` but no `version` is unpublished
3240    /// and must be left exactly alone.
3241    #[test]
3242    fn cargo_version_rewrite_leaves_a_path_only_workspace_dependency_untouched() {
3243        let toml = "\
3244[workspace.package]\n\
3245version = \"0.1.0\"\n\
3246\n\
3247[workspace.dependencies]\n\
3248internal-only = { path = \"crates/internal-only\" }\n";
3249        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3250        assert!(out.contains("internal-only = { path = \"crates/internal-only\" }"));
3251    }
3252
3253    /// An external crates.io dependency, `version` but no `path`, must never
3254    /// be touched by the internal-pin logic even when it sits in
3255    /// `[workspace.dependencies]` alongside a real internal pin.
3256    #[test]
3257    fn cargo_version_rewrite_leaves_an_external_dependency_untouched() {
3258        let toml = "\
3259[workspace.package]\n\
3260version = \"0.1.0\"\n\
3261\n\
3262[workspace.dependencies]\n\
3263serde = { version = \"1\", features = [\"derive\"] }\n\
3264inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3265        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3266        assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3267        assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3268    }
3269
3270    /// A manifest with no `[workspace.dependencies]` table at all must keep
3271    /// bumping `[workspace.package]` exactly as before - regression guard
3272    /// for the pre-existing behaviour this change extends.
3273    #[test]
3274    fn cargo_version_rewrite_without_workspace_dependencies_table_still_bumps_package() {
3275        let toml = "[workspace.package]\nversion = \"0.9.0\"\nedition = \"2024\"\n";
3276        let out = rewrite_cargo_version(toml, "0.10.0").unwrap();
3277        assert_eq!(
3278            out,
3279            "[workspace.package]\nversion = \"0.10.0\"\nedition = \"2024\"\n"
3280        );
3281    }
3282
3283    /// The `kanadehq/kanade` shape itself: several internal-looking and
3284    /// external entries mixed in one `[workspace.dependencies]` table.
3285    #[test]
3286    fn cargo_version_rewrite_handles_a_kanade_shaped_workspace_dependencies_table() {
3287        let toml = "\
3288[workspace.package]\n\
3289version = \"0.48.2\"\n\
3290\n\
3291[workspace.dependencies]\n\
3292anyhow = { version = \"1\" }\n\
3293serde = { version = \"1\", features = [\"derive\"] }\n\
3294kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n\
3295kanade-core = { path = \"crates/kanade-core\", version = \"0.48.2\" }\n\
3296kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }\n";
3297        let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3298        assert!(out.contains("anyhow = { version = \"1\" }"));
3299        assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3300        assert!(
3301            out.contains(
3302                "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3303            )
3304        );
3305        assert!(
3306            out.contains("kanade-core = { path = \"crates/kanade-core\", version = \"0.48.3\" }")
3307        );
3308        assert!(out.contains("kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }"));
3309    }
3310
3311    /// A shape the line-based rewrite cannot express - an inline table split
3312    /// across lines - must fail the whole bump rather than silently leave
3313    /// the pin stale.
3314    #[test]
3315    fn cargo_version_rewrite_bails_on_an_unrepresentable_inline_table() {
3316        let toml = "\
3317[workspace.package]\n\
3318version = \"0.1.0\"\n\
3319\n\
3320[workspace.dependencies]\n\
3321inner = { path = \"crates/inner\",\n\
3322    version = \"0.1.0\" }\n";
3323        assert!(rewrite_cargo_version(toml, "0.2.0").is_err());
3324    }
3325
3326    /// A trailing comment on the `[workspace.dependencies]` header itself is
3327    /// valid TOML and must not stop the section from being recognised.
3328    #[test]
3329    fn cargo_version_rewrite_recognises_a_commented_workspace_dependencies_header() {
3330        let toml = "\
3331[workspace.package]\n\
3332version = \"0.1.0\"\n\
3333\n\
3334[workspace.dependencies] # internal pins\n\
3335inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3336        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3337        assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3338    }
3339
3340    /// A quoted dependency key (`"inner" = { .. }`) is valid TOML and must
3341    /// compare equal to the plain name the `toml` crate itself reports.
3342    #[test]
3343    fn cargo_version_rewrite_bumps_a_quoted_dependency_key() {
3344        let toml = "\
3345[workspace.package]\n\
3346version = \"0.1.0\"\n\
3347\n\
3348[workspace.dependencies]\n\
3349\"inner\" = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3350        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3351        assert!(out.contains("\"inner\" = { path = \"crates/inner\", version = \"0.2.0\" }"));
3352    }
3353
3354    /// A literal (single-quoted) TOML string is a legal way to spell a
3355    /// version pin, and the rewrite must preserve that quoting style rather
3356    /// than failing to find it or silently switching it to a basic string.
3357    #[test]
3358    fn cargo_version_rewrite_bumps_a_literal_string_version_pin() {
3359        let toml = "\
3360[workspace.package]\n\
3361version = \"0.1.0\"\n\
3362\n\
3363[workspace.dependencies]\n\
3364inner = { path = 'crates/inner', version = '0.1.0' }\n";
3365        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3366        assert!(out.contains("inner = { path = 'crates/inner', version = '0.2.0' }"));
3367    }
3368
3369    /// A manifest whose unrelated sections would not satisfy a strict
3370    /// whole-file TOML parse (here: a duplicate top-level table, which the
3371    /// `toml` crate rejects) must still have its `[workspace.dependencies]`
3372    /// pins rewritten - only that section's own syntax is a precondition,
3373    /// not the rest of the file.
3374    #[test]
3375    fn cargo_version_rewrite_does_not_require_the_whole_file_to_parse() {
3376        let toml = "\
3377[workspace.package]\n\
3378version = \"0.1.0\"\n\
3379\n\
3380[workspace.dependencies]\n\
3381inner = { path = \"crates/inner\", version = \"0.1.0\" }\n\
3382\n\
3383[workspace.package]\n\
3384edition = \"2024\"\n";
3385        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3386        assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3387        assert!(
3388            toml::from_str::<toml::Value>(toml).is_err(),
3389            "the fixture itself must be invalid as a whole file, or this test proves nothing"
3390        );
3391    }
3392
3393    #[test]
3394    fn current_version_prefers_the_package_table_when_both_exist() {
3395        let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
3396        assert_eq!(current_version(toml).unwrap(), "0.8.0");
3397    }
3398
3399    /// Same shape as `kanadehq/kanade`'s root `Cargo.toml`: no `[package]`
3400    /// at all, only `[workspace]` and `[workspace.package]`.
3401    #[test]
3402    fn current_version_falls_back_to_workspace_package_without_a_package_table() {
3403        let toml = "\
3404[workspace]\n\
3405members = [\"crates/a\", \"crates/b\"]\n\
3406\n\
3407[workspace.package]\n\
3408version = \"0.45.18\"\n";
3409        assert_eq!(current_version(toml).unwrap(), "0.45.18");
3410    }
3411
3412    #[test]
3413    fn coalesce_proceeds_with_nothing_pending() {
3414        assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
3415    }
3416
3417    /// A minimal, otherwise-plausible pending marker for tests that only
3418    /// care about one field.
3419    fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
3420        PendingBump {
3421            target_version: target_version.to_owned(),
3422            level,
3423            branch: format!("chore/release-v{target_version}"),
3424            pr_url: "https://example.invalid/pull/9".to_owned(),
3425        }
3426    }
3427
3428    #[test]
3429    fn coalesce_skips_while_the_pending_target_is_still_ahead() {
3430        let pending = test_pending("0.9.0", BumpLevel::Minor);
3431        assert_eq!(
3432            coalesce(Some(&pending), "0.8.0").unwrap(),
3433            Coalesce::Skip {
3434                target_version: "0.9.0".to_owned()
3435            }
3436        );
3437    }
3438
3439    #[test]
3440    fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
3441        let pending = test_pending("0.9.0", BumpLevel::Minor);
3442        // The pending bump landed exactly: proceed with a fresh decision.
3443        assert_eq!(
3444            coalesce(Some(&pending), "0.9.0").unwrap(),
3445            Coalesce::Proceed
3446        );
3447        // A human bumped further than what was pending: also proceed.
3448        assert_eq!(
3449            coalesce(Some(&pending), "1.0.0").unwrap(),
3450            Coalesce::Proceed
3451        );
3452    }
3453
3454    #[test]
3455    fn pending_action_escalates_only_for_a_more_severe_decision() {
3456        assert_eq!(
3457            pending_action(BumpLevel::Patch, BumpLevel::Patch),
3458            PendingAction::AlreadyCovered
3459        );
3460        assert_eq!(
3461            pending_action(BumpLevel::Patch, BumpLevel::Minor),
3462            PendingAction::Escalate
3463        );
3464        assert_eq!(
3465            pending_action(BumpLevel::Patch, BumpLevel::Major),
3466            PendingAction::Escalate
3467        );
3468        assert_eq!(
3469            pending_action(BumpLevel::Minor, BumpLevel::Patch),
3470            PendingAction::AlreadyCovered
3471        );
3472        assert_eq!(
3473            pending_action(BumpLevel::Major, BumpLevel::Minor),
3474            PendingAction::AlreadyCovered
3475        );
3476        assert_eq!(
3477            pending_action(BumpLevel::Major, BumpLevel::Major),
3478            PendingAction::AlreadyCovered
3479        );
3480    }
3481
3482    #[test]
3483    fn pr_state_parsing_reads_open_and_not_open() {
3484        assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
3485        assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
3486        assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
3487    }
3488
3489    #[test]
3490    fn a_lock_is_exclusive_until_dropped() {
3491        let dir = tempfile::tempdir().unwrap();
3492        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3493        let first = MarkerLock::acquire(&marker)
3494            .unwrap()
3495            .expect("first attempt takes the lock");
3496        assert!(
3497            MarkerLock::acquire(&marker).unwrap().is_none(),
3498            "a second attempt must be refused while the first holds it"
3499        );
3500        drop(first);
3501        assert!(
3502            MarkerLock::acquire(&marker).unwrap().is_some(),
3503            "dropping the guard releases the lock for the next attempt"
3504        );
3505    }
3506
3507    #[test]
3508    fn a_stale_lock_is_reclaimed() {
3509        let dir = tempfile::tempdir().unwrap();
3510        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3511        let lock_path = marker.with_extension("lock");
3512        std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
3513        std::fs::write(&lock_path, b"").unwrap();
3514        let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
3515        std::fs::OpenOptions::new()
3516            .write(true)
3517            .open(&lock_path)
3518            .unwrap()
3519            .set_modified(old)
3520            .unwrap();
3521        assert!(
3522            MarkerLock::acquire(&marker).unwrap().is_some(),
3523            "a lock older than the stale window must be reclaimed rather than block forever"
3524        );
3525    }
3526
3527    #[tokio::test]
3528    async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
3529        let dir = tempfile::tempdir().unwrap();
3530        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3531        let held = MarkerLock::acquire(&marker)
3532            .unwrap()
3533            .expect("seed the contention");
3534        let releaser = tokio::spawn(async move {
3535            tokio::time::sleep(Duration::from_millis(20)).await;
3536            drop(held);
3537        });
3538        let waited =
3539            wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
3540                .await
3541                .unwrap();
3542        assert!(
3543            waited.is_some(),
3544            "a merge landing behind another's still-running decision must not be dropped - it \
3545             must wait for that decision to finish and then judge against what it left behind"
3546        );
3547        releaser.await.unwrap();
3548    }
3549
3550    #[tokio::test]
3551    async fn a_lock_held_past_the_ceiling_gives_up() {
3552        let dir = tempfile::tempdir().unwrap();
3553        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3554        let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
3555        let waited =
3556            wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
3557                .await
3558                .unwrap();
3559        assert!(
3560            waited.is_none(),
3561            "a lock genuinely held past the ceiling must eventually give up rather than wait \
3562             forever"
3563        );
3564    }
3565
3566    #[test]
3567    fn level_between_reads_off_the_differing_digit() {
3568        assert_eq!(
3569            level_between(
3570                Version::parse("0.8.0").unwrap(),
3571                Version::parse("1.0.0").unwrap()
3572            ),
3573            Some(BumpLevel::Major)
3574        );
3575        assert_eq!(
3576            level_between(
3577                Version::parse("0.8.0").unwrap(),
3578                Version::parse("0.9.0").unwrap()
3579            ),
3580            Some(BumpLevel::Minor)
3581        );
3582        assert_eq!(
3583            level_between(
3584                Version::parse("0.8.0").unwrap(),
3585                Version::parse("0.8.1").unwrap()
3586            ),
3587            Some(BumpLevel::Patch)
3588        );
3589        assert_eq!(
3590            level_between(
3591                Version::parse("0.8.0").unwrap(),
3592                Version::parse("0.8.0").unwrap()
3593            ),
3594            None
3595        );
3596    }
3597
3598    #[test]
3599    fn open_release_pr_is_found_among_unrelated_pull_requests() {
3600        let json = r#"[
3601            {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
3602            {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
3603        ]"#;
3604        let found = parse_open_release_pr(json).unwrap();
3605        assert_eq!(
3606            found,
3607            Some((
3608                "chore/release-v0.9.0".to_owned(),
3609                "https://example.invalid/pull/2".to_owned()
3610            ))
3611        );
3612    }
3613
3614    #[test]
3615    fn no_open_release_pr_reads_as_none_not_an_error() {
3616        let json =
3617            r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
3618        assert_eq!(parse_open_release_pr(json).unwrap(), None);
3619        assert_eq!(parse_open_release_pr("[]").unwrap(), None);
3620    }
3621
3622    #[test]
3623    fn marker_round_trips_through_disk() {
3624        let dir = tempfile::tempdir().unwrap();
3625        let path = marker_path(dir.path(), Path::new("/repos/magi"));
3626        assert!(read_marker(&path).is_none());
3627
3628        let marker = test_pending("0.9.0", BumpLevel::Patch);
3629        write_marker(&path, &marker).unwrap();
3630        let read_back = read_marker(&path).unwrap();
3631        assert_eq!(read_back.target_version, "0.9.0");
3632        assert_eq!(read_back.level, BumpLevel::Patch);
3633        assert_eq!(read_back.pr_url, marker.pr_url);
3634
3635        clear_marker(&path);
3636        assert!(read_marker(&path).is_none());
3637    }
3638
3639    #[test]
3640    fn different_repos_get_different_marker_files() {
3641        let dir = tempfile::tempdir().unwrap();
3642        let a = marker_path(dir.path(), Path::new("/repos/a"));
3643        let b = marker_path(dir.path(), Path::new("/repos/b"));
3644        assert_ne!(a, b);
3645    }
3646
3647    /// A version-bump-only pull request must never trigger the next bump - see
3648    /// [`is_release_only`]'s own doc for why that shape is the trigger for
3649    /// "do not treat this as a change to react to".
3650    #[test]
3651    fn a_bump_pull_requests_own_merge_does_not_retrigger() {
3652        let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
3653        assert!(
3654            is_release_only(&files),
3655            "the bump pull request's own diff must read as release-only"
3656        );
3657    }
3658
3659    #[test]
3660    fn should_release_bump_reads_only_a_merged_status() {
3661        assert!(should_release_bump(RunStatus::Merged));
3662        for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
3663            assert!(!should_release_bump(other));
3664        }
3665    }
3666
3667    /// `land::Step::Done { merged: true }` - a pull request already merged
3668    /// underneath magi. `land::decide` reads that straight off the pull
3669    /// request's own lifecycle before it looks at checks or comments at all.
3670    #[test]
3671    fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
3672        let pr = land::PrState {
3673            url: "https://github.com/o/r/pull/1".to_owned(),
3674            number: 1,
3675            state: PrLifecycle::Merged,
3676            checks: land::Checks::Green,
3677            failing: Vec::new(),
3678            review_comments: Vec::new(),
3679            blocking: land::Blocking::No,
3680        };
3681        assert_eq!(
3682            land::decide(&pr, 0, 4, Duration::ZERO),
3683            land::Step::Done { merged: true }
3684        );
3685        assert!(should_release_bump(RunStatus::Merged));
3686    }
3687
3688    /// `land::Step::Merge`'s own `gh pr merge` succeeding: `land::land` then
3689    /// sets `pr.state = PrLifecycle::Merged` by hand before returning (see
3690    /// `land::land`'s `Step::Merge` arm), which is the same value the other
3691    /// two paths converge on.
3692    #[test]
3693    fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
3694        let pr = land::PrState {
3695            url: "https://github.com/o/r/pull/2".to_owned(),
3696            number: 2,
3697            state: PrLifecycle::Open,
3698            checks: land::Checks::Green,
3699            failing: Vec::new(),
3700            review_comments: Vec::new(),
3701            blocking: land::Blocking::No,
3702        };
3703        assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
3704        // land::land's Step::Merge arm sets this by hand on success; asserted
3705        // here as the value that then makes should_release_bump fire.
3706        assert!(should_release_bump(RunStatus::Merged));
3707    }
3708
3709    /// [`land::merged_after_all`] - `gh pr merge` exited non-zero but the
3710    /// forge confirms the pull request merged anyway.
3711    #[test]
3712    fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
3713        let argv = land::merge_argv(3, "feat: something");
3714        let outcome = land::merged_after_all(
3715            &argv,
3716            "could not determine current branch: not on any branch",
3717            Some(PrLifecycle::Merged),
3718        );
3719        assert!(outcome.is_some(), "the forge's confirmation must win");
3720        assert!(should_release_bump(RunStatus::Merged));
3721
3722        // The same recovery must not fabricate a merge when the forge does
3723        // not confirm one.
3724        assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
3725        assert!(land::merged_after_all(&argv, "network error", None).is_none());
3726    }
3727
3728    /// The paths that do *not* land must not read as merged either.
3729    #[test]
3730    fn a_close_or_a_give_up_does_not_trigger_a_bump() {
3731        let pr = land::PrState {
3732            url: "https://github.com/o/r/pull/4".to_owned(),
3733            number: 4,
3734            state: PrLifecycle::Closed,
3735            checks: land::Checks::Green,
3736            failing: Vec::new(),
3737            review_comments: Vec::new(),
3738            blocking: land::Blocking::No,
3739        };
3740        assert_eq!(
3741            land::decide(&pr, 0, 4, Duration::ZERO),
3742            land::Step::Done { merged: false }
3743        );
3744        assert!(!should_release_bump(RunStatus::Blocked));
3745    }
3746}