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