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