Skip to main content

magi/
bump.rs

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