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