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