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};
30#[cfg(test)]
31use crate::ask;
32use crate::config::AgentSpec;
33use crate::git;
34use crate::land;
35use crate::notices::{Link, Notice, Notices};
36use crate::proc::Quiet as _;
37use crate::release_manifest;
38use crate::run::{self, RunState, RunStatus};
39use crate::verdict;
40
41/// One bounded release decision attempt, independent of a release PR outcome.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct DecisionAttempt {
44    /// Configured agent id tried in this attempt.
45    pub agent: String,
46    /// Sanitized failure classification or successful decision.
47    pub outcome: String,
48}
49
50/// Consume the configured chain once. A parsed decision ends failover; reason
51/// rewriting happens afterwards and cannot change the selected level.
52async fn ask_decision(
53    state: &mut RunState,
54    repo: &Path,
55    prompt: &str,
56    timeout: Duration,
57) -> Result<(AgentSpec, SeatState, BumpDecision)> {
58    let chain = agent::pick_chain(
59        &state.config.agents,
60        state.config.roles.chatter.as_ref(),
61        &agent::installed,
62        "chatter (release-bump decision)",
63    )?;
64    // Keep the whole chain within the existing decision budget. In
65    // particular, failover must not outlive the marker lock's stale ceiling.
66    let attempt_timeout = timeout / u32::try_from(chain.len()).unwrap_or(u32::MAX);
67    let artifacts = agent::artifacts_dir(&state.dir());
68    let cache = state.config.cache_dir();
69    let mut failures = Vec::new();
70    for (n, spec) in chain.iter().enumerate() {
71        let mut seat = crate::graph::handover_seat("bump", &spec.id, state.next_seat_seed());
72        // Include the persisted attempt count so a resumed decision cannot
73        // overwrite earlier evidence, including the first attempt's output.
74        let attempt = state.bump_decision_attempts.len() + 1;
75        let stem = format!("bump-decision-{attempt}");
76        state.event(
77            "bump",
78            format!("decision attempt {attempt}: trying {} ({stem})", spec.id),
79        );
80        // The reason is posted in the release pull request.
81        let outcome = async {
82            let spec = agent::for_github_text(spec, &state.config.graph.github_language, repo)?;
83            agent::invoke(
84                &spec,
85                &mut seat,
86                &Invocation {
87                    coordination: state.config.coordination.as_ref(),
88                    fetched_base: None,
89                    cwd: repo,
90                    prompt,
91                    timeout: attempt_timeout,
92                    allow_write: false,
93                    sessions: false,
94                    artifacts: &artifacts,
95                    stem: &stem,
96                    run: &state.id,
97                    node: "bump",
98                    cache_dir: cache.as_deref(),
99                    attachments: &[],
100                    writable: &[],
101                },
102            )
103            .await
104        }
105        .await;
106        let parsed = if agent::chain_advances(&outcome) {
107            None
108        } else {
109            outcome
110                .as_ref()
111                .ok()
112                .and_then(|out| parse_decision(&out.text).ok())
113        };
114        // Fixed classifications avoid copying CLI output, secrets or local
115        // paths into events, reports, or the surfaced failure message.
116        let (class, reason) = match &outcome {
117            Ok(out) if out.quota_exhausted() => {
118                (run::FailClass::Quota, "rate/quota limit".to_owned())
119            }
120            Ok(out) if out.timed_out => (run::FailClass::Timeout, "timed out".to_owned()),
121            Ok(out) if out.exit_code != Some(0) => (
122                run::FailClass::Other("cli".into()),
123                format!(
124                    "CLI failed ({})",
125                    crate::notices::exit_status(out.exit_code)
126                ),
127            ),
128            Ok(out) if out.text.trim().is_empty() => (
129                run::FailClass::Other("empty".into()),
130                "empty output".to_owned(),
131            ),
132            Err(_) => (
133                run::FailClass::Other("cli".into()),
134                "CLI invocation failed; inspect attempt artifacts".to_owned(),
135            ),
136            _ => (
137                run::FailClass::Other("json".into()),
138                "invalid decision JSON or missing reason".to_owned(),
139            ),
140        };
141        let status = if parsed.is_some() {
142            "valid decision"
143        } else {
144            &reason
145        };
146        state.bump_decision_attempts.push(DecisionAttempt {
147            agent: spec.id.clone(),
148            outcome: status.to_owned(),
149        });
150        state.event(
151            "bump",
152            format!("decision attempt {attempt}: {}: {status}", spec.id),
153        );
154        if let Some(decision) = parsed {
155            return Ok((spec.clone(), seat, decision));
156        }
157        failures.push(format!("{}: {reason}", spec.id));
158        if let Some(next) = chain.get(n + 1) {
159            crate::graph::record_handover(
160                state, "bump", "bump", &spec.id, &next.id, &class, &reason,
161            );
162        }
163    }
164    bail!(
165        "release-bump decision exhausted {} eligible agent(s): {}. Check CLI subscription limits, availability and bump-decision attempt artifacts; adjust [roles] chatter or retry after recovery. No release PR was created by this decision",
166        chain.len(),
167        failures.join("; ")
168    )
169}
170
171/// Validate only the reason after a decision is settled; no retry can replace
172/// its level, even when a rewrite returns a different decision or fails.
173async fn guard_reason(
174    state: &mut RunState,
175    repo: &Path,
176    prompt: &str,
177    spec: &AgentSpec,
178    seat: &mut SeatState,
179    decision: &mut BumpDecision,
180) {
181    let artifacts = agent::artifacts_dir(&state.dir());
182    let mut violations =
183        crate::github_text::check_for(&state.config.graph, "", &decision.reason, None);
184    // Sensitive-only: span redaction is enough, no rewrite needed.
185    violations.retain(|v| *v != crate::github_text::Violation::SensitiveData);
186    if violations.is_empty() {
187        decision.reason = crate::scrub::scrub(&decision.reason, &crate::scrub::Identity::current());
188    }
189    if state.config.graph.github_text_guard && !violations.is_empty() {
190        state.event(
191            "github-text",
192            format!("release reason rejected: {violations:?}; requesting one rewrite"),
193        );
194        let rewrite = format!(
195            "{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: {}",
196            decision.reason
197        );
198        let retry = async {
199            let spec = agent::for_github_text(spec, &state.config.graph.github_language, repo)?;
200            agent::invoke(
201                &spec,
202                seat,
203                &Invocation {
204                    coordination: state.config.coordination.as_ref(),
205                    fetched_base: None,
206                    cwd: repo,
207                    prompt: &rewrite,
208                    timeout: crate::graph::retry_budget(DECISION_TIMEOUT, true),
209                    allow_write: false,
210                    sessions: false,
211                    artifacts: &artifacts,
212                    stem: "bump-rewrite",
213                    run: &state.id,
214                    node: "bump",
215                    cache_dir: state.config.cache_dir().as_deref(),
216                    attachments: &[],
217                    writable: &[],
218                },
219            )
220            .await
221        }
222        .await;
223        let reason = retry
224            .ok()
225            .filter(|o| o.usable())
226            .and_then(|o| parse_decision(&o.text).ok())
227            .filter(|d| {
228                d.level == decision.level
229                    && crate::github_text::check_for(&state.config.graph, "", &d.reason, None)
230                        .is_empty()
231            })
232            .map(|d| d.reason);
233        if let Some(reason) = reason {
234            decision.reason = reason;
235            state.event("github-text", "release reason rewrite passed");
236        } else {
237            decision.reason = "The merged change requires a release.".to_owned();
238            state.event(
239                "github-text",
240                "release reason rewrite unavailable or rejected; using neutral text",
241            );
242        }
243    }
244}
245
246/// How long the decision call may run.
247///
248/// It reads a diffstat, a subject line and a version string, and returns
249/// three words and a sentence - nowhere near the budget an implement wave
250/// gets, so a fixed, generous constant is simpler than a new config knob for
251/// a call this small. This is the total CLI budget, divided equally among
252/// eligible candidates so failover cannot extend the release critical section.
253const DECISION_TIMEOUT: Duration = Duration::from_secs(600);
254
255/// Does this run's final status mean the merge this call is downstream of
256/// actually happened?
257///
258/// All three of `land`'s success paths converge on the same signal before
259/// [`crate::graph`] ever calls into this module: a pull request already
260/// merged underneath magi (`land::Step::Done { merged: true }`),
261/// `land::Step::Merge`'s own `gh pr merge` succeeding, and the
262/// [`land::merged_after_all`] recovery for a non-zero exit that merged
263/// anyway. Every one of them ends `land::land` with `pr.state ==
264/// PrLifecycle::Merged`, which is exactly what `graph::Runner::merge` reads
265/// to set `RunStatus::Merged` on the run - see the `all_three_merge_paths_*`
266/// tests below for each path's own evidence. Every path that does *not* land
267/// (a close, `Step::GiveUp`, an unanswered `land_approval`, or a `gh pr
268/// merge` failure the forge does not confirm) leaves the run `Blocked`
269/// instead, so this one check is the whole gate a caller needs.
270pub fn should_release_bump(status: RunStatus) -> bool {
271    status == RunStatus::Merged
272}
273
274/// Which digit of `major.minor.patch` a change earns.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(rename_all = "lowercase")]
277pub enum BumpLevel {
278    /// A breaking change to a public surface.
279    Major,
280    /// A user-visible new capability, or - below `1.0.0` - a breaking change.
281    Minor,
282    /// A fix, internal refactor, or dependency update.
283    Patch,
284}
285
286impl BumpLevel {
287    /// Stable lower-case name, as the prompt and the events spell it.
288    pub fn as_str(self) -> &'static str {
289        match self {
290            Self::Major => "major",
291            Self::Minor => "minor",
292            Self::Patch => "patch",
293        }
294    }
295
296    /// Severity for comparing two independent decisions: `patch < minor <
297    /// major`, spelled out explicitly rather than derived from declaration
298    /// order, which exists here only for readability and must not silently
299    /// become load-bearing.
300    fn severity(self) -> u8 {
301        match self {
302            Self::Patch => 0,
303            Self::Minor => 1,
304            Self::Major => 2,
305        }
306    }
307}
308
309/// The agent's answer: which digit, and why.
310///
311/// Parsed with [`verdict::extract_json`], so a reply missing `reason`, or
312/// spelling `level` as anything but `major` / `minor` / `patch`, is a parse
313/// error rather than a value with a blank field - [`parse_decision`] never
314/// fabricates a bump out of a response it could not read.
315#[derive(Debug, Clone, Deserialize)]
316pub struct BumpDecision {
317    /// The chosen digit.
318    pub level: BumpLevel,
319    /// One line, carried into the pull request body so "why was this minor"
320    /// is answerable later without archaeology.
321    pub reason: String,
322}
323
324/// Parse the agent's reply. Never returns a default decision: an unparsable
325/// or incomplete reply is `Err`, and the caller must not open a bump pull
326/// request on the strength of a guess.
327pub fn parse_decision(text: &str) -> Result<BumpDecision> {
328    let decision: BumpDecision = verdict::extract_json(text)?;
329    if decision.reason.trim().is_empty() {
330        bail!("the bump decision carried no reason");
331    }
332    Ok(decision)
333}
334
335/// `major.minor.patch`, the only shape a `[package] version` in this
336/// ecosystem carries in practice.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
338pub struct Version {
339    /// First component.
340    pub major: u64,
341    /// Second component.
342    pub minor: u64,
343    /// Third component.
344    pub patch: u64,
345}
346
347impl Version {
348    /// Parse `major.minor.patch`. A pre-release or build suffix on the patch
349    /// component (`0.8.0-rc1`) is tolerated by reading only its leading
350    /// digits - Cargo itself never writes one into `[package] version`, but a
351    /// human editing the file by hand might.
352    pub fn parse(s: &str) -> Result<Self> {
353        let s = s.trim();
354        let mut parts = s.splitn(3, '.');
355        let major = parts
356            .next()
357            .with_context(|| format!("`{s}` has no major component"))?;
358        let minor = parts
359            .next()
360            .with_context(|| format!("`{s}` has no minor component"))?;
361        let patch = parts
362            .next()
363            .with_context(|| format!("`{s}` has no patch component"))?;
364        let patch_digits: String = patch.chars().take_while(char::is_ascii_digit).collect();
365        Ok(Self {
366            major: major
367                .trim()
368                .parse()
369                .with_context(|| format!("`{major}` is not a number"))?,
370            minor: minor
371                .trim()
372                .parse()
373                .with_context(|| format!("`{minor}` is not a number"))?,
374            patch: patch_digits
375                .parse()
376                .with_context(|| format!("`{patch}` has no numeric patch component"))?,
377        })
378    }
379
380    /// The next version at `level`. A `major`/`minor` bump zeroes every digit
381    /// below it, matching what every tool that reads a semver range expects.
382    #[must_use]
383    pub fn bump(self, level: BumpLevel) -> Self {
384        match level {
385            BumpLevel::Major => Self {
386                major: self.major + 1,
387                minor: 0,
388                patch: 0,
389            },
390            BumpLevel::Minor => Self {
391                major: self.major,
392                minor: self.minor + 1,
393                patch: 0,
394            },
395            BumpLevel::Patch => Self {
396                major: self.major,
397                minor: self.minor,
398                patch: self.patch + 1,
399            },
400        }
401    }
402}
403
404impl std::fmt::Display for Version {
405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
407    }
408}
409
410/// Did the merged change touch only the release manifest and its lockfile?
411///
412/// [`after_merge`] is reached from *every* qualifying merge, including a
413/// version-bump pull request's own - without this check a bump would trigger
414/// another bump forever. A human-authored version-only pull request is exempt
415/// from review for the same reason (`AGENTS.md`'s "version-bump-only pull
416/// requests"), so using its shape as the "do not treat this as a trigger"
417/// test is one rule doing both jobs instead of two.
418pub fn is_release_only(files: &[String]) -> bool {
419    !files.is_empty() && files.iter().all(|f| f == "Cargo.toml" || f == "Cargo.lock")
420}
421
422/// Rewrite `table`'s `version = "..."` line, leaving every other byte
423/// untouched.
424///
425/// Scoped to the named table specifically, rather than the first line
426/// anywhere in the file that looks like `version = "..."`: a dependency
427/// pinned as `foo = { version = "1.2.3" }` must never move, and neither must
428/// the *other* of `[package]` / `[workspace.package]` when only one of them
429/// is the one being bumped. That scoping is what lets a version-bump-only
430/// diff stay exactly that, which [`is_release_only`] and the "no reviewer
431/// needed" exemption in `AGENTS.md` both rest on.
432fn rewrite_table_version(toml: &str, table: &str, new_version: &str) -> Result<String> {
433    let mut out = String::with_capacity(toml.len() + 8);
434    let mut in_table = false;
435    let mut done = false;
436    for line in toml.split_inclusive('\n') {
437        let trimmed = line.trim();
438        if trimmed.starts_with('[') {
439            in_table = trimmed == table;
440        }
441        if !done && in_table && trimmed.split('=').next().map(str::trim) == Some("version") {
442            let newline = if line.ends_with("\r\n") { "\r\n" } else { "\n" };
443            let _ = write!(out, "version = \"{new_version}\"{newline}");
444            done = true;
445            continue;
446        }
447        out.push_str(line);
448    }
449    if !done {
450        bail!("no `version` field found under `{table}`");
451    }
452    Ok(out)
453}
454
455/// Truncate `s` at the first `#` that is not inside a quoted string, the
456/// same rule a TOML parser uses to tell a comment from a literal `#` inside
457/// a value. Used only on table-header lines here (`[workspace.dependencies]
458/// # internal pins` is valid TOML), so header comparisons don't miss a
459/// section just because it carries a trailing comment.
460fn strip_trailing_comment(s: &str) -> &str {
461    let mut in_string = false;
462    let mut quote = '"';
463    for (i, c) in s.char_indices() {
464        if in_string {
465            if c == quote {
466                in_string = false;
467            }
468        } else if c == '"' || c == '\'' {
469            in_string = true;
470            quote = c;
471        } else if c == '#' {
472            return s[..i].trim_end();
473        }
474    }
475    s
476}
477
478/// Strip a single layer of matching quotes from a TOML key, so a
479/// `"name" = { .. }` or `'name' = { .. }` entry compares equal to the plain
480/// `name` the `toml` crate itself hands back for the same key - Cargo
481/// manifests almost never quote a dependency key (bare keys already allow
482/// `-`), but it is legal TOML and costs nothing extra to normalise.
483fn unquote_key(key: &str) -> &str {
484    for quote in ['"', '\''] {
485        if let Some(inner) = key.strip_prefix(quote).and_then(|k| k.strip_suffix(quote)) {
486            return inner;
487        }
488    }
489    key
490}
491
492/// Pull just the `[workspace.dependencies]` table - flat entries and any
493/// `[workspace.dependencies.<name>]` sub-tables - out of a full manifest and
494/// rewrite its headers to `[dependencies]` / `[dependencies.<name>]`, so the
495/// fragment parses standalone as TOML.
496///
497/// This is what keeps [`internal_pin_names`] and [`verify_pins_rewritten`]
498/// from imposing a whole-file strict-TOML precondition on every workspace
499/// manifest this rewrite runs against: only the dependency table itself has
500/// to parse, not any other section a hand-edited `Cargo.toml` might carry in
501/// a shape this crate's pinned `toml` version does not accept. Returns an
502/// empty string when the manifest has no such table at all.
503fn extract_workspace_dependencies_fragment(toml: &str) -> String {
504    let mut fragment = String::new();
505    let mut capturing = false;
506    for line in toml.split_inclusive('\n') {
507        let header = strip_trailing_comment(line.trim());
508        if header.starts_with('[') {
509            if header == "[workspace.dependencies]" {
510                capturing = true;
511                fragment.push_str("[dependencies]\n");
512            } else if let Some(name) = header
513                .strip_prefix("[workspace.dependencies.")
514                .and_then(|rest| rest.strip_suffix(']'))
515            {
516                capturing = true;
517                let _ = writeln!(fragment, "[dependencies.{name}]");
518            } else {
519                capturing = false;
520            }
521            continue;
522        }
523        if capturing {
524            fragment.push_str(line);
525        }
526    }
527    fragment
528}
529
530/// Names of `[workspace.dependencies]` entries that name an internal member
531/// by *both* `path` and `version` - the shape `AGENTS.md`'s own "internal
532/// version pin" guidance recommends for a published member that another
533/// workspace member depends on. Parsed with the `toml` crate (already a
534/// dependency, used read-only here) rather than by scanning for the word
535/// `version`, so an ordinary `[dependencies]` entry in some other table, or a
536/// `[workspace.dependencies]` entry that carries only one of the two keys,
537/// never enters the candidate set:
538///
539/// - `path` only (no `version`): an unpublished, workspace-only member - not
540///   ours to touch.
541/// - `version` only (no `path`): an external crates.io dependency - not ours
542///   to touch either.
543///
544/// Returns an empty list, not an error, when the manifest has no
545/// `[workspace.dependencies]` table at all - the common single-crate shape -
546/// so [`rewrite_cargo_version`]'s existing `[package]` path is unaffected.
547fn internal_pin_names(toml: &str) -> Result<Vec<String>> {
548    let fragment = extract_workspace_dependencies_fragment(toml);
549    if fragment.trim().is_empty() {
550        return Ok(Vec::new());
551    }
552    let value: toml::Value =
553        toml::from_str(&fragment).context("failed to parse `[workspace.dependencies]` as TOML")?;
554    let mut names: Vec<String> = value
555        .get("dependencies")
556        .and_then(|d| d.as_table())
557        .into_iter()
558        .flatten()
559        .filter(|(_, dep)| {
560            dep.as_table()
561                .is_some_and(|t| t.contains_key("path") && t.contains_key("version"))
562        })
563        .map(|(name, _)| name.clone())
564        .collect();
565    names.sort();
566    Ok(names)
567}
568
569/// Rewrite the quoted string value of `key = "..."` (or `key = '...'`, a
570/// literal string) on `line`, wherever it appears - once for an inline table
571/// (`name = { path = "..", version = ".." }`, either key order) and once for
572/// a dotted `[workspace.dependencies.name]` table's own `version = ".."`
573/// line. The replacement is written back between the *same* quote
574/// characters the line already used, so a literal-string pin stays a
575/// literal string. Returns `None` when `key` is not present on this line as
576/// a `key = "value"` pair, which the caller treats as "could not rewrite
577/// this one" rather than silently leaving it unbumped.
578fn rewrite_quoted_field(line: &str, key: &str, new_value: &str) -> Option<String> {
579    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-';
580    let mut search_from = 0;
581    while let Some(rel) = line[search_from..].find(key) {
582        let key_start = search_from + rel;
583        let before_ok = key_start == 0 || !is_ident(line[..key_start].chars().next_back().unwrap());
584        let eq_pos = key_start
585            + key.len()
586            + (line[key_start + key.len()..].len()
587                - line[key_start + key.len()..].trim_start().len());
588        if before_ok && line[eq_pos..].starts_with('=') {
589            let after_eq = &line[eq_pos + 1..];
590            let ws_len = after_eq.len() - after_eq.trim_start().len();
591            let quote_pos = eq_pos + 1 + ws_len;
592            if let Some(quote_char) = line[quote_pos..]
593                .chars()
594                .next()
595                .filter(|c| *c == '"' || *c == '\'')
596            {
597                let value_begin = quote_pos + quote_char.len_utf8();
598                if let Some(end_rel) = line[value_begin..].find(quote_char) {
599                    let value_end = value_begin + end_rel;
600                    let mut out = String::with_capacity(line.len());
601                    out.push_str(&line[..value_begin]);
602                    out.push_str(new_value);
603                    out.push_str(&line[value_end..]);
604                    return Some(out);
605                }
606            }
607        }
608        search_from = key_start + key.len();
609    }
610    None
611}
612
613/// Rewrite the `version` pin of each `[workspace.dependencies]` entry named
614/// in `names` to `new_version`, in every shape Cargo accepts: the inline
615/// table (`name = { path = "..", version = "old" }`), the dotted-key form
616/// under the flat table (`name.path = ".."` / `name.version = "old"` as two
617/// separate lines), and the dotted sub-table (`[workspace.dependencies.
618/// name]` followed by its own `version = "old"` line). Every other byte,
619/// including entries not in `names`, is untouched - same discipline as
620/// [`rewrite_table_version`].
621///
622/// Bails rather than silently leaving a pin unbumped when a named entry's
623/// `version` field cannot be located on a single line this way (for example
624/// an inline table split across lines): a bump pull request that leaves a
625/// stale internal pin behind is exactly the failure this exists to prevent,
626/// so an unrepresentable form must stop the bump, not ship it half-done.
627fn rewrite_workspace_dependency_pins(
628    toml: &str,
629    names: &[String],
630    new_version: &str,
631) -> Result<String> {
632    if names.is_empty() {
633        return Ok(toml.to_owned());
634    }
635    let mut out = String::with_capacity(toml.len() + names.len() * 8);
636    let mut in_flat_table = false;
637    let mut in_named_table: Option<String> = None;
638    let mut rewritten: std::collections::HashSet<String> = std::collections::HashSet::new();
639
640    for line in toml.split_inclusive('\n') {
641        let trimmed = line.trim();
642        if trimmed.starts_with('[') {
643            let header = strip_trailing_comment(trimmed);
644            in_flat_table = header == "[workspace.dependencies]";
645            in_named_table = header
646                .strip_prefix("[workspace.dependencies.")
647                .and_then(|rest| rest.strip_suffix(']'))
648                .map(str::to_owned);
649            out.push_str(line);
650            continue;
651        }
652
653        let key = trimmed.split('=').next().map(str::trim).map(unquote_key);
654
655        if in_flat_table {
656            // The inline-table form: `name = { path = "..", version = ".." }`.
657            if let Some(name) = key.and_then(|k| names.iter().find(|n| n.as_str() == k)) {
658                if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
659                    rewritten.insert(name.clone());
660                    out.push_str(&rewritten_line);
661                    continue;
662                }
663            } else if let Some(name) = key.and_then(|k| {
664                k.strip_suffix(".version")
665                    .and_then(|prefix| names.iter().find(|n| n.as_str() == prefix))
666            }) {
667                // The dotted-key form: `name.path = ".."` / `name.version =
668                // ".."` as two separate lines directly under
669                // `[workspace.dependencies]`, with no inline table and no
670                // `[workspace.dependencies.name]` sub-header either.
671                if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
672                    rewritten.insert(name.clone());
673                    out.push_str(&rewritten_line);
674                    continue;
675                }
676            }
677        } else if let Some(table_name) = &in_named_table {
678            if key == Some("version") && names.iter().any(|n| n == table_name) {
679                if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
680                    rewritten.insert(table_name.clone());
681                    out.push_str(&rewritten_line);
682                    continue;
683                }
684            }
685        }
686
687        out.push_str(line);
688    }
689
690    for name in names {
691        if !rewritten.contains(name) {
692            bail!(
693                "could not rewrite the `[workspace.dependencies]` version pin \
694                 for `{name}` - its `version` field was not found in a shape \
695                 this rewrite understands"
696            );
697        }
698    }
699    Ok(out)
700}
701
702/// Confirm every entry in `names` now reads `new_version` under
703/// `[workspace.dependencies]`, by reparsing the rewritten manifest rather
704/// than trusting the line-based rewrite's own bookkeeping. The safety net
705/// this run-time failure on the pending release bump is guarding against
706/// happened once already: `kanadehq/kanade`'s pin was left stale and its own
707/// `internal_pins_match_the_workspace_version` regression test caught it in
708/// CI, after the bump pull request had already opened.
709fn verify_pins_rewritten(toml: &str, names: &[String], new_version: &str) -> Result<()> {
710    let fragment = extract_workspace_dependencies_fragment(toml);
711    let value: toml::Value = toml::from_str(&fragment)
712        .context("rewritten `[workspace.dependencies]` failed to parse")?;
713    let deps = value.get("dependencies").and_then(|d| d.as_table());
714    for name in names {
715        let actual = deps
716            .and_then(|d| d.get(name))
717            .and_then(|dep| dep.as_table())
718            .and_then(|t| t.get("version"))
719            .and_then(|v| v.as_str());
720        if actual != Some(new_version) {
721            bail!(
722                "the `[workspace.dependencies]` version pin for `{name}` did \
723                 not end up at `{new_version}` after the rewrite"
724            );
725        }
726    }
727    Ok(())
728}
729
730/// Rewrite the release version, wherever this manifest actually declares it.
731///
732/// A single crate carries its version under `[package]`. A workspace root
733/// with no crate of its own - `[workspace] members = [...]` and nothing
734/// else - carries it under `[workspace.package]` instead, and `[package]`
735/// does not exist there at all. `[package]` is tried first because it is the
736/// far more common shape and the one every existing bump so far has hit;
737/// `[workspace.package]` is the fallback for the shape that never worked
738/// before this.
739///
740/// A workspace manifest can also carry a *second*, independent version
741/// literal per internal member: `[workspace.dependencies].<name>` entries
742/// that reference a published member by both `path` and `version`, exactly
743/// the shape `AGENTS.md` documents as the canonical place to centralize that
744/// pin. Cargo does not derive that literal from `[workspace.package]
745/// version` - nothing does - so once the table version is rewritten, every
746/// such pin is rewritten to match in the same edit, and the result is
747/// reparsed to confirm it before this function returns. An entry with only
748/// `path` (an unpublished workspace-only member) or only `version` (an
749/// external dependency) is left untouched, as is a plain single-crate
750/// manifest with no `[workspace.dependencies]` table at all.
751pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
752    let rewritten = rewrite_table_version(toml, "[package]", new_version)
753        .or_else(|_| rewrite_table_version(toml, "[workspace.package]", new_version))
754        .context("no `version` field found under `[package]` or `[workspace.package]`")?;
755
756    let pin_names = internal_pin_names(&rewritten)?;
757    if pin_names.is_empty() {
758        return Ok(rewritten);
759    }
760
761    let rewritten = rewrite_workspace_dependency_pins(&rewritten, &pin_names, new_version)?;
762    verify_pins_rewritten(&rewritten, &pin_names, new_version)?;
763    Ok(rewritten)
764}
765
766/// Find `table`'s `version` field, if it has one. No I/O.
767fn version_in_table(toml: &str, table: &str) -> Option<String> {
768    let mut in_table = false;
769    for line in toml.lines() {
770        let trimmed = line.trim();
771        if trimmed.starts_with('[') {
772            in_table = trimmed == table;
773            continue;
774        }
775        if !in_table {
776            continue;
777        }
778        let mut parts = trimmed.splitn(2, '=');
779        let key = parts.next().map(str::trim);
780        let Some(value) = parts.next() else {
781            continue;
782        };
783        if key == Some("version") {
784            return Some(value.trim().trim_matches('"').to_owned());
785        }
786    }
787    None
788}
789
790/// Read the version currently on the base branch, from `[package]` if it has
791/// one, else from `[workspace.package]` - see [`rewrite_cargo_version`] for
792/// why both exist and which wins. No I/O: the caller fetches the blob (`git
793/// show <remote>/<base>:Cargo.toml`).
794pub(crate) fn current_version(toml: &str) -> Result<String> {
795    version_in_table(toml, "[package]")
796        .or_else(|| version_in_table(toml, "[workspace.package]"))
797        .context("no `version` field found under `[package]` or `[workspace.package]`")
798}
799
800/// The version a release pull request really carries: the manifest on its head
801/// branch (an escalation rewrites it but keeps the branch name), falling back
802/// to the branch name only when the manifest cannot be read.
803async fn branch_version(repo: &Path, remote: &str, branch: &str, primary: &str) -> Option<String> {
804    let from_name = branch.strip_prefix("chore/release-v").map(str::to_owned);
805    // A tracking ref of its own: FETCH_HEAD is shared with every other fetch in
806    // this repository (the release watcher runs them concurrently).
807    let tracking = format!("refs/remotes/{remote}/{branch}");
808    let spec = format!("+refs/heads/{branch}:{tracking}");
809    let fetched = git::git_raw(repo, &["fetch", "--quiet", remote, &spec]).await;
810    if fetched.is_ok_and(|o| o.code == Some(0))
811        && let Ok(out) = git::git_raw(repo, &["show", &format!("{tracking}:{primary}")]).await
812        && out.code == Some(0)
813        && let Ok(v) = current_version(&out.stdout)
814    {
815        return Some(v);
816    }
817    from_name
818}
819
820/// Build the prompt asking an agent which digit of `major.minor.patch` a
821/// merged change earns.
822///
823/// Pure: every input is already known once a merge lands, so the whole
824/// decision policy - the "`minor` is the breaking digit below `1.0.0`" rule,
825/// what counts as a breaking surface, and the tie-break toward the larger
826/// digit - is asserted directly on the returned string, the same way
827/// [`crate::land::fix_prompt`] doc-comments its own rules rather than leaving
828/// them for a human to spot missing from a live reply.
829pub fn decision_prompt(
830    subject: &str,
831    instruction: &str,
832    diffstat: &str,
833    files: &[String],
834    current_version: &str,
835) -> String {
836    let mut s = format!(
837        "A pull request just merged into the base branch. Decide which digit \
838         of this project's `major.minor.patch` version this change earns, so \
839         a release bump can be opened for exactly it.\n\n\
840         Current version: {current_version}\n\n\
841         # Merge subject\n\n{subject}\n\n\
842         # The task that produced it\n\n{instruction}\n\n\
843         # Files changed ({} total)\n\n",
844        files.len()
845    );
846    const MAX_FILES: usize = 50;
847    for f in files.iter().take(MAX_FILES) {
848        let _ = writeln!(s, "- {f}");
849    }
850    if files.len() > MAX_FILES {
851        let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
852    }
853    let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
854
855    s.push_str(
856        "\n# How to decide\n\n\
857         This project is below version `1.0.0`. At that stage **`minor` is \
858         the digit that carries a breaking change** - do not spend `major` \
859         below `1.0.0`.\n\n\
860         A change is breaking, and earns `minor`, when it changes any of: \
861         the public API reachable from `src/lib.rs`, a CLI subcommand or \
862         flag, an HTTP API route or response shape, a configuration key, or \
863         the on-disk shape of persisted state.\n\n\
864         A user-visible new capability that breaks none of the above also \
865         earns `minor`.\n\n\
866         A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
867         **When it is not obvious which digit applies, choose the larger \
868         one.** An oversized bump costs nothing; a breaking change shipped as \
869         `patch` breaks every downstream update that pins a range.\n\n\
870         # Output\n\n\
871         Reply with exactly one fenced JSON object and nothing that matters \
872         outside it:\n\n\
873         ```json\n\
874         {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
875         ```\n",
876    );
877    // The reason is pasted into the release pull request's body.
878    let _ = write!(
879        s,
880        "\n{}\n\nThe `reason` goes into a GitHub pull request body, so write it \
881         in English.\n",
882        crate::prompt::GITHUB_ENGLISH_HEADING
883    );
884    s
885}
886
887/// magi's own record of a bump pull request it currently has open, so a
888/// burst of merges in quick succession does not each open a competing
889/// release.
890///
891/// **Chosen policy: serialize, not coalesce two independent decisions into
892/// one.** A bump branch touches only `Cargo.toml` / `Cargo.lock`, so `gh pr
893/// merge --squash` applies it onto whatever the base branch has become by
894/// the time it lands - every commit merged while it was open rides along
895/// for free, at no extra cost, once it merges. But the *digit* a still-open
896/// pull request targets was judged from only the first change, and a more
897/// severe change landing while it waits must not ship at the smaller digit
898/// just because it arrived second - so the serialization is at the pull
899/// request, not at the judgement: a later, more severe decision escalates
900/// the same open pull request (see [`pending_action`]) rather than opening a
901/// second one or being silently absorbed at the wrong digit.
902#[derive(Debug, Clone, Serialize, Deserialize)]
903pub struct PendingBump {
904    /// The version the open pull request bumps to.
905    pub target_version: String,
906    /// The digit that version was judged to need, so a later, more severe
907    /// merge can tell it needs to escalate rather than assume it is covered.
908    pub level: BumpLevel,
909    /// The branch the open pull request is built from, so an escalation
910    /// knows what to check out and push to.
911    pub branch: String,
912    /// The pull request's URL, so a later merge can confirm it is still
913    /// open before trusting it to block a fresh decision.
914    pub pr_url: String,
915}
916
917/// Where [`PendingBump`] is recorded for `repo` - one file per repository, so
918/// a machine running magi against more than one checkout does not confuse
919/// their releases with each other.
920pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
921    let key = repo.to_string_lossy();
922    home.join("bump")
923        .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
924}
925
926/// Read a recorded [`PendingBump`], if any. Missing or unreadable both read
927/// as "nothing pending" - a marker is bookkeeping, not a source of truth
928/// worth failing a merge over.
929pub fn read_marker(path: &Path) -> Option<PendingBump> {
930    let body = std::fs::read_to_string(path).ok()?;
931    serde_json::from_str(&body).ok()
932}
933
934/// Persist `marker`, atomically - the same tmp-then-rename shape
935/// [`crate::updater::write_progress`] uses, since this file is read by a
936/// later, unrelated process invocation and must never be seen half-written.
937pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
938    if let Some(parent) = path.parent() {
939        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
940    }
941    let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
942    let tmp = path.with_extension("json.tmp");
943    std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
944    std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
945    Ok(())
946}
947
948/// Drop a recorded marker. Best-effort: a marker that is already gone is not
949/// an error.
950pub fn clear_marker(path: &Path) {
951    let _ = std::fs::remove_file(path);
952}
953
954/// What a recorded [`PendingBump`] means for a fresh decision, given what the
955/// base branch's `Cargo.toml` says right now. No I/O: the caller reads both
956/// the marker and the version.
957#[derive(Debug, Clone, PartialEq, Eq)]
958pub enum Coalesce {
959    /// Nothing is pending, or the pending bump already landed (or was
960    /// superseded by a manual one) - safe to open a fresh decision.
961    Proceed,
962    /// A bump to `target_version` is already open; do not open a second one.
963    Skip {
964        /// The version the pending pull request already targets.
965        target_version: String,
966    },
967}
968
969/// Decide what a pending marker means against `current_version`.
970pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
971    let Some(pending) = pending else {
972        return Ok(Coalesce::Proceed);
973    };
974    let current = Version::parse(current_version)?;
975    let target = Version::parse(&pending.target_version)?;
976    if current >= target {
977        return Ok(Coalesce::Proceed);
978    }
979    Ok(Coalesce::Skip {
980        target_version: pending.target_version.clone(),
981    })
982}
983
984/// What a still-open pending bump means once a fresh decision is in hand.
985#[derive(Debug, Clone, Copy, PartialEq, Eq)]
986pub enum PendingAction {
987    /// The new decision is no more severe than what is already queued; the
988    /// open pull request covers it once it lands.
989    AlreadyCovered,
990    /// The new decision outranks the pending target - escalate the open
991    /// pull request instead of opening a second one or dropping it.
992    Escalate,
993}
994
995/// Compare a fresh decision against what a still-open pull request already
996/// targets.
997///
998/// A patch bump left pending while a breaking change lands does not become a
999/// breaking release just because the pull request that carries both is
1000/// squashed into one commit: the *version number* still comes from whichever
1001/// digit was judged, and a pending `patch` never widens itself to `minor` on
1002/// its own. This is the check that decides an escalation is owed.
1003pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
1004    if decision_level.severity() > pending_level.severity() {
1005        PendingAction::Escalate
1006    } else {
1007        PendingAction::AlreadyCovered
1008    }
1009}
1010
1011/// Parse `gh pr view --json state` output. No I/O.
1012fn parse_pr_state(json: &str) -> Result<bool> {
1013    #[derive(Deserialize)]
1014    struct State {
1015        state: String,
1016    }
1017    let parsed: State =
1018        serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
1019    Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
1020}
1021
1022/// Is the pull request at `pr_url` still open?
1023///
1024/// Read fresh rather than trusted from the marker: a bump pull request can be
1025/// closed without merging - CI that never goes green, an operator who
1026/// decided against it - and nothing else in this module ever revisits a
1027/// marker once it is written. Without this check, that close is invisible
1028/// here forever: the marker still names a pending target, the base branch
1029/// never reaches it because nothing ever merged the pull request, and every
1030/// later merge skips in perpetuity. A `gh` failure (network, auth) answers
1031/// `true` - the same "unreadable is not absent" rule `land::CHECKS_GRACE`
1032/// uses - because guessing "closed" wrongly opens a second, competing pull
1033/// request, while guessing "open" wrongly only costs one more merge's wait.
1034async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
1035    let out = tokio::process::Command::new("gh")
1036        .args(["pr", "view", pr_url, "--json", "state"])
1037        .current_dir(repo)
1038        .quiet()
1039        .stdin(std::process::Stdio::null())
1040        .output()
1041        .await
1042        .context("spawn gh pr view")?;
1043    if !out.status.success() {
1044        bail!(
1045            "gh pr view {pr_url}: {}",
1046            String::from_utf8_lossy(&out.stderr).trim()
1047        );
1048    }
1049    parse_pr_state(&String::from_utf8_lossy(&out.stdout))
1050}
1051
1052/// How long a stale lock file is trusted to mean its owner is still working,
1053/// before it is reclaimed.
1054///
1055/// Long enough to cover the slowest real step this module takes - the agent
1056/// decision call ([`DECISION_TIMEOUT`]) plus `cargo build` and a `gh pr
1057/// create` - so a lock is only ever stolen from a process that has actually
1058/// gone (crashed, killed), never one still inside its own critical section.
1059const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
1060
1061/// A host-local mutual exclusion for one repository's marker file.
1062///
1063/// Built on exclusive file creation rather than a locking crate: neither
1064/// `flock` nor `fs2` is a dependency of this crate, and the constraints on
1065/// this change forbid adding one. This is not a distributed lock and does
1066/// not coordinate two machines racing the same repository - it exists to
1067/// close the specific race two `after_merge` calls on the *same* host can
1068/// hit landing within the same window (a human `magi run` alongside the
1069/// daemon, or two review loops): both would otherwise read "nothing
1070/// pending", judge independently, and open two competing pull requests, with
1071/// whichever `write_marker` runs last silently erasing the other's record.
1072struct MarkerLock {
1073    path: PathBuf,
1074}
1075
1076impl MarkerLock {
1077    /// Try to take the lock for `marker`, stealing a stale one first if it is
1078    /// old enough to mean its owner is gone rather than merely slow.
1079    /// `Ok(None)` means someone else genuinely holds it right now.
1080    #[cfg(test)]
1081    fn acquire(marker: &Path) -> Result<Option<Self>> {
1082        Self::acquire_with(marker, crate::lock_file::Retry::new(None))
1083    }
1084
1085    fn acquire_with(marker: &Path, retry: crate::lock_file::Retry) -> Result<Option<Self>> {
1086        let path = marker.with_extension("lock");
1087        if let Some(parent) = path.parent() {
1088            std::fs::create_dir_all(parent)
1089                .with_context(|| format!("create {}", parent.display()))?;
1090        }
1091        if Self::try_create(&path, retry)? {
1092            return Ok(Some(Self { path }));
1093        }
1094        if Self::is_stale(&path) {
1095            let _ = std::fs::remove_file(&path);
1096            if Self::try_create(&path, retry)? {
1097                return Ok(Some(Self { path }));
1098            }
1099        }
1100        Ok(None)
1101    }
1102
1103    fn try_create(path: &Path, retry: crate::lock_file::Retry) -> Result<bool> {
1104        match retry.create(path) {
1105            Ok(_) => Ok(true),
1106            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1107            Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
1108        }
1109    }
1110
1111    fn is_stale(path: &Path) -> bool {
1112        std::fs::metadata(path)
1113            .and_then(|m| m.modified())
1114            .ok()
1115            .and_then(|m| m.elapsed().ok())
1116            .is_some_and(|age| age >= LOCK_STALE_AFTER)
1117    }
1118}
1119
1120impl Drop for MarkerLock {
1121    fn drop(&mut self) {
1122        let _ = std::fs::remove_file(&self.path);
1123    }
1124}
1125
1126/// How often a blocked caller checks whether [`MarkerLock`] has freed up.
1127const LOCK_POLL: Duration = Duration::from_secs(5);
1128
1129/// How long a caller waits for a contended lock before giving up on this
1130/// merge's own judgement entirely.
1131///
1132/// A first version of this gate gave up the instant the lock was taken,
1133/// which meant a change landing while another host's decision call was
1134/// still running was never judged at all - not even recorded as pending,
1135/// not escalated later, just dropped. The lock is only ever held for one
1136/// `after_merge` call, so waiting past it is what lets that call's own
1137/// decision reach [`pending_action`] against a marker the other side just
1138/// finished writing, instead of finding nothing to check against. Set just
1139/// under [`LOCK_STALE_AFTER`]: a lock still held this long after that point
1140/// is reclaimed as abandoned rather than waited on further.
1141const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
1142
1143/// Wait for [`MarkerLock`] to free up, polling rather than blocking forever.
1144/// `Ok(None)` means the ceiling passed with the lock still held.
1145async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
1146    wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
1147}
1148
1149/// [`wait_for_marker_lock`] with the poll interval and ceiling as parameters,
1150/// so the retry behaviour is testable without a test actually waiting out
1151/// [`LOCK_WAIT_CEILING`].
1152async fn wait_for_marker_lock_with(
1153    marker: &Path,
1154    poll: Duration,
1155    ceiling: Duration,
1156) -> Result<Option<MarkerLock>> {
1157    let deadline = std::time::Instant::now() + ceiling;
1158    let retry = crate::lock_file::Retry::new(Some(deadline));
1159    loop {
1160        // Delete-pending retries must not block the async executor.
1161        let marker = marker.to_owned();
1162        let acquired =
1163            tokio::task::spawn_blocking(move || MarkerLock::acquire_with(&marker, retry))
1164                .await
1165                .context("join marker lock acquisition")??;
1166        if let Some(lock) = acquired {
1167            return Ok(Some(lock));
1168        }
1169        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1170        if remaining.is_zero() {
1171            return Ok(None);
1172        }
1173        tokio::time::sleep(poll.min(remaining)).await;
1174    }
1175}
1176
1177/// Which digit differs between `from` and `to`? `None` when they are equal.
1178///
1179/// Used to recover the level a pull request found by [`find_open_release_pr`]
1180/// was judged at: the forge has the resulting version (in the branch name and
1181/// the title) but not the digit an agent chose to get there, and this is the
1182/// one other host-independent fact every host can compute the same way from
1183/// it.
1184fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
1185    if to.major != from.major {
1186        Some(BumpLevel::Major)
1187    } else if to.minor != from.minor {
1188        Some(BumpLevel::Minor)
1189    } else if to.patch != from.patch {
1190        Some(BumpLevel::Patch)
1191    } else {
1192        None
1193    }
1194}
1195
1196/// Parse `gh pr list --state open --json url,headRefName` output, returning
1197/// the first pull request whose branch is one of this module's own. No I/O.
1198fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
1199    #[derive(Deserialize)]
1200    struct Pr {
1201        url: String,
1202        #[serde(rename = "headRefName")]
1203        head_ref_name: String,
1204    }
1205    let list: Vec<Pr> =
1206        serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
1207    Ok(list
1208        .into_iter()
1209        .find(|p| p.head_ref_name.starts_with("chore/release-v"))
1210        .map(|p| (p.head_ref_name, p.url)))
1211}
1212
1213/// Every open pull request on one of this module's own branches, as
1214/// `(branch, url)`. No I/O.
1215pub(crate) fn parse_open_release_prs(json: &str) -> Result<Vec<(String, String)>> {
1216    #[derive(Deserialize)]
1217    struct Pr {
1218        url: String,
1219        #[serde(rename = "headRefName")]
1220        head_ref_name: String,
1221    }
1222    let list: Vec<Pr> =
1223        serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
1224    Ok(list
1225        .into_iter()
1226        .filter(|p| p.head_ref_name.starts_with("chore/release-v"))
1227        .map(|p| (p.head_ref_name, p.url))
1228        .collect())
1229}
1230
1231/// [`find_open_release_pr`], but every match rather than the first.
1232pub(crate) async fn list_open_release_prs(repo: &Path) -> Result<Vec<(String, String)>> {
1233    let out = tokio::process::Command::new("gh")
1234        .args([
1235            "pr",
1236            "list",
1237            "--state",
1238            "open",
1239            "--limit",
1240            "100",
1241            "--json",
1242            "url,headRefName",
1243        ])
1244        .current_dir(repo)
1245        .env_remove("GH_REPO")
1246        .quiet()
1247        .stdin(std::process::Stdio::null())
1248        .output()
1249        .await
1250        .context("spawn gh pr list")?;
1251    if !out.status.success() {
1252        bail!(
1253            "gh pr list: {}",
1254            String::from_utf8_lossy(&out.stderr).trim()
1255        );
1256    }
1257    parse_open_release_prs(&String::from_utf8_lossy(&out.stdout))
1258}
1259
1260/// Ask the forge directly whether a release bump is already open, for a host
1261/// that has never seen it.
1262///
1263/// [`MarkerLock`] and the marker file only ever coordinate *this* host - a
1264/// marker written on one machine is not visible to `run::home()` on another,
1265/// so two hosts landing runs against the same repository at the same time
1266/// can each read "nothing pending" and open a competing pull request no
1267/// local lock can see. `gh pr list` is the one place every host actually
1268/// shares a view, so it is consulted whenever this host's own marker says
1269/// there is nothing pending, before a fresh decision is allowed to open a
1270/// second pull request. This narrows the race to the gap between this call
1271/// and whichever host's `gh pr create` lands first - it does not close it -
1272/// because turning that into a real distributed lock would need coordination
1273/// this crate has no dependency for.
1274async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
1275    let out = tokio::process::Command::new("gh")
1276        .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
1277        .current_dir(repo)
1278        .quiet()
1279        .stdin(std::process::Stdio::null())
1280        .output()
1281        .await
1282        .context("spawn gh pr list")?;
1283    if !out.status.success() {
1284        bail!(
1285            "gh pr list: {}",
1286            String::from_utf8_lossy(&out.stderr).trim()
1287        );
1288    }
1289    parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
1290}
1291
1292/// After a merge lands, ask an agent how big the change was and open a
1293/// release bump sized to it.
1294///
1295/// Best-effort by construction, the same way `clean::fold_due` treats one
1296/// run's fold failure: this runs after the merge the run exists to produce
1297/// has already succeeded, so a failure here (the decision call, `gh`,
1298/// `cargo`) must never turn a landed run into a failed one. The caller logs
1299/// whatever this returns and moves on.
1300///
1301/// An `Err` is a bump that was tried and failed, never one that was not
1302/// eligible (every such case returns `Ok` after an event). When it fails
1303/// before any release pull request exists, the operator is notified here, so
1304/// the caller only has to record the event: nobody would otherwise learn that
1305/// a release silently never happened. A failure with a pull request in hand
1306/// goes through [`report_problem`] with its URL instead, and is not repeated.
1307/// A failure while a pending release pull request is in play (the decision or
1308/// an escalation of it) is notified here too, naming that pull request: the
1309/// escalation path itself never raises one, so suppressing it would leave the
1310/// operator with nothing.
1311pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
1312    after_merge_at(state, pr_url, None, None).await
1313}
1314
1315/// What one attempt had settled by the time it failed.
1316#[derive(Debug, Default)]
1317struct Progress {
1318    /// The target version, once the decision had been made.
1319    version: Option<String>,
1320    /// A release pull request already pending when this attempt started.
1321    pending_pr: Option<String>,
1322    /// Why the bump was deliberately not made.
1323    skip: Option<Skip>,
1324}
1325
1326/// A bump that was deliberately not made. Recorded on the run so it can never
1327/// read as a completed release.
1328#[derive(Debug)]
1329struct Skip {
1330    /// Why, in one line.
1331    reason: String,
1332    /// What the owner has to do, when the skip needs them (this also raises a
1333    /// notice).
1334    action: Option<String>,
1335}
1336
1337/// Record a skip on the run, and page the owner when it needs them.
1338fn record_skip(state: &mut RunState, store: Option<&Notices>, skip: &Skip) {
1339    let record = state.release_bump.get_or_insert_with(Default::default);
1340    record.skipped = Some(skip.reason.clone());
1341    let Some(action) = &skip.action else {
1342        return;
1343    };
1344    record.action_required = Some(action.clone());
1345    let notice = Notice::error(
1346        &format!("release-bump:{}", state.id),
1347        format!(
1348            "Run {} merged, but its release bump was skipped: {action}.",
1349            state.id
1350        ),
1351    )
1352    .link(Link::Run {
1353        id: state.id.clone(),
1354    });
1355    let store = store.cloned().unwrap_or_else(Notices::open);
1356    if let Err(e) = store.raise_with(notice, &state.config.notify) {
1357        state.event("bump", format!("could not raise a notice: {e:#}"));
1358    }
1359    state.event("bump", format!("needs attention: {action}"));
1360}
1361
1362/// The `(version, reason)` to notify with for a failed attempt. The reason is
1363/// the first line of the full error chain, so the operator can tell a stale
1364/// branch from a build failure. A pull request this attempt opened never reaches here: past
1365/// `gh pr create` nothing returns `Err`, and automerge trouble goes through
1366/// [`report_problem`] itself.
1367fn notice_for_failure(progress: &Progress, err: &anyhow::Error) -> (Option<String>, String) {
1368    let chain = format!("{err:#}");
1369    let first = chain.lines().next().unwrap_or_default().trim();
1370    (progress.version.clone(), first.to_owned())
1371}
1372
1373/// Does the base branch carry a `Cargo.toml` at its root? Release bumps read
1374/// and rewrite that file, so its absence means "not a Rust repository", which
1375/// is not a fault. `ls-tree` rather than `cat-file -e`, so an unresolvable
1376/// ref (a failed fetch, a misconfigured base) stays an error instead of being
1377/// reported as a non-Rust repository.
1378#[cfg(test)]
1379async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
1380    base_has_file(repo, remote, base, release_manifest::DEFAULT_PRIMARY).await
1381}
1382
1383/// [`base_has_cargo_toml`] for any repository-relative path.
1384async fn base_has_file(repo: &Path, remote: &str, base: &str, path: &str) -> Result<bool> {
1385    let out = git::git(
1386        repo,
1387        &[
1388            "ls-tree",
1389            "--name-only",
1390            &format!("{remote}/{base}"),
1391            "--",
1392            path,
1393        ],
1394    )
1395    .await
1396    .with_context(|| format!("look for {path} on the base branch"))?;
1397    Ok(!out.trim().is_empty())
1398}
1399
1400/// The commit a change was cut from: the merge-base of `<remote>/<base>` and
1401/// the branch, never a possibly stale local branch. `None` when it cannot be
1402/// found or the branch is already contained in the remote base (unknown).
1403async fn change_base(dir: &Path, remote: &str, base: &str, branch: &str) -> Option<String> {
1404    let tip = git::rev_parse(dir, branch).await.ok()?;
1405    let mb = git::git(dir, &["merge-base", &format!("{remote}/{base}"), branch])
1406        .await
1407        .ok()?;
1408    let mb = mb.trim().to_owned();
1409    (!mb.is_empty() && mb != tip).then_some(mb)
1410}
1411
1412/// The version of record's version at `rev`, read in `dir`. `None` when it
1413/// cannot be read (unknown, never "unchanged").
1414async fn primary_version_at(dir: &Path, rev: &str, path: &str) -> Option<String> {
1415    let out = git::git_raw(dir, &["show", &format!("{rev}:{path}")])
1416        .await
1417        .ok()?;
1418    if out.code != Some(0) {
1419        return None;
1420    }
1421    current_version(&out.stdout).ok()
1422}
1423
1424/// [`after_merge`] with an optional magi home, so tests can point the
1425/// marker and its lock at a scratch directory.
1426/// `store` is where a notice is raised; `None` is the operator's own.
1427async fn after_merge_at(
1428    state: &mut RunState,
1429    pr_url: &str,
1430    home: Option<&Path>,
1431    store: Option<&Notices>,
1432) -> Result<()> {
1433    let mut progress = Progress::default();
1434    let result = after_merge_inner(state, pr_url, home, &mut progress).await;
1435    if let Some(skip) = &progress.skip {
1436        record_skip(state, store, skip);
1437    }
1438    if let Err(e) = &result {
1439        let (version, reason) = notice_for_failure(&progress, e);
1440        let pending = progress.pending_pr.as_deref();
1441        let store = store.cloned().unwrap_or_else(Notices::open);
1442        report_problem_in(
1443            state,
1444            &store,
1445            pending,
1446            version.as_deref(),
1447            &reason,
1448            pending.is_some(),
1449        )
1450        .await;
1451    }
1452    result
1453}
1454
1455async fn after_merge_inner(
1456    state: &mut RunState,
1457    pr_url: &str,
1458    home: Option<&Path>,
1459    progress: &mut Progress,
1460) -> Result<()> {
1461    if !state.config.merge.release_bump {
1462        return Ok(());
1463    }
1464    let Some(winner) = state.winner().cloned() else {
1465        return Ok(());
1466    };
1467    let repo = state.repo.clone();
1468    let base = state.base_branch.clone();
1469    let remote = state.config.merge.remote.clone();
1470
1471    let files = git::changed_files(&winner.worktree, &base, &winner.branch)
1472        .await
1473        .unwrap_or_default();
1474    let declared = state.config.release.version_files.clone();
1475    let custom = release_manifest::is_custom(&declared);
1476    let primary = release_manifest::primary_path(&declared).to_owned();
1477    if release_manifest::release_only(&files, &declared) {
1478        // With declared files the file names alone are not enough (a
1479        // dependency update touches `package.json` too): the version of
1480        // record must not be provably unchanged.
1481        let mb = change_base(&winner.worktree, &remote, &base, &winner.branch).await;
1482        let unchanged = custom
1483            && match mb {
1484                Some(mb) => matches!(
1485                    (
1486                        primary_version_at(&winner.worktree, &mb, &primary).await,
1487                        primary_version_at(&winner.worktree, &winner.branch, &primary).await,
1488                    ),
1489                    (Some(before), Some(after)) if before == after
1490                ),
1491                None => false,
1492            };
1493        if !unchanged {
1494            state.event(
1495                "bump",
1496                "the merged change touches only the release manifest; not treating it as a trigger",
1497            );
1498            progress.skip = Some(Skip {
1499                reason: "the merged change touches only the release files; not a trigger"
1500                    .to_owned(),
1501                action: None,
1502            });
1503            return Ok(());
1504        }
1505    }
1506
1507    // Outside the lock, and before anything that assumes a Rust manifest.
1508    // Fetch first so a stale remote-tracking ref cannot misjudge the base.
1509    git::fetch(&repo, &remote, &base).await.ok();
1510    if !base_has_file(&repo, &remote, &base, &primary).await? {
1511        state.event(
1512            "bump",
1513            if custom {
1514                format!(
1515                    "release bump: no {primary} on the base branch (from [release] version_files); skipping"
1516                )
1517            } else {
1518                "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
1519                    .to_owned()
1520            },
1521        );
1522        // A repository that releases from a workflow but has no manifest magi
1523        // can read is one whose release silently never happens: say so.
1524        let releases_by_workflow =
1525            base_has_file(&repo, &remote, &base, ".github/workflows/auto-tag.yml")
1526                .await
1527                .unwrap_or(false);
1528        let ja = crate::lang::is_japanese(&state.config.graph.language);
1529        let action = releases_by_workflow.then(|| {
1530            if ja {
1531                format!(
1532                    "base ブランチに {primary} がありません。auto-tag.yml があるリポジトリです。magi.toml の [release] version_files に版の正本 (auto-tag.yml が読む Cargo.toml) を指定するか、リリースを手で行ってください"
1533                )
1534            } else {
1535                format!(
1536                    "the base branch has no {primary} but does have .github/workflows/auto-tag.yml; set [release] version_files in magi.toml to the version of record auto-tag.yml reads, or release by hand"
1537                )
1538            }
1539        });
1540        progress.skip = Some(Skip {
1541            reason: format!("no {primary} on the base branch; no release bump was made"),
1542            action,
1543        });
1544        return Ok(());
1545    }
1546
1547    let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
1548    // Held for the rest of this function: the whole read-decide-write
1549    // sequence below is the critical section two `after_merge` calls landing
1550    // within the same window must not both be inside at once. See
1551    // `MarkerLock`'s own doc for why a second, unrelated bump PR is what
1552    // that race produces without it, and `wait_for_marker_lock`'s for why
1553    // this waits rather than giving up the instant it is contended.
1554    let Some(_lock) = wait_for_marker_lock(&marker).await? else {
1555        state.event(
1556            "bump",
1557            "another release bump decision held the lock past the wait ceiling; skipping this round",
1558        );
1559        return Ok(());
1560    };
1561
1562    git::fetch(&repo, &remote, &base).await.ok();
1563    let mut versions = Vec::new();
1564    for f in release_manifest::effective(&declared) {
1565        let text = git::git(&repo, &["show", &format!("{remote}/{base}:{}", f.path)])
1566            .await
1567            .with_context(|| format!("read {} from the base branch", f.path))?;
1568        let v = release_manifest::read_version(f.kind, &text)
1569            .with_context(|| format!("read the version of {}", f.path))?;
1570        versions.push((f.path, v));
1571    }
1572    let base_version = match release_manifest::agree(&versions) {
1573        Ok(v) => v,
1574        Err(e) => {
1575            let reason = format!("{e:#}");
1576            state.event("bump", format!("release bump skipped: {reason}"));
1577            progress.skip = Some(Skip {
1578                action: Some(format!("{reason}; align them by hand, then release")),
1579                reason,
1580            });
1581            return Ok(());
1582        }
1583    };
1584    // Local mode has no auto-tag to hand the release to: the bump PR and the
1585    // watcher's approval are the release path, so it always proceeds.
1586    if custom && !state.config.release.is_local() {
1587        // The change itself may already have raised the version; the
1588        // repository's own auto-tag then releases it, and a second bump
1589        // would publish a duplicate. Measured against the remote base the
1590        // branch was cut from, never a possibly stale local branch; a branch
1591        // already contained in it (merged without squashing) is unknown.
1592        let mb = change_base(&winner.worktree, &remote, &base, &winner.branch)
1593            .await
1594            .unwrap_or_default();
1595        if !mb.is_empty()
1596            && let (Some(before), Some(after)) = (
1597                primary_version_at(&winner.worktree, &mb, &primary).await,
1598                primary_version_at(&winner.worktree, &winner.branch, &primary).await,
1599            )
1600            && before != after
1601        {
1602            state.event(
1603                "bump",
1604                format!(
1605                    "the merged change already moved {primary} from v{before} to v{after}; leaving the release to the repository's auto-tag"
1606                ),
1607            );
1608            progress.skip = Some(Skip {
1609                reason: format!(
1610                    "the change itself raised {primary} from v{before} to v{after}; the repository's auto-tag releases it"
1611                ),
1612                action: None,
1613            });
1614            return Ok(());
1615        }
1616    }
1617
1618    let mut pending = read_marker(&marker);
1619    if let Some(p) = &pending {
1620        match coalesce(Some(p), &base_version)? {
1621            Coalesce::Proceed => {
1622                // Landed, or superseded by a manual bump: free for a fresh
1623                // decision.
1624                clear_marker(&marker);
1625                pending = None;
1626            }
1627            Coalesce::Skip { target_version } => {
1628                if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
1629                    state.event(
1630                        "bump",
1631                        format!(
1632                            "the pending release bump to v{target_version} ({}) is no longer \
1633                             open; treating it as abandoned",
1634                            p.pr_url
1635                        ),
1636                    );
1637                    clear_marker(&marker);
1638                    pending = None;
1639                }
1640                // Otherwise still genuinely open: fall through and ask the
1641                // same question this merge would get on a fresh path, so a
1642                // more severe change landing while it waits can escalate it
1643                // instead of being silently absorbed at the wrong digit.
1644            }
1645        }
1646    }
1647
1648    if pending.is_none() {
1649        // This host's own marker has nothing to say - check the forge itself
1650        // before trusting that to mean a fresh pull request is safe to open.
1651        // See `find_open_release_pr`'s own doc for what this does and does
1652        // not close.
1653        if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
1654            && let Some(target) = branch_version(&repo, &remote, &branch, &primary)
1655                .await
1656                .and_then(|v| Version::parse(&v).ok())
1657        {
1658            let base_parsed = Version::parse(&base_version)?;
1659            if target > base_parsed
1660                && let Some(level) = level_between(base_parsed, target)
1661            {
1662                let adopted = PendingBump {
1663                    target_version: target.to_string(),
1664                    level,
1665                    branch,
1666                    pr_url: url,
1667                };
1668                // Best-effort: worst case this host asks the forge again
1669                // next time instead of finding its own record of it.
1670                let _ = write_marker(&marker, &adopted);
1671                pending = Some(adopted);
1672            }
1673        }
1674    }
1675
1676    progress.pending_pr = pending.as_ref().map(|p| p.pr_url.clone());
1677    let title = pr_title(&repo, pr_url).await.unwrap_or_default();
1678    let subject = land::merge_subject(
1679        crate::graph::landing_title(state, &title),
1680        &crate::graph::landing_subject_source(state),
1681    );
1682    let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
1683        .await
1684        .unwrap_or_default();
1685    let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
1686
1687    let (spec, mut seat, mut decision) =
1688        ask_decision(state, &repo, &prompt, DECISION_TIMEOUT).await?;
1689    guard_reason(state, &repo, &prompt, &spec, &mut seat, &mut decision).await;
1690
1691    if let Some(p) = pending {
1692        return match pending_action(p.level, decision.level) {
1693            PendingAction::AlreadyCovered => {
1694                state.event(
1695                    "bump",
1696                    format!(
1697                        "a release bump to v{} ({}) already covers at least a {} change; not \
1698                         opening another",
1699                        p.target_version,
1700                        p.pr_url,
1701                        decision.level.as_str()
1702                    ),
1703                );
1704                Ok(())
1705            }
1706            PendingAction::Escalate => {
1707                progress.version = Some(
1708                    Version::parse(&base_version)?
1709                        .bump(decision.level)
1710                        .to_string(),
1711                );
1712                escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
1713            }
1714        };
1715    }
1716
1717    let next = Version::parse(&base_version)?
1718        .bump(decision.level)
1719        .to_string();
1720    progress.version = Some(next.clone());
1721    let branch = format!("chore/release-v{next}");
1722    let worktree = state.dir().join("bump");
1723    let (shared, branch_ref, next_ref, decision_ref) = (&mut *state, &branch, &next, &decision);
1724    let (pr_url_opened, outcome) =
1725        release_attempt(
1726            &repo,
1727            &remote,
1728            &base,
1729            &worktree,
1730            &branch,
1731            |head| {
1732                let repo = repo.clone();
1733                async move { gh_open_pr_for_head(&repo, &head).await }
1734            },
1735            |wt| async move {
1736                open_bump_pr(shared, &wt, branch_ref, next_ref, decision_ref, pr_url).await
1737            },
1738        )
1739        .await?;
1740    let local = outcome == AutomergeOutcome::LocalGate;
1741    let (automerge_warning, merged_detail) = match outcome {
1742        AutomergeOutcome::Enabled | AutomergeOutcome::LocalGate => (None, None),
1743        AutomergeOutcome::MergedDirectly { detail } => (None, Some(detail)),
1744        AutomergeOutcome::Failed { reason } => (Some(reason), None),
1745    };
1746
1747    // The pull request exists on the forge the moment `open_bump_pr` returns
1748    // its URL, regardless of what happens next - so the event that names it
1749    // is unconditional, and a marker write failing (a full disk, a missing
1750    // `home/bump` directory) is reported as its own warning rather than
1751    // swallowing that URL entirely the way propagating it with `?` would.
1752    // `find_open_release_pr` is the fallback if this leaves no local record:
1753    // the next merge that finds no marker still finds this pull request on
1754    // the forge before opening a second one.
1755    let marker_write = write_marker(
1756        &marker,
1757        &PendingBump {
1758            target_version: next.clone(),
1759            level: decision.level,
1760            branch,
1761            pr_url: pr_url_opened.clone(),
1762        },
1763    );
1764    state.event(
1765        "bump",
1766        format!(
1767            "opened a {} release bump to v{next} ({}): {pr_url_opened}",
1768            decision.level.as_str(),
1769            decision.reason
1770        ),
1771    );
1772    if let Err(e) = marker_write {
1773        state.event(
1774            "bump",
1775            format!(
1776                "could not record the pending release bump marker for v{next}: {e:#}; a later \
1777                 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
1778                 the forge either"
1779            ),
1780        );
1781    }
1782    state.release_bump = Some(run::ReleaseBump {
1783        pr_url: Some(pr_url_opened.clone()),
1784        version: Some(next.clone()),
1785        automerge_enabled: automerge_warning.is_none() && merged_detail.is_none() && !local,
1786        merged_directly: merged_detail.is_some(),
1787        local,
1788        ..run::ReleaseBump::default()
1789    });
1790    if let Some(detail) = merged_detail {
1791        // Merged already: a pending marker would make the next merge wait on
1792        // a pull request that is gone.
1793        clear_marker(&marker);
1794        state.event("bump", format!("merged v{next} directly: {detail}"));
1795    }
1796    if let Some(warning) = automerge_warning {
1797        state.event(
1798            "bump",
1799            format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
1800        );
1801        report_problem(state, Some(&pr_url_opened), Some(&next), &warning).await;
1802    }
1803    Ok(())
1804}
1805
1806/// The node name post-merge notices were filed under, back when they were
1807/// questions. Kept because [`ask::Questions::settle_run`] must go on exempting
1808/// any such question still open on disk; new problems go to
1809/// [`crate::notices`] instead - nothing here is something to answer.
1810pub const NOTICE_NODE: &str = "release-bump";
1811
1812/// What to tell the operator to do about a refused `gh pr merge --auto`.
1813///
1814/// Keys on the wording GitHub is known to use when the base branch has no
1815/// required status checks (`enablePullRequestAutoMerge` / "protected branch
1816/// rules"); anything else falls back to the generic advice, with the reason
1817/// carried verbatim next to it.
1818fn automerge_hint(reason: &str) -> &'static str {
1819    let r = reason.to_lowercase();
1820    if is_clean_status_refusal(reason) {
1821        "merge the release pull request by hand; CI is already green"
1822    } else if r.contains("enablepullrequestautomerge") || r.contains("protected branch rules") {
1823        "merge the release pull request by hand, and enable branch protection with required \
1824         status checks on the base branch so automerge can work next time"
1825    } else {
1826        "merge the release pull request by hand"
1827    }
1828}
1829
1830/// The comment left on the release pull request itself.
1831fn automerge_failure_comment(reason: &str) -> String {
1832    format!(
1833        "magi could not enable automerge on this pull request: {reason}\n\n\
1834         Action required: {}. Until then the release does not happen.",
1835        automerge_hint(reason)
1836    )
1837}
1838
1839/// Record a post-merge problem on the run and raise the operator-facing
1840/// notification, without touching the forge. Returns the notice and the
1841/// comment body meant for the release pull request when there is one.
1842///
1843/// Split from [`report_problem`] so the state, the notice and the wording
1844/// can be asserted without a `gh`. The notice is keyed on the run, so a retry
1845/// of the same failed bump folds into one entry instead of flooding the bell.
1846#[cfg(test)]
1847fn surface_problem(
1848    state: &mut RunState,
1849    store: &Notices,
1850    pr_url: Option<&str>,
1851    version: Option<&str>,
1852    reason: &str,
1853) -> Result<(Notice, Option<String>)> {
1854    surface_problem_in(state, store, pr_url, version, reason, false)
1855}
1856
1857/// [`surface_problem`], where `pending` says `pr_url` is a release pull
1858/// request that already existed and could not be raised to `version` - not one
1859/// whose automerge failed. It is linked and recorded, but gets no comment.
1860fn surface_problem_in(
1861    state: &mut RunState,
1862    store: &Notices,
1863    pr_url: Option<&str>,
1864    version: Option<&str>,
1865    reason: &str,
1866    pending: bool,
1867) -> Result<(Notice, Option<String>)> {
1868    let action = if let (true, Some(url)) = (pending, pr_url) {
1869        let ja = crate::lang::is_japanese(&state.config.graph.language);
1870        let first = reason.lines().next().filter(|l| !l.is_empty());
1871        let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1872        if ja {
1873            let target = version.map(|v| format!(" v{v}")).unwrap_or_default();
1874            format!(
1875                "既存のリリース PR {url} を{target}へ更新できませんでした{cause}。PR を手で更新してください"
1876            )
1877        } else {
1878            let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1879            format!(
1880                "the pending release pull request {url} could not be updated{target}{cause}; update it by hand"
1881            )
1882        }
1883    } else if pr_url.is_some() {
1884        automerge_hint(reason).to_owned()
1885    } else {
1886        let ja = crate::lang::is_japanese(&state.config.graph.language);
1887        let first = reason.lines().next().filter(|l| !l.is_empty());
1888        if ja {
1889            let target = version.map(|v| format!(" (v{v})")).unwrap_or_default();
1890            let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1891            format!(
1892                "リリースバンプ{target}は実行されませんでした{cause}。リリース PR を手で開いてください"
1893            )
1894        } else {
1895            let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1896            let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1897            format!(
1898                "the release bump{target} did not run{cause}; open the release pull request by hand"
1899            )
1900        }
1901    };
1902    let record = state.release_bump.get_or_insert_with(Default::default);
1903    record.pr_url = pr_url.map(str::to_owned).or(record.pr_url.take());
1904    record.version = version.map(str::to_owned).or(record.version.take());
1905    record.automerge_enabled = false;
1906    record.problem = Some(reason.to_owned());
1907    record.action_required = Some(action.clone());
1908
1909    let mut notice = Notice::error(
1910        &format!("release-bump:{}", state.id),
1911        format!(
1912            "Run {} merged, but its release step failed: {action}.",
1913            state.id
1914        ),
1915    );
1916    notice = match pr_url {
1917        Some(url) => notice.link(Link::Url {
1918            url: url.to_owned(),
1919        }),
1920        None => notice.link(Link::Run {
1921            id: state.id.clone(),
1922        }),
1923    };
1924    let notice = store
1925        .raise_with(
1926            notice
1927                .about([state.id.clone()])
1928                .for_run(Some(state.id.clone())),
1929            &state.config.notify,
1930        )
1931        .context("raise the release-bump notification")?;
1932    state.event("bump", format!("needs attention: {action}"));
1933    let comment = pr_url
1934        .filter(|_| !pending)
1935        .map(|_| automerge_failure_comment(reason));
1936    Ok((notice, comment))
1937}
1938
1939/// Make a post-merge problem visible: record it, comment on the release pull
1940/// request, raise a notification and run the configured notifier. Every step
1941/// is best-effort - a failed comment or webhook is an event, never a reason to
1942/// lose the record or the run.
1943pub async fn report_problem(
1944    state: &mut RunState,
1945    pr_url: Option<&str>,
1946    version: Option<&str>,
1947    reason: &str,
1948) {
1949    report_problem_in(state, &Notices::open(), pr_url, version, reason, false).await;
1950}
1951
1952/// [`report_problem`] against an explicit notice store.
1953async fn report_problem_in(
1954    state: &mut RunState,
1955    store: &Notices,
1956    pr_url: Option<&str>,
1957    version: Option<&str>,
1958    reason: &str,
1959    pending: bool,
1960) {
1961    match surface_problem_in(state, store, pr_url, version, reason, pending) {
1962        Ok((_notice, comment)) => {
1963            if let (Some(url), Some(body)) = (pr_url, comment)
1964                && let Err(e) = gh_pr_comment(state, url, &body).await
1965            {
1966                state.event("bump", format!("could not comment on {url}: {e:#}"));
1967            }
1968        }
1969        Err(e) => state.event("bump", format!("could not raise a notice: {e:#}")),
1970    }
1971}
1972
1973pub(crate) async fn gh_pr_comment(state: &mut RunState, pr_url: &str, body: &str) -> Result<()> {
1974    let (_, body) = crate::github_text::prepare(state, "", body);
1975    let cwd = &state.repo;
1976    let out = tokio::process::Command::new("gh")
1977        .args(["pr", "comment", pr_url, "--body", &body])
1978        .current_dir(cwd)
1979        .quiet()
1980        .stdin(std::process::Stdio::null())
1981        .output()
1982        .await
1983        .context("spawn gh pr comment")?;
1984    if out.status.success() {
1985        Ok(())
1986    } else {
1987        bail!(
1988            "gh pr comment: {}",
1989            String::from_utf8_lossy(&out.stderr).trim()
1990        )
1991    }
1992}
1993
1994/// Bump an already-open release pull request further, because a change more
1995/// severe than what it already covers landed while it waited on CI or
1996/// automerge - see [`pending_action`].
1997///
1998/// Adds a second commit rather than rewriting the first: `gh pr merge
1999/// --squash` prefers a single commit's own message over the pull request's
2000/// title, and falls back to the title once there is more than one commit -
2001/// so the title is what is kept honest here, via `gh pr edit`.
2002async fn escalate_pending(
2003    state: &mut RunState,
2004    repo: &Path,
2005    remote: &str,
2006    pending: &PendingBump,
2007    decision: &BumpDecision,
2008    base_version: &str,
2009    marker: &Path,
2010) -> Result<()> {
2011    let next = Version::parse(base_version)?
2012        .bump(decision.level)
2013        .to_string();
2014    let worktree = state.dir().join("bump");
2015    git::worktree_remove(repo, &worktree).await.ok();
2016    let checked_out = git::git_raw(
2017        repo,
2018        &[
2019            "worktree",
2020            "add",
2021            "--force",
2022            &worktree.to_string_lossy(),
2023            &pending.branch,
2024        ],
2025    )
2026    .await?;
2027    if !checked_out.ok() {
2028        bail!(
2029            "checking out the pending release branch {} failed: {}",
2030            pending.branch,
2031            checked_out.stderr
2032        );
2033    }
2034
2035    // Only the substantive change - the commit landing on the remote branch
2036    // - has to succeed for the escalation to have happened at all. Anything
2037    // after the push is a follow-up, not a precondition: the branch already
2038    // carries the new version whether or not it succeeds.
2039    let pushed: Result<()> = async {
2040        write_release_files(state, &worktree, &next).await?;
2041        let committed = git::commit_all(
2042            &worktree,
2043            &format!(
2044                "chore: release v{next} (supersedes v{})",
2045                pending.target_version
2046            ),
2047        )
2048        .await
2049        .context("commit the escalated version bump")?;
2050        if !committed {
2051            bail!("escalating the version bump left nothing to commit");
2052        }
2053        let pushed = git::push(&worktree, remote, &pending.branch).await?;
2054        if !pushed.ok() {
2055            bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
2056        }
2057        Ok(())
2058    }
2059    .await;
2060    if let Err(e) = pushed {
2061        git::worktree_remove(repo, &worktree).await.ok();
2062        return Err(e);
2063    }
2064
2065    // The commit is on the remote branch now regardless of what happens
2066    // below - the title edit is cosmetic, and the marker and the event must
2067    // both reflect the real, already-pushed state even if it fails.
2068    let (title, _) = crate::github_text::prepare(
2069        state,
2070        &format!("chore: release v{next} ({} bump)", decision.level.as_str()),
2071        "",
2072    );
2073    let title_warning = match gh_pr_edit_title(&worktree, &pending.pr_url, &title).await {
2074        Ok(()) => None,
2075        Err(e) => Some(e.to_string()),
2076    };
2077    git::worktree_remove(repo, &worktree).await.ok();
2078
2079    let marker_write = write_marker(
2080        marker,
2081        &PendingBump {
2082            target_version: next.clone(),
2083            level: decision.level,
2084            branch: pending.branch.clone(),
2085            pr_url: pending.pr_url.clone(),
2086        },
2087    );
2088    state.event(
2089        "bump",
2090        format!(
2091            "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
2092            pending.target_version,
2093            decision.level.as_str(),
2094            decision.reason,
2095            pending.pr_url
2096        ),
2097    );
2098    if let Err(e) = marker_write {
2099        state.event(
2100            "bump",
2101            format!(
2102                "could not update the pending release bump marker to v{next}: {e:#}; a later \
2103                 merge may misjudge whether it is already covered"
2104            ),
2105        );
2106    }
2107    if let Some(warning) = title_warning {
2108        state.event(
2109            "bump",
2110            format!(
2111                "pushed v{next} to {} but could not update its title: {warning}; the squashed \
2112                 subject may still read the superseded version",
2113                pending.pr_url
2114            ),
2115        );
2116    }
2117    Ok(())
2118}
2119
2120/// Does an open pull request have `branch` as its head? Any head repository
2121/// counts: the question is only whether the branch name is spoken for.
2122async fn gh_open_pr_for_head(repo: &Path, branch: &str) -> Result<bool> {
2123    let out = tokio::process::Command::new("gh")
2124        .args([
2125            "pr", "list", "--head", branch, "--state", "open", "--json", "url",
2126        ])
2127        .current_dir(repo)
2128        .quiet()
2129        .stdin(std::process::Stdio::null())
2130        .output()
2131        .await
2132        .context("spawn gh pr list")?;
2133    if !out.status.success() {
2134        bail!(
2135            "gh pr list --head {branch}: {}",
2136            String::from_utf8_lossy(&out.stderr).trim()
2137        );
2138    }
2139    let prs: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout)
2140        .with_context(|| format!("parse the pull requests headed by {branch}"))?;
2141    Ok(!prs.is_empty())
2142}
2143
2144/// Create the release branch and worktree, run `fill` in it, and leave nothing
2145/// behind unless a pull request came out of it.
2146///
2147/// A leftover `chore/release-vX.Y.Z` from an earlier failed attempt is
2148/// inspected rather than fatal ([`reclaim_stale_branch`]), once per attempt. A
2149/// failure of `fill` - or of creating the worktree - removes the worktree and
2150/// the branch this attempt made, except a branch that reached the remote,
2151/// which is kept and said so in the returned error.
2152async fn release_attempt<T, F, Fut, P, PFut>(
2153    repo: &Path,
2154    remote: &str,
2155    base: &str,
2156    worktree: &Path,
2157    branch: &str,
2158    open_pr: P,
2159    fill: F,
2160) -> Result<T>
2161where
2162    F: FnOnce(PathBuf) -> Fut,
2163    Fut: std::future::Future<Output = Result<T>>,
2164    P: Fn(String) -> PFut,
2165    PFut: std::future::Future<Output = Result<bool>>,
2166{
2167    let start = format!("{remote}/{base}");
2168    git::worktree_remove(repo, worktree).await.ok();
2169    let mut retried = false;
2170    if git::branch_exists(repo, branch).await? {
2171        reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await?;
2172        retried = true;
2173    }
2174    loop {
2175        // Two steps instead of `worktree add -b`, so that who made the branch
2176        // is known from which step failed rather than guessed from git's
2177        // wording or the branch's tip: `git branch` creates the ref atomically
2178        // or fails, and only a branch it created is this attempt's.
2179        if let Err(e) = git::git(repo, &["branch", branch, &start]).await {
2180            let e = e.context("create the release branch");
2181            if !git::branch_exists(repo, branch).await.unwrap_or(false) {
2182                return Err(e);
2183            }
2184            // It appeared since the check above: somebody else's.
2185            if retried {
2186                return Err(anyhow!(
2187                    "{e:#}; left {branch} in place: it is not one this attempt created"
2188                ));
2189            }
2190            retried = true;
2191            if let Err(r) = reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await {
2192                return Err(anyhow!("{r:#} (after: {e:#})"));
2193            }
2194            continue;
2195        }
2196        if let Some(parent) = worktree.parent() {
2197            tokio::fs::create_dir_all(parent).await.ok();
2198        }
2199        let path = worktree.to_string_lossy();
2200        if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
2201            let e = e.context("create the release-bump worktree");
2202            return Err(discard_attempt(repo, remote, worktree, branch, e).await);
2203        }
2204        break;
2205    }
2206    match fill(worktree.to_path_buf()).await {
2207        Ok(v) => {
2208            // The branch lives on in the pull request; the worktree is
2209            // throwaway and a stale one would collide with the next attempt.
2210            git::worktree_remove(repo, worktree).await.ok();
2211            Ok(v)
2212        }
2213        Err(e) => Err(discard_attempt(repo, remote, worktree, branch, e).await),
2214    }
2215}
2216
2217/// Undo what a failed attempt created and fold the outcome into its error.
2218async fn discard_attempt(
2219    repo: &Path,
2220    remote: &str,
2221    worktree: &Path,
2222    branch: &str,
2223    cause: anyhow::Error,
2224) -> anyhow::Error {
2225    git::worktree_remove(repo, worktree).await.ok();
2226    if !git::branch_exists(repo, branch).await.unwrap_or(false) {
2227        return cause;
2228    }
2229    // The remote's own answer decides, not whether a push was attempted: a
2230    // push that failed half way may still have landed the ref.
2231    let note = match git::remote_has_branch(repo, remote, branch).await {
2232        Ok(false) => match git::branch_delete(repo, branch).await {
2233            Ok(true) => return cause,
2234            _ => format!("could not delete the local branch {branch}"),
2235        },
2236        Ok(true) => format!("left {branch} in place: it was pushed to {remote}"),
2237        Err(e) => format!(
2238            "left {branch} in place: could not tell whether it was pushed to {remote} ({e:#})"
2239        ),
2240    };
2241    anyhow!("{cause:#}; {note}")
2242}
2243
2244/// A branch of the release's name already exists. Delete it so the attempt can
2245/// go on if and only if it holds nothing: not on the remote, no open pull
2246/// request, and no commit that `start` (`<remote>/<base>`) lacks. Anything
2247/// unproven fails closed with a reason that names the branch.
2248async fn reclaim_stale_branch<P, PFut>(
2249    repo: &Path,
2250    remote: &str,
2251    start: &str,
2252    branch: &str,
2253    open_pr: &P,
2254) -> Result<()>
2255where
2256    P: Fn(String) -> PFut,
2257    PFut: std::future::Future<Output = Result<bool>>,
2258{
2259    match git::remote_has_branch(repo, remote, branch).await {
2260        Ok(false) => {}
2261        Ok(true) => bail!("the branch {branch} already exists and is on {remote}; left alone"),
2262        Err(e) => bail!(
2263            "the branch {branch} already exists and could not check whether {remote} has it              ({e:#}); left alone"
2264        ),
2265    }
2266    match open_pr(branch.to_string()).await {
2267        Ok(false) => {}
2268        Ok(true) => {
2269            bail!("the branch {branch} already exists and has an open pull request; left alone")
2270        }
2271        Err(e) => bail!(
2272            "the branch {branch} already exists and could not check for an open pull request \
2273             ({e:#}); left alone"
2274        ),
2275    }
2276    match git::commits_ahead(repo, start, branch).await {
2277        Ok(0) => {}
2278        Ok(n) => bail!(
2279            "the branch {branch} already exists with {n} commit(s) not in {start}; left alone"
2280        ),
2281        Err(e) => bail!(
2282            "the branch {branch} already exists and could not compare it with {start} ({e:#}); \
2283             left alone"
2284        ),
2285    }
2286    if let Some(held) = git::worktree_holding(repo, branch).await? {
2287        let same = |a: &Path, b: &Path| match (a.canonicalize(), b.canonicalize()) {
2288            (Ok(a), Ok(b)) => a == b,
2289            _ => a == b,
2290        };
2291        if same(&held, repo) {
2292            bail!(
2293                "the branch {branch} already exists and is checked out in the main checkout; left alone"
2294            );
2295        }
2296        git::worktree_remove(repo, &held).await.ok();
2297    }
2298    if !git::branch_delete(repo, branch).await? {
2299        bail!("the branch {branch} already exists and could not be deleted; left alone");
2300    }
2301    Ok(())
2302}
2303
2304/// Edit the version, let the lockfile follow, commit, push, and open the pull
2305/// request with automerge enabled. Returns the opened pull request's URL and,
2306/// when enabling automerge itself failed, a note of why - the pull request
2307/// still exists on the forge either way, and the caller must not lose track
2308/// of its URL over that failure alone.
2309async fn open_bump_pr(
2310    state: &mut RunState,
2311    worktree: &Path,
2312    branch: &str,
2313    next_version: &str,
2314    decision: &BumpDecision,
2315    source_pr_url: &str,
2316) -> Result<(String, AutomergeOutcome)> {
2317    write_release_files(state, worktree, next_version).await?;
2318
2319    let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
2320        .await
2321        .context("commit the version bump")?;
2322    if !committed {
2323        bail!("the version bump left nothing to commit");
2324    }
2325
2326    let remote = state.config.merge.remote.clone();
2327    let pushed = git::push(worktree, &remote, branch).await?;
2328    if !pushed.ok() {
2329        bail!("pushing {branch} failed: {}", pushed.stderr);
2330    }
2331
2332    let (title, body) = release_pr(
2333        decision.level.as_str(),
2334        &decision.reason,
2335        next_version,
2336        &state.id,
2337        source_pr_url,
2338    );
2339    let (title, body) = crate::github_text::prepare(state, &title, &body);
2340    let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
2341    // Without Actions nothing would ever merge it on green, and a direct merge
2342    // here would skip the owner's approval: leave it open for the release
2343    // watcher, which asks and then releases.
2344    if state.config.release.is_local() {
2345        // Watched from now on, so a pull request merged before the watcher's
2346        // first lap still gets released.
2347        crate::release_watch::register(&crate::run::home(), &state.repo, &url, &state.id);
2348        return Ok((url, AutomergeOutcome::LocalGate));
2349    }
2350    let outcome = match gh_enable_automerge(worktree, &url).await {
2351        Ok(()) => AutomergeOutcome::Enabled,
2352        Err(e) => {
2353            let reason = e.to_string();
2354            if is_clean_status_refusal(&reason) {
2355                gh_merge_directly(worktree, &url, &title, reason).await
2356            } else {
2357                AutomergeOutcome::Failed { reason }
2358            }
2359        }
2360    };
2361    Ok((url, outcome))
2362}
2363
2364/// What became of the release pull request's path to `main`.
2365#[derive(Debug, Clone, PartialEq, Eq)]
2366enum AutomergeOutcome {
2367    /// Automerge is armed; CI green will merge it.
2368    Enabled,
2369    /// CI beat us to it, so magi merged the pull request itself.
2370    MergedDirectly { detail: String },
2371    /// `[release] mode = "local"`: automerge was deliberately not armed.
2372    LocalGate,
2373    /// Neither worked; a human has to merge it.
2374    Failed { reason: String },
2375}
2376
2377/// Is this GitHub's refusal to arm automerge on a pull request that is already
2378/// mergeable? Automerge can only be enabled while something is still pending,
2379/// so a fast CI that finishes first gets `Pull request is in clean status`.
2380/// Both fragments are required so an unrelated "clean status" wording or a
2381/// branch-protection refusal does not trigger a direct merge.
2382fn is_clean_status_refusal(reason: &str) -> bool {
2383    let r = reason.to_lowercase();
2384    r.contains("is in clean status") && r.contains("enablepullrequestautomerge")
2385}
2386
2387/// The direct merge, in the same shape [`land::merge_argv`] uses but addressed
2388/// by URL: squash under the pull request's own title, delete the branch.
2389pub(crate) fn bump_merge_argv(pr_url: &str, subject: &str) -> Vec<String> {
2390    [
2391        "pr",
2392        "merge",
2393        pr_url,
2394        "--squash",
2395        "--delete-branch",
2396        "--subject",
2397        subject,
2398    ]
2399    .map(str::to_owned)
2400    .to_vec()
2401}
2402
2403/// Decide what a direct merge amounted to. No I/O. A zero exit is a merge; a
2404/// non-zero one is judged by the forge (`after`), never by the exit code, and
2405/// an unreadable forge is not evidence of success.
2406fn resolve_direct_merge(
2407    refusal: &str,
2408    argv: &[String],
2409    merge_ok: bool,
2410    stderr: &str,
2411    after: Option<land::PrLifecycle>,
2412) -> AutomergeOutcome {
2413    if merge_ok {
2414        return AutomergeOutcome::MergedDirectly {
2415            detail: format!("automerge was refused ({refusal}); gh {}", argv.join(" ")),
2416        };
2417    }
2418    match land::merged_after_all(argv, stderr, after) {
2419        Some(m) => AutomergeOutcome::MergedDirectly { detail: m.detail },
2420        None => AutomergeOutcome::Failed {
2421            reason: format!("{refusal}; merging directly failed too: {}", stderr.trim()),
2422        },
2423    }
2424}
2425
2426/// Merge the pull request directly after automerge was refused as clean.
2427/// Every failure lands in [`AutomergeOutcome::Failed`] so the caller keeps the
2428/// warning-and-comment path.
2429async fn gh_merge_directly(
2430    cwd: &Path,
2431    pr_url: &str,
2432    subject: &str,
2433    refusal: String,
2434) -> AutomergeOutcome {
2435    let argv = bump_merge_argv(pr_url, subject);
2436    let out = match tokio::process::Command::new("gh")
2437        .args(&argv)
2438        .current_dir(cwd)
2439        .quiet()
2440        .stdin(std::process::Stdio::null())
2441        .output()
2442        .await
2443    {
2444        Ok(o) => o,
2445        Err(e) => {
2446            return AutomergeOutcome::Failed {
2447                reason: format!("{refusal}; could not spawn gh to merge directly: {e}"),
2448            };
2449        }
2450    };
2451    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
2452    // jj keeps HEAD detached, so `--delete-branch` exits non-zero after the
2453    // merge has happened; ask the forge.
2454    let after = if out.status.success() {
2455        None
2456    } else {
2457        land::lifecycle(cwd, pr_url).await.ok()
2458    };
2459    resolve_direct_merge(&refusal, &argv, out.status.success(), &stderr, after)
2460}
2461
2462/// Put `version` into the repository's release files in `worktree`: the
2463/// configured `version_files` (the lock beside the first one is edited as text),
2464/// else the root `Cargo.toml` with `cargo build` moving `Cargo.lock`.
2465async fn write_release_files(state: &RunState, worktree: &Path, version: &str) -> Result<()> {
2466    let declared = &state.config.release.version_files;
2467    if release_manifest::is_custom(declared) {
2468        return release_manifest::apply(worktree, declared, version);
2469    }
2470    let cargo_toml_path = worktree.join("Cargo.toml");
2471    let toml = tokio::fs::read_to_string(&cargo_toml_path)
2472        .await
2473        .with_context(|| format!("read {}", cargo_toml_path.display()))?;
2474    let rewritten = rewrite_cargo_version(&toml, version)?;
2475    tokio::fs::write(&cargo_toml_path, rewritten)
2476        .await
2477        .with_context(|| format!("write {}", cargo_toml_path.display()))?;
2478    sync_lockfile(worktree, state.config.cache_dir().as_deref()).await
2479}
2480
2481/// Run `cargo build` so `Cargo.lock` follows the version bump, the same step
2482/// `AGENTS.md`'s hand-driven release recipe calls for.
2483///
2484/// Not exercised by a test: it is the one step in this module that runs the
2485/// real `cargo`, which the constraints on this change rule out doing from a
2486/// test (no network, no writing outside a throwaway worktree the test itself
2487/// does not have).
2488async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
2489    let mut cmd = tokio::process::Command::new("cargo");
2490    cmd.arg("build").current_dir(worktree).quiet();
2491    crate::incremental::configure(&mut cmd, worktree)?;
2492    if let Some(dir) = cache_dir {
2493        cmd.env("CARGO_TARGET_DIR", dir);
2494    }
2495    let out = cmd
2496        .stdin(std::process::Stdio::null())
2497        .output()
2498        .await
2499        .context("spawn cargo build")?;
2500    if !out.status.success() {
2501        bail!(
2502            "cargo build failed while syncing Cargo.lock: {}",
2503            String::from_utf8_lossy(&out.stderr).trim()
2504        );
2505    }
2506    Ok(())
2507}
2508
2509/// Title and body of a release bump pull request. Fixed English whatever
2510/// `[graph] language` says: it lands on GitHub. Pure so a test can hold it to
2511/// that. (`reason` comes from the decision seat, which the prompt tells to
2512/// write English.)
2513fn release_pr(
2514    level: &str,
2515    reason: &str,
2516    next_version: &str,
2517    run_id: &str,
2518    source_pr_url: &str,
2519) -> (String, String) {
2520    let title = format!("chore: release v{next_version} ({level} bump)");
2521    let body = format!(
2522        "## Background\n\n\
2523         A change that was just merged is a `{level}` change, so the crate needs a new \
2524         release: {reason}\n\n\
2525         Triggered by magi run `{run_id}`, which landed {source}.\n\n\
2526         ## Change\n\n\
2527         Raises the package version to `v{next_version}` in `Cargo.toml`, with \
2528         `Cargo.lock` following it. Nothing else changes.\n\n\
2529         ## Risk\n\n\
2530         Version-bump-only, so there is nothing here for a reviewer to find. Merging \
2531         it starts the release pipeline (auto-tag, then the release workflow).",
2532        source = source_pr_url,
2533    );
2534    (title, body)
2535}
2536
2537/// The merged pull request's title, for [`land::merge_subject`].
2538async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
2539    let out = tokio::process::Command::new("gh")
2540        .args(["pr", "view", pr_url, "--json", "title"])
2541        .current_dir(repo)
2542        .quiet()
2543        .stdin(std::process::Stdio::null())
2544        .output()
2545        .await
2546        .context("spawn gh pr view")?;
2547    if !out.status.success() {
2548        bail!(
2549            "gh pr view {pr_url}: {}",
2550            String::from_utf8_lossy(&out.stderr).trim()
2551        );
2552    }
2553    #[derive(Deserialize)]
2554    struct Title {
2555        title: String,
2556    }
2557    let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
2558        .context("parse `gh pr view --json title` output")?;
2559    Ok(parsed.title)
2560}
2561
2562async fn gh_pr_create(
2563    cwd: &Path,
2564    base: &str,
2565    head: &str,
2566    title: &str,
2567    body: &str,
2568) -> Result<String> {
2569    let out = tokio::process::Command::new("gh")
2570        .args([
2571            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
2572        ])
2573        .current_dir(cwd)
2574        .quiet()
2575        .stdin(std::process::Stdio::null())
2576        .output()
2577        .await
2578        .context("spawn gh pr create")?;
2579    if out.status.success() {
2580        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2581    } else {
2582        bail!(
2583            "gh pr create: {}",
2584            String::from_utf8_lossy(&out.stderr).trim()
2585        )
2586    }
2587}
2588
2589/// Enable automerge, mirroring `AGENTS.md`'s `gh pr merge --auto --squash
2590/// --delete-branch`. Never `git tag`: `auto-tag.yml` mints the tag once this
2591/// merges, and a manual tag would collide with its push.
2592async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
2593    let out = tokio::process::Command::new("gh")
2594        .args([
2595            "pr",
2596            "merge",
2597            pr_url,
2598            "--auto",
2599            "--squash",
2600            "--delete-branch",
2601        ])
2602        .current_dir(cwd)
2603        .quiet()
2604        .stdin(std::process::Stdio::null())
2605        .output()
2606        .await
2607        .context("spawn gh pr merge --auto")?;
2608    if out.status.success() {
2609        Ok(())
2610    } else {
2611        bail!(
2612            "gh pr merge --auto: {}",
2613            String::from_utf8_lossy(&out.stderr).trim()
2614        )
2615    }
2616}
2617
2618/// Rewrite a pull request's title, used when [`escalate_pending`] adds a
2619/// second commit: `gh pr merge --squash` only prefers a single commit's own
2620/// message over the title, so once there are two the title is what lands.
2621async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
2622    let out = tokio::process::Command::new("gh")
2623        .args(["pr", "edit", pr_url, "--title", title])
2624        .current_dir(cwd)
2625        .quiet()
2626        .stdin(std::process::Stdio::null())
2627        .output()
2628        .await
2629        .context("spawn gh pr edit")?;
2630    if out.status.success() {
2631        Ok(())
2632    } else {
2633        bail!(
2634            "gh pr edit --title: {}",
2635            String::from_utf8_lossy(&out.stderr).trim()
2636        )
2637    }
2638}
2639
2640#[cfg(test)]
2641mod tests {
2642    use super::*;
2643    use crate::notices::Severity;
2644
2645    #[test]
2646    fn github_facing_bump_text_is_english() {
2647        let (title, body) =
2648            release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
2649        assert!(crate::github_text::check(&title, &body).is_empty());
2650        assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
2651        assert_eq!(title, "chore: release v0.37.0 (minor bump)");
2652        assert!(
2653            body.contains("## Background") && body.contains("## Change"),
2654            "{body}"
2655        );
2656        let p = decision_prompt("s", "i", "d", &[], "0.36.5");
2657        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
2658    }
2659    use crate::config::Config;
2660    use crate::land::PrLifecycle;
2661
2662    /// `[merge] release_bump = false` must short-circuit before any I/O -
2663    /// `after_merge` is reached from a live run with a real repo and a real
2664    /// `gh`, so the disabled case is asserted with a `RunState` that would
2665    /// fail loudly (an unresolvable `/no/such/repo`) the moment anything past
2666    /// the flag check tried to touch it.
2667    #[tokio::test]
2668    async fn a_disabled_config_does_nothing() {
2669        let config = Config {
2670            merge: crate::config::Merge {
2671                release_bump: false,
2672                ..crate::config::Merge::default()
2673            },
2674            ..Config::default()
2675        };
2676        let mut state = RunState::new(
2677            PathBuf::from("/no/such/repo"),
2678            "main".to_owned(),
2679            "0000000000000000000000000000000000000000".to_owned(),
2680            "irrelevant".to_owned(),
2681            config,
2682        );
2683        after_merge(&mut state, "https://example.invalid/pull/1")
2684            .await
2685            .expect("a disabled config must return Ok without touching anything");
2686        assert!(
2687            state.events.is_empty(),
2688            "nothing should happen at all, not even a logged event"
2689        );
2690    }
2691
2692    /// A bare `origin` plus a clone of it, `main` pushed with `files`.
2693    async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
2694        let dir = tempfile::tempdir().unwrap();
2695        let origin = dir.path().join("origin.git");
2696        let repo = dir.path().join("repo");
2697        let o = origin.to_string_lossy().into_owned();
2698        git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
2699            .await
2700            .unwrap();
2701        tokio::fs::create_dir_all(&repo).await.unwrap();
2702        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
2703        git::git(&repo, &["config", "user.name", "test"])
2704            .await
2705            .unwrap();
2706        git::git(&repo, &["config", "user.email", "test@example.com"])
2707            .await
2708            .unwrap();
2709        for (name, body) in files {
2710            tokio::fs::write(repo.join(name), body).await.unwrap();
2711        }
2712        git::git(&repo, &["add", "-A"]).await.unwrap();
2713        git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
2714        git::git(&repo, &["remote", "add", "origin", &o])
2715            .await
2716            .unwrap();
2717        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
2718        (dir, repo)
2719    }
2720
2721    async fn no_pr(_: String) -> Result<bool> {
2722        Ok(false)
2723    }
2724
2725    /// `origin_with` plus a branch `b` made from `main`, optionally one commit
2726    /// ahead of it.
2727    async fn with_branch(ahead: bool) -> (tempfile::TempDir, PathBuf) {
2728        let (d, repo) = origin_with(&[("f", "x\n")]).await;
2729        git::git(&repo, &["branch", "b"]).await.unwrap();
2730        if ahead {
2731            git::git(&repo, &["checkout", "-q", "b"]).await.unwrap();
2732            git::git(&repo, &["commit", "--allow-empty", "-m", "wip"])
2733                .await
2734                .unwrap();
2735            git::git(&repo, &["checkout", "-q", "main"]).await.unwrap();
2736        }
2737        (d, repo)
2738    }
2739
2740    async fn attempt(repo: &Path, open_pr: bool, fail_after_push: Option<bool>) -> Result<()> {
2741        let wt = repo.parent().unwrap().join("bump");
2742        release_attempt(
2743            repo,
2744            "origin",
2745            "main",
2746            &wt,
2747            "b",
2748            |_| async move { Ok(open_pr) },
2749            |w| async move {
2750                if fail_after_push == Some(true) {
2751                    git::push(&w, "origin", "b").await?;
2752                }
2753                if fail_after_push.is_some() {
2754                    bail!("cargo build failed");
2755                }
2756                Ok(())
2757            },
2758        )
2759        .await
2760    }
2761
2762    #[tokio::test]
2763    async fn a_stale_ancestor_branch_is_deleted_and_the_attempt_proceeds() {
2764        let (_d, repo) = with_branch(false).await;
2765        attempt(&repo, false, None).await.unwrap();
2766        // Success keeps the branch (it backs the pull request).
2767        assert!(git::branch_exists(&repo, "b").await.unwrap());
2768    }
2769
2770    #[tokio::test]
2771    async fn a_stale_branch_holding_a_leftover_worktree_is_reclaimed() {
2772        let (_d, repo) = with_branch(false).await;
2773        let old = repo.parent().unwrap().join("old");
2774        git::git(&repo, &["worktree", "add", &old.to_string_lossy(), "b"])
2775            .await
2776            .unwrap();
2777        attempt(&repo, false, None).await.unwrap();
2778        assert!(!old.exists());
2779    }
2780
2781    #[tokio::test]
2782    async fn a_stale_branch_with_an_unmerged_commit_is_kept_with_a_reason() {
2783        let (_d, repo) = with_branch(true).await;
2784        let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2785        assert!(e.contains("`b`") || e.contains("branch b"), "{e}");
2786        assert!(e.contains("1 commit(s) not in origin/main"), "{e}");
2787        assert!(git::branch_exists(&repo, "b").await.unwrap());
2788    }
2789
2790    #[tokio::test]
2791    async fn a_stale_branch_on_the_remote_is_kept() {
2792        let (_d, repo) = with_branch(false).await;
2793        git::git(&repo, &["push", "origin", "b"]).await.unwrap();
2794        let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2795        assert!(e.contains("branch b") && e.contains("is on origin"), "{e}");
2796        assert!(git::branch_exists(&repo, "b").await.unwrap());
2797    }
2798
2799    #[tokio::test]
2800    async fn a_stale_branch_with_an_open_pull_request_is_kept() {
2801        let (_d, repo) = with_branch(false).await;
2802        let e = attempt(&repo, true, None).await.unwrap_err().to_string();
2803        assert!(e.contains("open pull request"), "{e}");
2804        assert!(git::branch_exists(&repo, "b").await.unwrap());
2805    }
2806
2807    #[tokio::test]
2808    async fn an_unanswerable_pull_request_check_keeps_the_branch() {
2809        let (_d, repo) = with_branch(false).await;
2810        let wt = repo.parent().unwrap().join("bump");
2811        let e = release_attempt(
2812            &repo,
2813            "origin",
2814            "main",
2815            &wt,
2816            "b",
2817            |_| async { bail!("offline") },
2818            |_| async { Ok(()) },
2819        )
2820        .await
2821        .unwrap_err()
2822        .to_string();
2823        assert!(
2824            e.contains("could not check for an open pull request"),
2825            "{e}"
2826        );
2827        assert!(git::branch_exists(&repo, "b").await.unwrap());
2828    }
2829
2830    #[tokio::test]
2831    async fn a_failed_attempt_removes_its_own_worktree_and_branch() {
2832        let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2833        let wt = repo.parent().unwrap().join("bump");
2834        let e = attempt(&repo, false, Some(false)).await.unwrap_err();
2835        assert!(format!("{e:#}").contains("cargo build failed"));
2836        assert!(!wt.exists());
2837        assert!(!git::branch_exists(&repo, "b").await.unwrap());
2838    }
2839
2840    #[tokio::test]
2841    async fn a_pushed_branch_survives_a_failed_attempt_and_the_reason_says_so() {
2842        let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2843        let e = attempt(&repo, false, Some(true)).await.unwrap_err();
2844        let e = format!("{e:#}");
2845        assert!(
2846            e.contains("cargo build failed") && e.contains("pushed to origin"),
2847            "{e}"
2848        );
2849        assert!(git::branch_exists(&repo, "b").await.unwrap());
2850    }
2851
2852    #[tokio::test]
2853    async fn a_branch_left_by_a_half_done_worktree_add_is_removed() {
2854        // No pre-existing branch; the worktree path sits under a regular
2855        // file, so `worktree add -b` may create the branch and then fail.
2856        let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2857        let blocker = repo.parent().unwrap().join("blocker");
2858        tokio::fs::write(&blocker, "file").await.unwrap();
2859        let wt = blocker.join("bump");
2860        let r = release_attempt(
2861            &repo,
2862            "origin",
2863            "main",
2864            &wt,
2865            "b",
2866            |_| async { bail!("offline") },
2867            |_| async { Ok(()) },
2868        )
2869        .await;
2870        assert!(r.is_err());
2871        assert!(!git::branch_exists(&repo, "b").await.unwrap());
2872    }
2873
2874    #[tokio::test]
2875    async fn the_retry_is_taken_at_most_once() {
2876        // The branch is reclaimed, but the worktree path cannot be created, so
2877        // the second creation fails too: no third try, and no loop.
2878        let (_d, repo) = with_branch(false).await;
2879        let blocker = repo.parent().unwrap().join("blocker");
2880        tokio::fs::write(&blocker, "file").await.unwrap();
2881        let wt = blocker.join("bump");
2882        let r = release_attempt(&repo, "origin", "main", &wt, "b", no_pr, |_| async {
2883            Ok(())
2884        })
2885        .await;
2886        assert!(r.is_err());
2887        assert!(!git::branch_exists(&repo, "b").await.unwrap());
2888    }
2889
2890    #[tokio::test]
2891    async fn git_worktree_failure_reaches_release_notice_without_debug_exit_codes() {
2892        let (dir, repo) = origin_with(&[("f", "x\n")]).await;
2893        let worktree = dir.path().join("occupied");
2894        tokio::fs::create_dir(&worktree).await.unwrap();
2895        let existing = worktree.join("keep");
2896        tokio::fs::write(&existing, "operator data").await.unwrap();
2897        let err = release_attempt::<(), _, _, _, _>(
2898            &repo,
2899            "origin",
2900            "main",
2901            &worktree,
2902            "release-test",
2903            no_pr,
2904            |_| async { panic!("worktree creation must fail before release edits") },
2905        )
2906        .await
2907        .unwrap_err();
2908        let (version, reason) = notice_for_failure(&Progress::default(), &err);
2909        assert!(
2910            reason.contains("create the release-bump worktree"),
2911            "{reason}"
2912        );
2913        assert!(reason.contains("exit code 128"), "{reason}");
2914        let store = Notices::at(dir.path().join("notifications"));
2915        let mut state = winner_state(&repo, "main");
2916        state.status = RunStatus::Merged;
2917        report_problem_in(&mut state, &store, None, version.as_deref(), &reason, false).await;
2918        let notices = store.list();
2919        assert_eq!(notices.len(), 1);
2920        let notice = &notices[0];
2921        assert!(
2922            notice.message.contains("exit code 128"),
2923            "{}",
2924            notice.message
2925        );
2926        assert!(!notice.message.contains("Some(") && !notice.message.contains("None"));
2927        assert_eq!(notice.severity, Severity::Error);
2928        assert_eq!(state.status, RunStatus::Merged);
2929        assert!(state.needs_attention());
2930        assert!(!git::branch_exists(&repo, "release-test").await.unwrap());
2931        assert_eq!(
2932            tokio::fs::read_to_string(existing).await.unwrap(),
2933            "operator data"
2934        );
2935    }
2936
2937    #[tokio::test]
2938    async fn base_has_cargo_toml_tells_rust_from_non_rust() {
2939        let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
2940        assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
2941        let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
2942        assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
2943        // An unresolvable ref is a fault, not "not Rust".
2944        assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
2945    }
2946
2947    #[tokio::test]
2948    async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
2949        let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2950        let home = tempfile::tempdir().unwrap();
2951        let mut state = RunState::new(
2952            repo.clone(),
2953            "main".to_owned(),
2954            "0000000000000000000000000000000000000000".to_owned(),
2955            "task".to_owned(),
2956            Config::default(),
2957        );
2958        state.candidates.push(crate::run::Candidate {
2959            index: 0,
2960            label: 'A',
2961            agent: "x".to_owned(),
2962            branch: "main".to_owned(),
2963            worktree: repo.clone(),
2964            summary: String::new(),
2965            stat: String::new(),
2966            files: 1,
2967            commits: 1,
2968            empty: false,
2969            failed: None,
2970            verified_noop: None,
2971            duration_ms: 0,
2972            folded: false,
2973        });
2974        state.tally = Some(
2975            serde_json::from_value(serde_json::json!({
2976                "first_choice": {}, "borda": {}, "winner": "A",
2977                "unanimous_initial": true, "deliberated": false,
2978                "changed_votes": 0, "unanimous_final": true,
2979            }))
2980            .unwrap(),
2981        );
2982        after_merge_at(
2983            &mut state,
2984            "https://example.invalid/pull/1",
2985            Some(home.path()),
2986            None,
2987        )
2988        .await
2989        .expect("a non-Rust repository is not an error");
2990        let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
2991        assert_eq!(bumps.len(), 1, "{:?}", state.events);
2992        assert_eq!(
2993            bumps[0].message,
2994            "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
2995        );
2996        assert!(
2997            std::fs::read_dir(home.path()).unwrap().next().is_none(),
2998            "no marker and no lock may be created"
2999        );
3000    }
3001
3002    fn winner_state(repo: &Path, base: &str) -> RunState {
3003        let mut state = RunState::new(
3004            repo.to_path_buf(),
3005            base.to_owned(),
3006            "0000000000000000000000000000000000000000".to_owned(),
3007            "task".to_owned(),
3008            Config::default(),
3009        );
3010        state.candidates.push(crate::run::Candidate {
3011            index: 0,
3012            label: 'A',
3013            agent: "x".to_owned(),
3014            branch: "main".to_owned(),
3015            worktree: repo.to_path_buf(),
3016            summary: String::new(),
3017            stat: String::new(),
3018            files: 1,
3019            commits: 1,
3020            empty: false,
3021            failed: None,
3022            verified_noop: None,
3023            duration_ms: 0,
3024            folded: false,
3025        });
3026        state.tally = Some(
3027            serde_json::from_value(serde_json::json!({
3028                "first_choice": {}, "borda": {}, "winner": "A",
3029                "unanimous_initial": true, "deliberated": false,
3030                "changed_votes": 0, "unanimous_final": true,
3031            }))
3032            .unwrap(),
3033        );
3034        state
3035    }
3036
3037    #[tokio::test]
3038    async fn a_real_failure_without_a_pr_raises_one_notice_and_a_retry_folds_into_it() {
3039        let (_d, repo) =
3040            origin_with(&[("Cargo.toml", "[package]\nname=\"x\"\nversion=\"0.1.0\"\n")]).await;
3041        let home = tempfile::tempdir().unwrap();
3042        let store = Notices::at(home.path().join("notifications"));
3043        // An unresolvable base is a fault, not "not eligible".
3044        let mut state = winner_state(&repo, "nope");
3045        let url = "https://example.invalid/pull/1";
3046        after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3047            .await
3048            .expect_err("an unresolvable base is a failure");
3049        let listed = store.list();
3050        assert_eq!(listed.len(), 1, "{listed:?}");
3051        assert!(
3052            listed[0].message.contains("did not run"),
3053            "{}",
3054            listed[0].message
3055        );
3056        assert!(
3057            listed[0].message.contains("Cargo.toml"),
3058            "{}",
3059            listed[0].message
3060        );
3061        assert!(state.events.iter().any(|e| e.node == "bump"));
3062        after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3063            .await
3064            .expect_err("still failing");
3065        let listed = store.list();
3066        assert_eq!(listed.len(), 1, "{listed:?}");
3067        assert_eq!(listed[0].count, 2);
3068    }
3069
3070    #[tokio::test]
3071    async fn not_eligible_cases_raise_no_notice() {
3072        let home = tempfile::tempdir().unwrap();
3073        let store = Notices::at(home.path().join("notifications"));
3074        let url = "https://example.invalid/pull/1";
3075        // No Cargo.toml on the base branch.
3076        let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
3077        let mut state = winner_state(&repo, "main");
3078        after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3079            .await
3080            .unwrap();
3081        // Disabled.
3082        let mut state = winner_state(&repo, "nope");
3083        state.config.merge.release_bump = false;
3084        after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3085            .await
3086            .unwrap();
3087        // No winner.
3088        let mut state = winner_state(&repo, "nope");
3089        state.tally = None;
3090        after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3091            .await
3092            .unwrap();
3093        assert!(store.list().is_empty(), "{:?}", store.list());
3094    }
3095
3096    #[test]
3097    fn the_no_pr_notice_follows_the_configured_language() {
3098        let dir = tempfile::tempdir().unwrap();
3099        let store = Notices::at(dir.path().join("notifications"));
3100        let mut state = merged_state();
3101        state.config.graph.language = "ja".to_owned();
3102        let (n, _) = surface_problem(&mut state, &store, None, Some("0.2.0"), "boom").unwrap();
3103        assert!(n.message.contains("実行されませんでした"), "{}", n.message);
3104        assert!(n.message.contains("boom") && n.message.contains("v0.2.0"));
3105    }
3106
3107    #[test]
3108    fn a_failed_escalation_points_at_the_pending_pr_without_commenting() {
3109        let dir = tempfile::tempdir().unwrap();
3110        let store = Notices::at(dir.path().join("notifications"));
3111        let mut state = merged_state();
3112        let url = "https://example.invalid/pull/9";
3113        let (n, comment) = surface_problem_in(
3114            &mut state,
3115            &store,
3116            Some(url),
3117            Some("0.3.0"),
3118            "push failed",
3119            true,
3120        )
3121        .unwrap();
3122        assert!(comment.is_none());
3123        assert!(
3124            n.message.contains(url) && n.message.contains("v0.3.0"),
3125            "{}",
3126            n.message
3127        );
3128        assert!(!n.message.contains("open the release pull request by hand"));
3129        assert!(matches!(n.link, Some(Link::Url { .. })));
3130    }
3131
3132    #[test]
3133    fn a_failure_notice_names_the_pending_pr_and_the_first_line_of_the_cause() {
3134        let err = anyhow!("outer context").context("cargo build failed\nsecond line");
3135        let with_pr = Progress {
3136            version: Some("0.2.0".into()),
3137            pending_pr: Some("https://example.invalid/pull/9".into()),
3138            skip: None,
3139        };
3140        let (v, reason) = notice_for_failure(&with_pr, &err);
3141        assert_eq!(v.as_deref(), Some("0.2.0"));
3142        assert_eq!(reason, "cargo build failed");
3143        let (v, reason) = notice_for_failure(&Progress::default(), &err);
3144        assert_eq!(v, None);
3145        assert_eq!(reason, "cargo build failed");
3146    }
3147
3148    const NO_RULES: &str = "gh pr merge --auto: GraphQL: Pull request Branch does not have \
3149                            required protected branch rules (enablePullRequestAutoMerge)";
3150
3151    fn merged_state() -> RunState {
3152        // `report::run` prints `state.dir()`; pin the global home like the
3153        // report tests do so nothing reaches the operator's real one.
3154        run::pin_test_home();
3155        let mut s = RunState::new(
3156            PathBuf::from("/no/such/repo"),
3157            "main".to_owned(),
3158            "0000000000000000000000000000000000000000".to_owned(),
3159            "task".to_owned(),
3160            Config::default(),
3161        );
3162        s.status = RunStatus::Merged;
3163        s
3164    }
3165
3166    #[cfg(unix)]
3167    fn decision_cli(id: &str, reply: &str, exit: &str) -> AgentSpec {
3168        AgentSpec {
3169            id: id.into(),
3170            kind: crate::config::AgentKind::Command,
3171            model: None,
3172            command: vec![
3173                "sh".into(),
3174                "-c".into(),
3175                concat!(
3176                    "if [ \"$STATUS\" = timeout ]; then exec sleep 5; fi; ",
3177                    "printf '%s' \"$REPLY\"; exit \"$STATUS\""
3178                )
3179                .into(),
3180            ],
3181            extra_args: Vec::new(),
3182            env: std::collections::BTreeMap::from([
3183                ("REPLY".into(), reply.into()),
3184                ("STATUS".into(), exit.into()),
3185            ]),
3186            prompt_delivery: None,
3187            max_turn_secs: None,
3188        }
3189    }
3190
3191    #[cfg(unix)]
3192    fn decision_state(agents: Vec<AgentSpec>, preferences: &[&str]) -> RunState {
3193        let mut state = merged_state();
3194        state.config.agents = agents;
3195        state.config.roles.chatter = Some(crate::config::AgentChoice::Chain(
3196            preferences.iter().map(|s| (*s).to_owned()).collect(),
3197        ));
3198        state
3199    }
3200
3201    #[cfg(unix)]
3202    const VALID_DECISION: &str = r#"{"level":"minor","reason":"Adds a feature."}"#;
3203
3204    #[cfg(unix)]
3205    #[tokio::test]
3206    async fn decision_failover_handles_each_unusable_cli_outcome() {
3207        for (reply, exit, expected) in [
3208            (
3209                r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3210                "1",
3211                "rate/quota limit",
3212            ),
3213            (
3214                r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached","level":"minor","reason":"Looks valid."}"#,
3215                "0",
3216                "rate/quota limit",
3217            ),
3218            (
3219                r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached","level":"minor","reason":"Looks valid."}"#,
3220                "0",
3221                "rate/quota limit",
3222            ),
3223            ("", "0", "empty output"),
3224            (
3225                "",
3226                "spawn",
3227                "CLI invocation failed; inspect attempt artifacts",
3228            ),
3229            ("not JSON", "0", "invalid decision JSON or missing reason"),
3230            (VALID_DECISION, "1", "CLI failed (exit code 1)"),
3231            ("", "timeout", "timed out"),
3232        ] {
3233            let dir = tempfile::tempdir().unwrap();
3234            let mut state = decision_state(
3235                vec![
3236                    decision_cli("first", reply, exit),
3237                    decision_cli("second", VALID_DECISION, "0"),
3238                ],
3239                &["first", "second"],
3240            );
3241            if exit == "spawn" {
3242                state.config.agents[0].command = vec!["magi-test-no-such-decision-cli".into()];
3243            }
3244            let (spec, seat, decision) =
3245                ask_decision(&mut state, dir.path(), "decide", Duration::from_millis(200))
3246                    .await
3247                    .unwrap();
3248            assert_eq!(spec.id, "second");
3249            assert_eq!(decision.level, BumpLevel::Minor);
3250            assert_eq!(state.bump_decision_attempts.len(), 2);
3251            assert_eq!(state.bump_decision_attempts[0].outcome, expected);
3252            assert_eq!(state.handovers.len(), 1);
3253            assert_eq!(state.handovers[0].to, "second");
3254            assert_ne!(
3255                seat.claude_session,
3256                SeatState::new("bump", "first", state.seed).claude_session
3257            );
3258            let report = crate::report::run(&state);
3259            assert!(report.contains(expected), "{report}");
3260            assert!(report.contains("second: valid decision"), "{report}");
3261            assert!(state.release_bump.is_none());
3262            let artifacts = agent::artifacts_dir(&state.dir());
3263            if exit != "spawn" {
3264                assert!(artifacts.join("bump-decision-1.out").exists());
3265            }
3266            assert!(artifacts.join("bump-decision-2.out").exists());
3267        }
3268    }
3269
3270    #[cfg(unix)]
3271    #[tokio::test]
3272    async fn decision_chain_respects_preferences_and_stops_on_first_success() {
3273        let dir = tempfile::tempdir().unwrap();
3274        let mut state = decision_state(
3275            vec![
3276                decision_cli("first", "bad", "1"),
3277                decision_cli("second", VALID_DECISION, "0"),
3278            ],
3279            &["missing", "second", "second", "first"],
3280        );
3281        let (spec, _, _) = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(1))
3282            .await
3283            .unwrap();
3284        assert_eq!(spec.id, "second");
3285        assert_eq!(state.bump_decision_attempts.len(), 1);
3286        assert!(state.handovers.is_empty());
3287    }
3288
3289    #[cfg(unix)]
3290    #[tokio::test]
3291    async fn decision_chain_consumes_same_failure_classes_and_preserves_unset_policy() {
3292        let dir = tempfile::tempdir().unwrap();
3293        let mut state = decision_state(
3294            vec![
3295                decision_cli("first", "bad", "0"),
3296                decision_cli("second", "bad", "0"),
3297                decision_cli("third", VALID_DECISION, "0"),
3298            ],
3299            &["first", "second", "third"],
3300        );
3301        let (spec, _, _) = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(2))
3302            .await
3303            .unwrap();
3304        assert_eq!(spec.id, "third");
3305        assert_eq!(state.bump_decision_attempts.len(), 3);
3306        assert_eq!(state.handovers.len(), 2);
3307        // No configured fallback list means the existing single default pick.
3308        state.config.roles.chatter = None;
3309        let err = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(2))
3310            .await
3311            .unwrap_err();
3312        assert!(err.to_string().contains("exhausted 1 eligible agent(s)"));
3313        assert_eq!(state.bump_decision_attempts.len(), 4);
3314    }
3315
3316    #[cfg(unix)]
3317    #[tokio::test]
3318    async fn decision_reason_rewrite_failure_preserves_the_selected_level() {
3319        let dir = tempfile::tempdir().unwrap();
3320        for reply in ["invalid", r#"{"level":"patch","reason":"Fixes a defect."}"#] {
3321            let mut state = merged_state();
3322            state.config.graph.github_text_guard = true;
3323            state.config.graph.github_language = "en".into();
3324            let spec = decision_cli("chosen", reply, "0");
3325            let mut seat = SeatState::new("bump", &spec.id, state.seed);
3326            let mut decision =
3327                parse_decision(r#"{"level":"minor","reason":"新しい機能を追加しました。"}"#)
3328                    .unwrap();
3329            guard_reason(
3330                &mut state,
3331                dir.path(),
3332                "decide",
3333                &spec,
3334                &mut seat,
3335                &mut decision,
3336            )
3337            .await;
3338            assert_eq!(decision.level, BumpLevel::Minor);
3339            assert_eq!(decision.reason, "The merged change requires a release.");
3340            assert!(state.handovers.is_empty());
3341            assert!(state.bump_decision_attempts.is_empty());
3342        }
3343    }
3344
3345    #[cfg(unix)]
3346    #[tokio::test]
3347    async fn decision_exhaustion_is_bounded_reported_and_leaves_no_release_outcome() {
3348        let dir = tempfile::tempdir().unwrap();
3349        let mut state = decision_state(
3350            vec![
3351                decision_cli("first", "bad", "0"),
3352                decision_cli("second", "", "0"),
3353            ],
3354            &["first", "first", "second", "second"],
3355        );
3356        let err = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(1))
3357            .await
3358            .unwrap_err()
3359            .to_string();
3360        assert!(err.contains("exhausted 2 eligible agent(s)"), "{err}");
3361        assert!(err.contains("first: invalid decision JSON"), "{err}");
3362        assert!(err.contains("second: empty output"), "{err}");
3363        assert!(err.contains("[roles] chatter"), "{err}");
3364        assert_eq!(state.bump_decision_attempts.len(), 2);
3365        assert_eq!(state.handovers.len(), 1);
3366        assert!(state.release_bump.is_none());
3367        let report = crate::report::run(&state);
3368        assert!(report.contains("first: invalid decision JSON"));
3369        assert!(report.contains("second: empty output"));
3370        let restored: RunState =
3371            serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
3372        assert_eq!(restored.bump_decision_attempts.len(), 2);
3373    }
3374
3375    #[test]
3376    fn the_known_automerge_refusal_names_branch_protection() {
3377        assert!(automerge_hint(NO_RULES).contains("branch protection with required"));
3378        let other = automerge_hint("gh: network unreachable");
3379        assert!(!other.contains("branch protection"), "{other}");
3380        let body = automerge_failure_comment(NO_RULES);
3381        assert!(body.contains("enablePullRequestAutoMerge"), "{body}");
3382        assert!(body.contains("Action required"), "{body}");
3383    }
3384
3385    const CLEAN: &str = "gh pr merge --auto: GraphQL: Pull request Pull request is in clean \
3386                         status (enablePullRequestAutoMerge)";
3387
3388    #[test]
3389    fn clean_status_refusal_is_matched_narrowly() {
3390        assert!(is_clean_status_refusal(CLEAN));
3391        assert!(!is_clean_status_refusal(NO_RULES));
3392        assert!(!is_clean_status_refusal("gh: network unreachable"));
3393        assert!(!is_clean_status_refusal("Pull request is in clean status"));
3394        assert!(automerge_hint(CLEAN).contains("already green"));
3395    }
3396
3397    #[test]
3398    fn the_direct_merge_argv_matches_the_land_flags() {
3399        let a = bump_merge_argv("https://github.com/o/r/pull/9", "chore: release v1.0.0");
3400        let l = land::merge_argv(9, "chore: release v1.0.0");
3401        assert_eq!(a[..2], l[..2]);
3402        assert_eq!(a[3..], l[3..]);
3403        assert_eq!(a[2], "https://github.com/o/r/pull/9");
3404    }
3405
3406    #[test]
3407    fn a_direct_merge_is_judged_by_the_forge_not_the_exit_code() {
3408        let argv = bump_merge_argv("u", "t");
3409        let merged = |o: &AutomergeOutcome| matches!(o, AutomergeOutcome::MergedDirectly { .. });
3410        assert!(merged(&resolve_direct_merge(CLEAN, &argv, true, "", None)));
3411        let detached = "not on any branch";
3412        assert!(merged(&resolve_direct_merge(
3413            CLEAN,
3414            &argv,
3415            false,
3416            detached,
3417            Some(PrLifecycle::Merged)
3418        )));
3419        for after in [Some(PrLifecycle::Open), None] {
3420            let o = resolve_direct_merge(CLEAN, &argv, false, "boom", after);
3421            match o {
3422                AutomergeOutcome::Failed { reason } => {
3423                    assert!(
3424                        reason.contains("clean status") && reason.contains("boom"),
3425                        "{reason}"
3426                    )
3427                }
3428                other => panic!("expected Failed, got {other:?}"),
3429            }
3430        }
3431    }
3432
3433    #[test]
3434    fn a_local_mode_bump_reads_back_and_old_records_are_not_local() {
3435        let old: run::ReleaseBump =
3436            serde_json::from_str(r#"{"pr_url":"u","automerge_enabled":true}"#).unwrap();
3437        assert!(!old.local);
3438        let mut state = merged_state();
3439        state.release_bump = Some(run::ReleaseBump {
3440            pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
3441            version: Some("1.0.0".to_owned()),
3442            local: true,
3443            ..run::ReleaseBump::default()
3444        });
3445        // Not a failure: nothing was supposed to be armed.
3446        assert!(!state.needs_attention());
3447        let text = crate::report::run(&state);
3448        assert!(text.contains("release.mode = local"), "{text}");
3449        assert!(!text.contains("FAILED"), "{text}");
3450    }
3451
3452    #[test]
3453    fn a_directly_merged_bump_is_not_reported_as_pending_or_failed() {
3454        let mut state = merged_state();
3455        state.release_bump = Some(run::ReleaseBump {
3456            pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
3457            version: Some("1.0.0".to_owned()),
3458            merged_directly: true,
3459            ..run::ReleaseBump::default()
3460        });
3461        assert!(!state.needs_attention());
3462        let text = crate::report::run(&state);
3463        assert!(text.contains("merged directly"), "{text}");
3464        assert!(!text.contains("FAILED"), "{text}");
3465    }
3466
3467    #[test]
3468    fn an_automerge_failure_is_recorded_shown_and_filed_and_survives_settling() {
3469        let dir = tempfile::tempdir().unwrap();
3470        let store = Notices::at(dir.path().join("notifications"));
3471        let questions = ask::Questions::at(dir.path().join("questions"));
3472        let mut state = merged_state();
3473        let url = "https://github.com/o/r/pull/35";
3474
3475        let (n, comment) =
3476            surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
3477
3478        // The comment goes on the release PR and carries reason and fix.
3479        let comment = comment.expect("a PR was opened, so it gets a comment");
3480        assert!(comment.contains("branch protection"), "{comment}");
3481
3482        // The run is still Merged, but no longer reads as plain green.
3483        assert_eq!(state.status, RunStatus::Merged);
3484        assert!(state.needs_attention());
3485        let text = crate::report::run(&state);
3486        assert!(text.contains("release bump"), "{text}");
3487        assert!(text.contains("FAILED"), "{text}");
3488        assert!(text.contains(url), "{text}");
3489        assert!(text.contains("action required"), "{text}");
3490        assert!(crate::report::line(&state).contains("release needs a human"));
3491
3492        // Exactly one notification, linked to the PR, and no question.
3493        assert_eq!(n.severity, Severity::Error);
3494        assert_eq!(
3495            n.link,
3496            Some(Link::Url {
3497                url: url.to_owned()
3498            })
3499        );
3500        assert_eq!(store.list().len(), 1);
3501        assert!(questions.open_for(&state.id).is_empty());
3502
3503        // A retry of the same failure folds into the same entry.
3504        surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
3505        let listed = store.list();
3506        assert_eq!(listed.len(), 1);
3507        assert_eq!(listed[0].count, 2);
3508    }
3509
3510    #[test]
3511    fn a_bump_that_never_ran_is_surfaced_without_a_pr_comment() {
3512        let dir = tempfile::tempdir().unwrap();
3513        let store = Notices::at(dir.path().join("notifications"));
3514        let mut state = merged_state();
3515        let (n, comment) = surface_problem(&mut state, &store, None, None, "no agent").unwrap();
3516        assert!(comment.is_none());
3517        assert!(matches!(n.link, Some(Link::Run { .. })));
3518        assert!(state.needs_attention());
3519    }
3520
3521    #[test]
3522    fn version_parses_and_bumps_each_digit() {
3523        let v = Version::parse("0.4.0").unwrap();
3524        assert_eq!(
3525            v,
3526            Version {
3527                major: 0,
3528                minor: 4,
3529                patch: 0
3530            }
3531        );
3532
3533        assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
3534        assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
3535        assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
3536    }
3537
3538    #[test]
3539    fn version_tolerates_a_prerelease_suffix_on_patch() {
3540        let v = Version::parse("1.2.3-rc1").unwrap();
3541        assert_eq!(
3542            v,
3543            Version {
3544                major: 1,
3545                minor: 2,
3546                patch: 3
3547            }
3548        );
3549    }
3550
3551    #[test]
3552    fn version_rejects_garbage() {
3553        assert!(Version::parse("not-a-version").is_err());
3554        assert!(Version::parse("1.2").is_err());
3555    }
3556
3557    #[test]
3558    fn decision_parses_each_level() {
3559        for (json, level) in [
3560            (
3561                r#"{"level":"major","reason":"drops a config key"}"#,
3562                BumpLevel::Major,
3563            ),
3564            (
3565                r#"{"level":"minor","reason":"adds a new flag"}"#,
3566                BumpLevel::Minor,
3567            ),
3568            (
3569                r#"{"level":"patch","reason":"fixes a race"}"#,
3570                BumpLevel::Patch,
3571            ),
3572        ] {
3573            let decision = parse_decision(json).unwrap();
3574            assert_eq!(decision.level, level);
3575            assert!(!decision.reason.is_empty());
3576        }
3577    }
3578
3579    #[test]
3580    fn decision_wrapped_in_a_fence_and_prose_still_parses() {
3581        let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
3582        let decision = parse_decision(text).unwrap();
3583        assert_eq!(decision.level, BumpLevel::Minor);
3584        assert_eq!(decision.reason, "new HTTP route");
3585    }
3586
3587    #[test]
3588    fn a_broken_reply_is_an_error_not_a_default() {
3589        assert!(parse_decision("I decline to answer.").is_err());
3590        assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
3591        assert!(
3592            parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
3593            "an empty reason must not pass either"
3594        );
3595        assert!(
3596            parse_decision(r#"{"level":"patch"}"#).is_err(),
3597            "a reply with no reason at all must not pass"
3598        );
3599    }
3600
3601    #[test]
3602    fn prompt_states_the_zero_x_rule_and_the_tie_break() {
3603        let prompt = decision_prompt(
3604            "feat: add a phone endpoint",
3605            "add POST /api/widgets",
3606            "1 file changed, 10 insertions(+)",
3607            &["src/web.rs".to_owned()],
3608            "0.8.0",
3609        );
3610        assert!(prompt.contains("0.8.0"), "the current version is stated");
3611        assert!(
3612            prompt.contains("below `1.0.0`")
3613                && prompt.contains("`minor` is the digit that carries a breaking change"),
3614            "the 0.x rule must be explicit: {prompt}"
3615        );
3616        assert!(
3617            prompt.contains("choose the larger"),
3618            "the tie-break toward the bigger digit must be explicit: {prompt}"
3619        );
3620    }
3621
3622    #[test]
3623    fn release_only_diffs_are_recognised() {
3624        assert!(is_release_only(&["Cargo.toml".to_owned()]));
3625        assert!(is_release_only(&[
3626            "Cargo.toml".to_owned(),
3627            "Cargo.lock".to_owned()
3628        ]));
3629        assert!(!is_release_only(&[]));
3630        assert!(!is_release_only(&[
3631            "Cargo.toml".to_owned(),
3632            "src/main.rs".to_owned()
3633        ]));
3634    }
3635
3636    #[test]
3637    fn cargo_version_rewrite_touches_only_the_package_table() {
3638        let toml = "\
3639[package]\n\
3640# a comment mentioning version on purpose\n\
3641name = \"magi-cli\"\n\
3642version = \"0.8.0\"\n\
3643edition = \"2024\"\n\
3644\n\
3645[dependencies]\n\
3646foo = { version = \"1.2.3\" }\n";
3647        let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
3648        assert!(out.contains("version = \"0.9.0\""));
3649        assert!(
3650            out.contains("foo = { version = \"1.2.3\" }"),
3651            "a dependency's own version pin must survive: {out}"
3652        );
3653        assert!(
3654            out.contains("# a comment mentioning version on purpose"),
3655            "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
3656        );
3657        assert_eq!(
3658            out.lines().count(),
3659            toml.lines().count(),
3660            "the rewrite replaces one line, it does not add or remove any"
3661        );
3662    }
3663
3664    #[test]
3665    fn cargo_version_rewrite_fails_without_a_package_table() {
3666        let toml = "[dependencies]\nfoo = \"1\"\n";
3667        assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
3668    }
3669
3670    /// The shape `kanadehq/kanade` has: a workspace root with member crates
3671    /// but no crate of its own, so `[package]` never exists and the version
3672    /// lives under `[workspace.package]` alone. Before this fell back,
3673    /// `rewrite_cargo_version` bailed on every such repository and no bump
3674    /// pull request was ever opened for it.
3675    #[test]
3676    fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
3677        let toml = "\
3678[workspace]\n\
3679members = [\"crates/a\", \"crates/b\"]\n\
3680\n\
3681[workspace.package]\n\
3682version = \"0.45.18\"\n\
3683edition = \"2024\"\n\
3684\n\
3685[workspace.dependencies]\n\
3686foo = { version = \"1.2.3\" }\n";
3687        let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
3688        assert!(out.contains("version = \"0.45.19\""));
3689        assert!(
3690            out.contains("foo = { version = \"1.2.3\" }"),
3691            "a workspace dependency's own version pin must survive: {out}"
3692        );
3693        assert_eq!(
3694            out.lines().count(),
3695            toml.lines().count(),
3696            "the rewrite replaces one line, it does not add or remove any"
3697        );
3698    }
3699
3700    /// The exact failure that happened on `kanadehq/kanade`: an internal
3701    /// member referenced by both `path` and `version` must have its pin
3702    /// bumped alongside `[workspace.package]`, in the same edit.
3703    #[test]
3704    fn cargo_version_rewrite_bumps_an_internal_workspace_dependency_pin() {
3705        let toml = "\
3706[workspace]\n\
3707members = [\"crates/kanade-shared\"]\n\
3708\n\
3709[workspace.package]\n\
3710version = \"0.48.2\"\n\
3711\n\
3712[workspace.dependencies]\n\
3713kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n";
3714        let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3715        assert!(out.contains("[workspace.package]\nversion = \"0.48.3\"\n"));
3716        assert!(
3717            out.contains(
3718                "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3719            ),
3720            "the internal pin must move with the workspace version: {out}"
3721        );
3722    }
3723
3724    /// Key order inside the inline table must not matter.
3725    #[test]
3726    fn cargo_version_rewrite_bumps_an_internal_pin_with_version_before_path() {
3727        let toml = "\
3728[workspace.package]\n\
3729version = \"1.0.0\"\n\
3730\n\
3731[workspace.dependencies]\n\
3732inner = { version = \"1.0.0\", path = \"crates/inner\" }\n";
3733        let out = rewrite_cargo_version(toml, "1.0.1").unwrap();
3734        assert!(out.contains("inner = { version = \"1.0.1\", path = \"crates/inner\" }"));
3735    }
3736
3737    /// The dotted-table form (`[workspace.dependencies.name]`) must be
3738    /// rewritten too, not only the inline-table form.
3739    #[test]
3740    fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_table_form() {
3741        let toml = "\
3742[workspace.package]\n\
3743version = \"2.3.0\"\n\
3744\n\
3745[workspace.dependencies.inner]\n\
3746path = \"crates/inner\"\n\
3747version = \"2.3.0\"\n";
3748        let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3749        assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3750        assert!(out.contains(
3751            "[workspace.dependencies.inner]\npath = \"crates/inner\"\nversion = \"2.4.0\"\n"
3752        ));
3753    }
3754
3755    /// The dotted-key form written as two separate lines directly under the
3756    /// flat `[workspace.dependencies]` table - no inline table, no
3757    /// `[workspace.dependencies.name]` sub-header - is also valid TOML and
3758    /// must have its `version` line rewritten.
3759    #[test]
3760    fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_key_form() {
3761        let toml = "\
3762[workspace.package]\n\
3763version = \"2.3.0\"\n\
3764\n\
3765[workspace.dependencies]\n\
3766inner.path = \"crates/inner\"\n\
3767inner.version = \"2.3.0\"\n";
3768        let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3769        assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3770        assert!(out.contains("inner.path = \"crates/inner\"\ninner.version = \"2.4.0\"\n"));
3771    }
3772
3773    /// A workspace-only member with `path` but no `version` is unpublished
3774    /// and must be left exactly alone.
3775    #[test]
3776    fn cargo_version_rewrite_leaves_a_path_only_workspace_dependency_untouched() {
3777        let toml = "\
3778[workspace.package]\n\
3779version = \"0.1.0\"\n\
3780\n\
3781[workspace.dependencies]\n\
3782internal-only = { path = \"crates/internal-only\" }\n";
3783        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3784        assert!(out.contains("internal-only = { path = \"crates/internal-only\" }"));
3785    }
3786
3787    /// An external crates.io dependency, `version` but no `path`, must never
3788    /// be touched by the internal-pin logic even when it sits in
3789    /// `[workspace.dependencies]` alongside a real internal pin.
3790    #[test]
3791    fn cargo_version_rewrite_leaves_an_external_dependency_untouched() {
3792        let toml = "\
3793[workspace.package]\n\
3794version = \"0.1.0\"\n\
3795\n\
3796[workspace.dependencies]\n\
3797serde = { version = \"1\", features = [\"derive\"] }\n\
3798inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3799        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3800        assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3801        assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3802    }
3803
3804    /// A manifest with no `[workspace.dependencies]` table at all must keep
3805    /// bumping `[workspace.package]` exactly as before - regression guard
3806    /// for the pre-existing behaviour this change extends.
3807    #[test]
3808    fn cargo_version_rewrite_without_workspace_dependencies_table_still_bumps_package() {
3809        let toml = "[workspace.package]\nversion = \"0.9.0\"\nedition = \"2024\"\n";
3810        let out = rewrite_cargo_version(toml, "0.10.0").unwrap();
3811        assert_eq!(
3812            out,
3813            "[workspace.package]\nversion = \"0.10.0\"\nedition = \"2024\"\n"
3814        );
3815    }
3816
3817    /// The `kanadehq/kanade` shape itself: several internal-looking and
3818    /// external entries mixed in one `[workspace.dependencies]` table.
3819    #[test]
3820    fn cargo_version_rewrite_handles_a_kanade_shaped_workspace_dependencies_table() {
3821        let toml = "\
3822[workspace.package]\n\
3823version = \"0.48.2\"\n\
3824\n\
3825[workspace.dependencies]\n\
3826anyhow = { version = \"1\" }\n\
3827serde = { version = \"1\", features = [\"derive\"] }\n\
3828kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n\
3829kanade-core = { path = \"crates/kanade-core\", version = \"0.48.2\" }\n\
3830kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }\n";
3831        let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3832        assert!(out.contains("anyhow = { version = \"1\" }"));
3833        assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3834        assert!(
3835            out.contains(
3836                "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3837            )
3838        );
3839        assert!(
3840            out.contains("kanade-core = { path = \"crates/kanade-core\", version = \"0.48.3\" }")
3841        );
3842        assert!(out.contains("kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }"));
3843    }
3844
3845    /// A shape the line-based rewrite cannot express - an inline table split
3846    /// across lines - must fail the whole bump rather than silently leave
3847    /// the pin stale.
3848    #[test]
3849    fn cargo_version_rewrite_bails_on_an_unrepresentable_inline_table() {
3850        let toml = "\
3851[workspace.package]\n\
3852version = \"0.1.0\"\n\
3853\n\
3854[workspace.dependencies]\n\
3855inner = { path = \"crates/inner\",\n\
3856    version = \"0.1.0\" }\n";
3857        assert!(rewrite_cargo_version(toml, "0.2.0").is_err());
3858    }
3859
3860    /// A trailing comment on the `[workspace.dependencies]` header itself is
3861    /// valid TOML and must not stop the section from being recognised.
3862    #[test]
3863    fn cargo_version_rewrite_recognises_a_commented_workspace_dependencies_header() {
3864        let toml = "\
3865[workspace.package]\n\
3866version = \"0.1.0\"\n\
3867\n\
3868[workspace.dependencies] # internal pins\n\
3869inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3870        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3871        assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3872    }
3873
3874    /// A quoted dependency key (`"inner" = { .. }`) is valid TOML and must
3875    /// compare equal to the plain name the `toml` crate itself reports.
3876    #[test]
3877    fn cargo_version_rewrite_bumps_a_quoted_dependency_key() {
3878        let toml = "\
3879[workspace.package]\n\
3880version = \"0.1.0\"\n\
3881\n\
3882[workspace.dependencies]\n\
3883\"inner\" = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3884        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3885        assert!(out.contains("\"inner\" = { path = \"crates/inner\", version = \"0.2.0\" }"));
3886    }
3887
3888    /// A literal (single-quoted) TOML string is a legal way to spell a
3889    /// version pin, and the rewrite must preserve that quoting style rather
3890    /// than failing to find it or silently switching it to a basic string.
3891    #[test]
3892    fn cargo_version_rewrite_bumps_a_literal_string_version_pin() {
3893        let toml = "\
3894[workspace.package]\n\
3895version = \"0.1.0\"\n\
3896\n\
3897[workspace.dependencies]\n\
3898inner = { path = 'crates/inner', version = '0.1.0' }\n";
3899        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3900        assert!(out.contains("inner = { path = 'crates/inner', version = '0.2.0' }"));
3901    }
3902
3903    /// A manifest whose unrelated sections would not satisfy a strict
3904    /// whole-file TOML parse (here: a duplicate top-level table, which the
3905    /// `toml` crate rejects) must still have its `[workspace.dependencies]`
3906    /// pins rewritten - only that section's own syntax is a precondition,
3907    /// not the rest of the file.
3908    #[test]
3909    fn cargo_version_rewrite_does_not_require_the_whole_file_to_parse() {
3910        let toml = "\
3911[workspace.package]\n\
3912version = \"0.1.0\"\n\
3913\n\
3914[workspace.dependencies]\n\
3915inner = { path = \"crates/inner\", version = \"0.1.0\" }\n\
3916\n\
3917[workspace.package]\n\
3918edition = \"2024\"\n";
3919        let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3920        assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3921        assert!(
3922            toml::from_str::<toml::Value>(toml).is_err(),
3923            "the fixture itself must be invalid as a whole file, or this test proves nothing"
3924        );
3925    }
3926
3927    #[test]
3928    fn current_version_prefers_the_package_table_when_both_exist() {
3929        let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
3930        assert_eq!(current_version(toml).unwrap(), "0.8.0");
3931    }
3932
3933    /// Same shape as `kanadehq/kanade`'s root `Cargo.toml`: no `[package]`
3934    /// at all, only `[workspace]` and `[workspace.package]`.
3935    #[test]
3936    fn current_version_falls_back_to_workspace_package_without_a_package_table() {
3937        let toml = "\
3938[workspace]\n\
3939members = [\"crates/a\", \"crates/b\"]\n\
3940\n\
3941[workspace.package]\n\
3942version = \"0.45.18\"\n";
3943        assert_eq!(current_version(toml).unwrap(), "0.45.18");
3944    }
3945
3946    #[test]
3947    fn coalesce_proceeds_with_nothing_pending() {
3948        assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
3949    }
3950
3951    /// A minimal, otherwise-plausible pending marker for tests that only
3952    /// care about one field.
3953    fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
3954        PendingBump {
3955            target_version: target_version.to_owned(),
3956            level,
3957            branch: format!("chore/release-v{target_version}"),
3958            pr_url: "https://example.invalid/pull/9".to_owned(),
3959        }
3960    }
3961
3962    #[test]
3963    fn coalesce_skips_while_the_pending_target_is_still_ahead() {
3964        let pending = test_pending("0.9.0", BumpLevel::Minor);
3965        assert_eq!(
3966            coalesce(Some(&pending), "0.8.0").unwrap(),
3967            Coalesce::Skip {
3968                target_version: "0.9.0".to_owned()
3969            }
3970        );
3971    }
3972
3973    #[test]
3974    fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
3975        let pending = test_pending("0.9.0", BumpLevel::Minor);
3976        // The pending bump landed exactly: proceed with a fresh decision.
3977        assert_eq!(
3978            coalesce(Some(&pending), "0.9.0").unwrap(),
3979            Coalesce::Proceed
3980        );
3981        // A human bumped further than what was pending: also proceed.
3982        assert_eq!(
3983            coalesce(Some(&pending), "1.0.0").unwrap(),
3984            Coalesce::Proceed
3985        );
3986    }
3987
3988    #[test]
3989    fn pending_action_escalates_only_for_a_more_severe_decision() {
3990        assert_eq!(
3991            pending_action(BumpLevel::Patch, BumpLevel::Patch),
3992            PendingAction::AlreadyCovered
3993        );
3994        assert_eq!(
3995            pending_action(BumpLevel::Patch, BumpLevel::Minor),
3996            PendingAction::Escalate
3997        );
3998        assert_eq!(
3999            pending_action(BumpLevel::Patch, BumpLevel::Major),
4000            PendingAction::Escalate
4001        );
4002        assert_eq!(
4003            pending_action(BumpLevel::Minor, BumpLevel::Patch),
4004            PendingAction::AlreadyCovered
4005        );
4006        assert_eq!(
4007            pending_action(BumpLevel::Major, BumpLevel::Minor),
4008            PendingAction::AlreadyCovered
4009        );
4010        assert_eq!(
4011            pending_action(BumpLevel::Major, BumpLevel::Major),
4012            PendingAction::AlreadyCovered
4013        );
4014    }
4015
4016    #[test]
4017    fn pr_state_parsing_reads_open_and_not_open() {
4018        assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
4019        assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
4020        assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
4021    }
4022
4023    #[test]
4024    fn a_lock_is_exclusive_until_dropped() {
4025        let dir = tempfile::tempdir().unwrap();
4026        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4027        let first = MarkerLock::acquire(&marker)
4028            .unwrap()
4029            .expect("first attempt takes the lock");
4030        assert!(
4031            MarkerLock::acquire(&marker).unwrap().is_none(),
4032            "a second attempt must be refused while the first holds it"
4033        );
4034        drop(first);
4035        assert!(
4036            MarkerLock::acquire(&marker).unwrap().is_some(),
4037            "dropping the guard releases the lock for the next attempt"
4038        );
4039    }
4040
4041    #[test]
4042    fn a_stale_lock_is_reclaimed() {
4043        let dir = tempfile::tempdir().unwrap();
4044        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4045        let lock_path = marker.with_extension("lock");
4046        std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
4047        std::fs::write(&lock_path, b"").unwrap();
4048        let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
4049        std::fs::OpenOptions::new()
4050            .write(true)
4051            .open(&lock_path)
4052            .unwrap()
4053            .set_modified(old)
4054            .unwrap();
4055        assert!(
4056            MarkerLock::acquire(&marker).unwrap().is_some(),
4057            "a lock older than the stale window must be reclaimed rather than block forever"
4058        );
4059    }
4060
4061    #[tokio::test]
4062    async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
4063        let dir = tempfile::tempdir().unwrap();
4064        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4065        let held = MarkerLock::acquire(&marker)
4066            .unwrap()
4067            .expect("seed the contention");
4068        let releaser = tokio::spawn(async move {
4069            tokio::time::sleep(Duration::from_millis(20)).await;
4070            drop(held);
4071        });
4072        let waited =
4073            wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
4074                .await
4075                .unwrap();
4076        assert!(
4077            waited.is_some(),
4078            "a merge landing behind another's still-running decision must not be dropped - it \
4079             must wait for that decision to finish and then judge against what it left behind"
4080        );
4081        releaser.await.unwrap();
4082    }
4083
4084    #[tokio::test]
4085    async fn a_lock_held_past_the_ceiling_gives_up() {
4086        let dir = tempfile::tempdir().unwrap();
4087        let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4088        let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
4089        let waited =
4090            wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
4091                .await
4092                .unwrap();
4093        assert!(
4094            waited.is_none(),
4095            "a lock genuinely held past the ceiling must eventually give up rather than wait \
4096             forever"
4097        );
4098    }
4099
4100    #[test]
4101    fn level_between_reads_off_the_differing_digit() {
4102        assert_eq!(
4103            level_between(
4104                Version::parse("0.8.0").unwrap(),
4105                Version::parse("1.0.0").unwrap()
4106            ),
4107            Some(BumpLevel::Major)
4108        );
4109        assert_eq!(
4110            level_between(
4111                Version::parse("0.8.0").unwrap(),
4112                Version::parse("0.9.0").unwrap()
4113            ),
4114            Some(BumpLevel::Minor)
4115        );
4116        assert_eq!(
4117            level_between(
4118                Version::parse("0.8.0").unwrap(),
4119                Version::parse("0.8.1").unwrap()
4120            ),
4121            Some(BumpLevel::Patch)
4122        );
4123        assert_eq!(
4124            level_between(
4125                Version::parse("0.8.0").unwrap(),
4126                Version::parse("0.8.0").unwrap()
4127            ),
4128            None
4129        );
4130    }
4131
4132    #[test]
4133    fn open_release_pr_is_found_among_unrelated_pull_requests() {
4134        let json = r#"[
4135            {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
4136            {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
4137        ]"#;
4138        let found = parse_open_release_pr(json).unwrap();
4139        assert_eq!(
4140            found,
4141            Some((
4142                "chore/release-v0.9.0".to_owned(),
4143                "https://example.invalid/pull/2".to_owned()
4144            ))
4145        );
4146    }
4147
4148    #[test]
4149    fn no_open_release_pr_reads_as_none_not_an_error() {
4150        let json =
4151            r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
4152        assert_eq!(parse_open_release_pr(json).unwrap(), None);
4153        assert_eq!(parse_open_release_pr("[]").unwrap(), None);
4154    }
4155
4156    #[test]
4157    fn marker_round_trips_through_disk() {
4158        let dir = tempfile::tempdir().unwrap();
4159        let path = marker_path(dir.path(), Path::new("/repos/magi"));
4160        assert!(read_marker(&path).is_none());
4161
4162        let marker = test_pending("0.9.0", BumpLevel::Patch);
4163        write_marker(&path, &marker).unwrap();
4164        let read_back = read_marker(&path).unwrap();
4165        assert_eq!(read_back.target_version, "0.9.0");
4166        assert_eq!(read_back.level, BumpLevel::Patch);
4167        assert_eq!(read_back.pr_url, marker.pr_url);
4168
4169        clear_marker(&path);
4170        assert!(read_marker(&path).is_none());
4171    }
4172
4173    #[test]
4174    fn different_repos_get_different_marker_files() {
4175        let dir = tempfile::tempdir().unwrap();
4176        let a = marker_path(dir.path(), Path::new("/repos/a"));
4177        let b = marker_path(dir.path(), Path::new("/repos/b"));
4178        assert_ne!(a, b);
4179    }
4180
4181    /// A version-bump-only pull request must never trigger the next bump - see
4182    /// [`is_release_only`]'s own doc for why that shape is the trigger for
4183    /// "do not treat this as a change to react to".
4184    #[test]
4185    fn a_bump_pull_requests_own_merge_does_not_retrigger() {
4186        let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
4187        assert!(
4188            is_release_only(&files),
4189            "the bump pull request's own diff must read as release-only"
4190        );
4191    }
4192
4193    #[test]
4194    fn should_release_bump_reads_only_a_merged_status() {
4195        assert!(should_release_bump(RunStatus::Merged));
4196        for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
4197            assert!(!should_release_bump(other));
4198        }
4199    }
4200
4201    /// `land::Step::Done { merged: true }` - a pull request already merged
4202    /// underneath magi. `land::decide` reads that straight off the pull
4203    /// request's own lifecycle before it looks at checks or comments at all.
4204    #[test]
4205    fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
4206        let pr = land::PrState {
4207            url: "https://github.com/o/r/pull/1".to_owned(),
4208            number: 1,
4209            state: PrLifecycle::Merged,
4210            checks: land::Checks::Green,
4211            failing: Vec::new(),
4212            review_comments: Vec::new(),
4213            blocking: land::Blocking::No,
4214        };
4215        assert_eq!(
4216            land::decide(&pr, 0, 4, Duration::ZERO),
4217            land::Step::Done { merged: true }
4218        );
4219        assert!(should_release_bump(RunStatus::Merged));
4220    }
4221
4222    /// `land::Step::Merge`'s own `gh pr merge` succeeding: `land::land` then
4223    /// sets `pr.state = PrLifecycle::Merged` by hand before returning (see
4224    /// `land::land`'s `Step::Merge` arm), which is the same value the other
4225    /// two paths converge on.
4226    #[test]
4227    fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
4228        let pr = land::PrState {
4229            url: "https://github.com/o/r/pull/2".to_owned(),
4230            number: 2,
4231            state: PrLifecycle::Open,
4232            checks: land::Checks::Green,
4233            failing: Vec::new(),
4234            review_comments: Vec::new(),
4235            blocking: land::Blocking::No,
4236        };
4237        assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
4238        // land::land's Step::Merge arm sets this by hand on success; asserted
4239        // here as the value that then makes should_release_bump fire.
4240        assert!(should_release_bump(RunStatus::Merged));
4241    }
4242
4243    /// [`land::merged_after_all`] - `gh pr merge` exited non-zero but the
4244    /// forge confirms the pull request merged anyway.
4245    #[test]
4246    fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
4247        let argv = land::merge_argv(3, "feat: something");
4248        let outcome = land::merged_after_all(
4249            &argv,
4250            "could not determine current branch: not on any branch",
4251            Some(PrLifecycle::Merged),
4252        );
4253        assert!(outcome.is_some(), "the forge's confirmation must win");
4254        assert!(should_release_bump(RunStatus::Merged));
4255
4256        // The same recovery must not fabricate a merge when the forge does
4257        // not confirm one.
4258        assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
4259        assert!(land::merged_after_all(&argv, "network error", None).is_none());
4260    }
4261
4262    /// The paths that do *not* land must not read as merged either.
4263    #[test]
4264    fn a_close_or_a_give_up_does_not_trigger_a_bump() {
4265        let pr = land::PrState {
4266            url: "https://github.com/o/r/pull/4".to_owned(),
4267            number: 4,
4268            state: PrLifecycle::Closed,
4269            checks: land::Checks::Green,
4270            failing: Vec::new(),
4271            review_comments: Vec::new(),
4272            blocking: land::Blocking::No,
4273        };
4274        assert_eq!(
4275            land::decide(&pr, 0, 4, Duration::ZERO),
4276            land::Step::Done { merged: false }
4277        );
4278        assert!(!should_release_bump(RunStatus::Blocked));
4279    }
4280
4281    fn shun_files() -> Vec<crate::config::VersionFile> {
4282        use crate::config::{VersionFile, VersionKind};
4283        vec![
4284            VersionFile {
4285                path: "src-tauri/Cargo.toml".to_owned(),
4286                kind: VersionKind::Cargo,
4287            },
4288            VersionFile {
4289                path: "package.json".to_owned(),
4290                kind: VersionKind::Json,
4291            },
4292            VersionFile {
4293                path: "src-tauri/tauri.conf.json".to_owned(),
4294                kind: VersionKind::Json,
4295            },
4296        ]
4297    }
4298
4299    const SHUN_TOML: &str = "[package]\nname = \"shun\"\nversion = \"1.0.0\"\n";
4300    const SHUN_LOCK: &str = "[[package]]\nname = \"shun\"\nversion = \"1.0.0\"\n";
4301
4302    async fn shun_origin(package_version: &str) -> (tempfile::TempDir, PathBuf) {
4303        let pkg = format!("{{\n  \"version\": \"{package_version}\"\n}}\n");
4304        let origin = origin_with(&[("README.md", "x\n")]).await;
4305        for (name, body) in [
4306            ("src-tauri/Cargo.toml", SHUN_TOML),
4307            ("src-tauri/Cargo.lock", SHUN_LOCK),
4308            ("package.json", pkg.as_str()),
4309            ("src-tauri/tauri.conf.json", "{ \"version\": \"1.0.0\" }\n"),
4310        ] {
4311            let path = origin.1.join(name);
4312            tokio::fs::create_dir_all(path.parent().unwrap())
4313                .await
4314                .unwrap();
4315            tokio::fs::write(path, body).await.unwrap();
4316        }
4317        git::git(&origin.1, &["add", "-A"]).await.unwrap();
4318        git::git(&origin.1, &["commit", "-m", "shun"])
4319            .await
4320            .unwrap();
4321        git::git(&origin.1, &["push", "origin", "main"])
4322            .await
4323            .unwrap();
4324        origin
4325    }
4326
4327    #[tokio::test]
4328    async fn a_shun_layout_bump_changes_only_the_declared_files_and_the_adjacent_lock() {
4329        let (_d, repo) = shun_origin("1.0.0").await;
4330        let mut state = winner_state(&repo, "main");
4331        state.config.release.version_files = shun_files();
4332        write_release_files(&state, &repo, "1.1.0").await.unwrap();
4333        let status = git::git(&repo, &["status", "--porcelain"]).await.unwrap();
4334        let mut paths: Vec<_> = status.lines().map(|l| l[3..].to_owned()).collect();
4335        paths.sort();
4336        assert_eq!(
4337            paths,
4338            [
4339                "package.json",
4340                "src-tauri/Cargo.lock",
4341                "src-tauri/Cargo.toml",
4342                "src-tauri/tauri.conf.json"
4343            ]
4344        );
4345        let lock = std::fs::read_to_string(repo.join("src-tauri/Cargo.lock")).unwrap();
4346        assert!(lock.contains("version = \"1.1.0\""));
4347    }
4348
4349    #[tokio::test]
4350    async fn an_unconfigured_repo_with_auto_tag_but_no_root_manifest_is_skipped_visibly() {
4351        let (_d, repo) = origin_with(&[("README.md", "x\n")]).await;
4352        let wf = repo.join(".github/workflows");
4353        tokio::fs::create_dir_all(&wf).await.unwrap();
4354        tokio::fs::write(wf.join("auto-tag.yml"), "name: t\n")
4355            .await
4356            .unwrap();
4357        git::git(&repo, &["add", "-A"]).await.unwrap();
4358        git::git(&repo, &["commit", "-m", "wf"]).await.unwrap();
4359        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
4360        let home = tempfile::tempdir().unwrap();
4361        let store = Notices::at(home.path().join("notifications"));
4362        let mut state = winner_state(&repo, "main");
4363        after_merge_at(
4364            &mut state,
4365            "https://example.invalid/pull/1",
4366            Some(home.path()),
4367            Some(&store),
4368        )
4369        .await
4370        .unwrap();
4371        let rec = state.release_bump.clone().expect("a skip is recorded");
4372        assert!(rec.skipped.is_some_and(|s| s.contains("Cargo.toml")));
4373        assert!(
4374            rec.action_required
4375                .is_some_and(|a| a.contains("version_files"))
4376        );
4377        assert!(rec.pr_url.is_none() && !rec.automerge_enabled);
4378        assert_eq!(store.list().len(), 1);
4379        assert!(crate::report::run(&state).contains("SKIPPED"));
4380    }
4381
4382    #[tokio::test]
4383    async fn a_non_rust_repo_without_auto_tag_records_a_skip_without_paging() {
4384        let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
4385        let home = tempfile::tempdir().unwrap();
4386        let store = Notices::at(home.path().join("notifications"));
4387        let mut state = winner_state(&repo, "main");
4388        after_merge_at(
4389            &mut state,
4390            "https://example.invalid/pull/1",
4391            Some(home.path()),
4392            Some(&store),
4393        )
4394        .await
4395        .unwrap();
4396        let rec = state.release_bump.clone().unwrap();
4397        assert!(rec.skipped.is_some());
4398        assert!(rec.action_required.is_none());
4399        assert!(store.list().is_empty());
4400    }
4401
4402    #[tokio::test]
4403    async fn disagreeing_version_files_stop_the_bump_and_ask_the_owner() {
4404        let (_d, repo) = shun_origin("0.9.0").await;
4405        let home = tempfile::tempdir().unwrap();
4406        let store = Notices::at(home.path().join("notifications"));
4407        let mut state = winner_state(&repo, "main");
4408        state.config.release.version_files = shun_files();
4409        after_merge_at(
4410            &mut state,
4411            "https://example.invalid/pull/1",
4412            Some(home.path()),
4413            Some(&store),
4414        )
4415        .await
4416        .unwrap();
4417        let rec = state.release_bump.clone().unwrap();
4418        assert!(rec.skipped.is_some_and(|s| s.contains("disagree")));
4419        assert!(rec.action_required.is_some());
4420        assert_eq!(store.list().len(), 1);
4421        assert!(rec.pr_url.is_none());
4422    }
4423
4424    #[tokio::test]
4425    async fn a_change_that_already_raised_the_version_is_left_to_the_repos_auto_tag() {
4426        let (_d, repo) = shun_origin("1.0.0").await;
4427        git::git(&repo, &["checkout", "-b", "feat"]).await.unwrap();
4428        let toml = SHUN_TOML.replace("1.0.0", "1.0.1");
4429        tokio::fs::write(repo.join("src-tauri/Cargo.toml"), toml)
4430            .await
4431            .unwrap();
4432        tokio::fs::write(repo.join("app.ts"), "x\n").await.unwrap();
4433        git::git(&repo, &["add", "-A"]).await.unwrap();
4434        git::git(&repo, &["commit", "-m", "fix and bump"])
4435            .await
4436            .unwrap();
4437        let home = tempfile::tempdir().unwrap();
4438        let store = Notices::at(home.path().join("notifications"));
4439        let mut state = winner_state(&repo, "main");
4440        state.candidates[0].branch = "feat".to_owned();
4441        state.config.release.version_files = shun_files();
4442        after_merge_at(
4443            &mut state,
4444            "https://example.invalid/pull/1",
4445            Some(home.path()),
4446            Some(&store),
4447        )
4448        .await
4449        .unwrap();
4450        let rec = state.release_bump.clone().unwrap();
4451        assert!(
4452            rec.skipped
4453                .is_some_and(|s| s.contains("already") || s.contains("raised"))
4454        );
4455        assert!(rec.pr_url.is_none() && rec.action_required.is_none());
4456        assert!(store.list().is_empty());
4457    }
4458
4459    #[tokio::test]
4460    async fn a_stale_local_base_does_not_make_an_inherited_version_look_like_this_changes() {
4461        let (_d, repo) = shun_origin("1.0.0").await;
4462        let old = git::rev_parse(&repo, "HEAD").await.unwrap();
4463        tokio::fs::write(
4464            repo.join("src-tauri/Cargo.toml"),
4465            SHUN_TOML.replace("1.0.0", "1.0.1"),
4466        )
4467        .await
4468        .unwrap();
4469        git::git(&repo, &["commit", "-am", "bump upstream"])
4470            .await
4471            .unwrap();
4472        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
4473        git::git(&repo, &["checkout", "-b", "feat"]).await.unwrap();
4474        tokio::fs::write(repo.join("app.ts"), "x\n").await.unwrap();
4475        git::git(&repo, &["add", "-A"]).await.unwrap();
4476        git::git(&repo, &["commit", "-m", "fix"]).await.unwrap();
4477        // The local main lags origin/main, which already carries 1.0.1.
4478        git::git(&repo, &["update-ref", "refs/heads/main", &old])
4479            .await
4480            .unwrap();
4481        let home = tempfile::tempdir().unwrap();
4482        let store = Notices::at(home.path().join("notifications"));
4483        let mut state = winner_state(&repo, "main");
4484        state.candidates[0].branch = "feat".to_owned();
4485        state.config.release.version_files = shun_files();
4486        let _ = after_merge_at(
4487            &mut state,
4488            "https://example.invalid/pull/1",
4489            Some(home.path()),
4490            Some(&store),
4491        )
4492        .await;
4493        assert!(
4494            !state
4495                .events
4496                .iter()
4497                .any(|e| e.message.contains("already moved")),
4498            "{:?}",
4499            state.events
4500        );
4501    }
4502
4503    #[tokio::test]
4504    async fn a_dependency_only_change_on_a_stale_local_base_is_not_release_only() {
4505        let (_d, repo) = shun_origin("1.0.0").await;
4506        let old = git::rev_parse(&repo, "HEAD").await.unwrap();
4507        for (p, from) in [
4508            ("src-tauri/Cargo.toml", SHUN_TOML),
4509            ("package.json", "{\n  \"version\": \"1.0.0\"\n}\n"),
4510            ("src-tauri/tauri.conf.json", "{ \"version\": \"1.0.0\" }\n"),
4511        ] {
4512            tokio::fs::write(repo.join(p), from.replace("1.0.0", "1.0.1"))
4513                .await
4514                .unwrap();
4515        }
4516        tokio::fs::write(
4517            repo.join("src-tauri/Cargo.lock"),
4518            SHUN_LOCK.replace("1.0.0", "1.0.1"),
4519        )
4520        .await
4521        .unwrap();
4522        git::git(&repo, &["commit", "-am", "bump upstream"])
4523            .await
4524            .unwrap();
4525        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
4526        git::git(&repo, &["checkout", "-b", "feat"]).await.unwrap();
4527        tokio::fs::write(
4528            repo.join("package.json"),
4529            "{\n  \"version\": \"1.0.1\",\n  \"dependencies\": {}\n}\n",
4530        )
4531        .await
4532        .unwrap();
4533        git::git(&repo, &["commit", "-am", "deps"]).await.unwrap();
4534        git::git(&repo, &["update-ref", "refs/heads/main", &old])
4535            .await
4536            .unwrap();
4537        let home = tempfile::tempdir().unwrap();
4538        let store = Notices::at(home.path().join("notifications"));
4539        let mut state = winner_state(&repo, "main");
4540        state.candidates[0].branch = "feat".to_owned();
4541        state.config.release.version_files = shun_files();
4542        let _ = after_merge_at(
4543            &mut state,
4544            "https://example.invalid/pull/1",
4545            Some(home.path()),
4546            Some(&store),
4547        )
4548        .await;
4549        assert!(
4550            !state
4551                .events
4552                .iter()
4553                .any(|e| e.message.contains("only the release manifest")),
4554            "{:?}",
4555            state.events
4556        );
4557        assert!(
4558            state
4559                .release_bump
4560                .as_ref()
4561                .is_none_or(|b| b.skipped.is_none())
4562        );
4563    }
4564}