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