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