Skip to main content

magi/
bump.rs

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