Skip to main content

magi/
bump.rs

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