use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize};
use crate::agent::{self, Invocation, SeatState};
use crate::config::AgentSpec;
use crate::git;
use crate::land;
use crate::proc::Quiet as _;
use crate::run::{self, RunState, RunStatus};
use crate::verdict;
const DECISION_TIMEOUT: Duration = Duration::from_secs(600);
pub fn should_release_bump(status: RunStatus) -> bool {
status == RunStatus::Merged
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BumpLevel {
Major,
Minor,
Patch,
}
impl BumpLevel {
pub fn as_str(self) -> &'static str {
match self {
Self::Major => "major",
Self::Minor => "minor",
Self::Patch => "patch",
}
}
fn severity(self) -> u8 {
match self {
Self::Patch => 0,
Self::Minor => 1,
Self::Major => 2,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct BumpDecision {
pub level: BumpLevel,
pub reason: String,
}
pub fn parse_decision(text: &str) -> Result<BumpDecision> {
let decision: BumpDecision = verdict::extract_json(text)?;
if decision.reason.trim().is_empty() {
bail!("the bump decision carried no reason");
}
Ok(decision)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
pub major: u64,
pub minor: u64,
pub patch: u64,
}
impl Version {
pub fn parse(s: &str) -> Result<Self> {
let s = s.trim();
let mut parts = s.splitn(3, '.');
let major = parts
.next()
.with_context(|| format!("`{s}` has no major component"))?;
let minor = parts
.next()
.with_context(|| format!("`{s}` has no minor component"))?;
let patch = parts
.next()
.with_context(|| format!("`{s}` has no patch component"))?;
let patch_digits: String = patch.chars().take_while(char::is_ascii_digit).collect();
Ok(Self {
major: major
.trim()
.parse()
.with_context(|| format!("`{major}` is not a number"))?,
minor: minor
.trim()
.parse()
.with_context(|| format!("`{minor}` is not a number"))?,
patch: patch_digits
.parse()
.with_context(|| format!("`{patch}` has no numeric patch component"))?,
})
}
#[must_use]
pub fn bump(self, level: BumpLevel) -> Self {
match level {
BumpLevel::Major => Self {
major: self.major + 1,
minor: 0,
patch: 0,
},
BumpLevel::Minor => Self {
major: self.major,
minor: self.minor + 1,
patch: 0,
},
BumpLevel::Patch => Self {
major: self.major,
minor: self.minor,
patch: self.patch + 1,
},
}
}
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
pub fn is_release_only(files: &[String]) -> bool {
!files.is_empty() && files.iter().all(|f| f == "Cargo.toml" || f == "Cargo.lock")
}
pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
let mut out = String::with_capacity(toml.len() + 8);
let mut in_package = false;
let mut done = false;
for line in toml.split_inclusive('\n') {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[package]";
}
if !done && in_package && trimmed.split('=').next().map(str::trim) == Some("version") {
let newline = if line.ends_with("\r\n") { "\r\n" } else { "\n" };
let _ = write!(out, "version = \"{new_version}\"{newline}");
done = true;
continue;
}
out.push_str(line);
}
if !done {
bail!("no `version` field found under `[package]`");
}
Ok(out)
}
fn current_version(toml: &str) -> Result<String> {
let mut in_package = false;
for line in toml.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[package]";
continue;
}
if !in_package {
continue;
}
let mut parts = trimmed.splitn(2, '=');
let key = parts.next().map(str::trim);
let Some(value) = parts.next() else { continue };
if key == Some("version") {
return Ok(value.trim().trim_matches('"').to_owned());
}
}
bail!("no `version` field found under `[package]`")
}
pub fn decision_prompt(
subject: &str,
instruction: &str,
diffstat: &str,
files: &[String],
current_version: &str,
) -> String {
let mut s = format!(
"A pull request just merged into the base branch. Decide which digit \
of this project's `major.minor.patch` version this change earns, so \
a release bump can be opened for exactly it.\n\n\
Current version: {current_version}\n\n\
# Merge subject\n\n{subject}\n\n\
# The task that produced it\n\n{instruction}\n\n\
# Files changed ({} total)\n\n",
files.len()
);
const MAX_FILES: usize = 50;
for f in files.iter().take(MAX_FILES) {
let _ = writeln!(s, "- {f}");
}
if files.len() > MAX_FILES {
let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
}
let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
s.push_str(
"\n# How to decide\n\n\
This project is below version `1.0.0`. At that stage **`minor` is \
the digit that carries a breaking change** - do not spend `major` \
below `1.0.0`.\n\n\
A change is breaking, and earns `minor`, when it changes any of: \
the public API reachable from `src/lib.rs`, a CLI subcommand or \
flag, an HTTP API route or response shape, a configuration key, or \
the on-disk shape of persisted state.\n\n\
A user-visible new capability that breaks none of the above also \
earns `minor`.\n\n\
A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
**When it is not obvious which digit applies, choose the larger \
one.** An oversized bump costs nothing; a breaking change shipped as \
`patch` breaks every downstream update that pins a range.\n\n\
# Output\n\n\
Reply with exactly one fenced JSON object and nothing that matters \
outside it:\n\n\
```json\n\
{\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
```\n",
);
s
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingBump {
pub target_version: String,
pub level: BumpLevel,
pub branch: String,
pub pr_url: String,
}
pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
let key = repo.to_string_lossy();
home.join("bump")
.join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
}
pub fn read_marker(path: &Path) -> Option<PendingBump> {
let body = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&body).ok()
}
pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
Ok(())
}
pub fn clear_marker(path: &Path) {
let _ = std::fs::remove_file(path);
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Coalesce {
Proceed,
Skip {
target_version: String,
},
}
pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
let Some(pending) = pending else {
return Ok(Coalesce::Proceed);
};
let current = Version::parse(current_version)?;
let target = Version::parse(&pending.target_version)?;
if current >= target {
return Ok(Coalesce::Proceed);
}
Ok(Coalesce::Skip {
target_version: pending.target_version.clone(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PendingAction {
AlreadyCovered,
Escalate,
}
pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
if decision_level.severity() > pending_level.severity() {
PendingAction::Escalate
} else {
PendingAction::AlreadyCovered
}
}
fn parse_pr_state(json: &str) -> Result<bool> {
#[derive(Deserialize)]
struct State {
state: String,
}
let parsed: State =
serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
}
async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
let out = tokio::process::Command::new("gh")
.args(["pr", "view", pr_url, "--json", "state"])
.current_dir(repo)
.quiet()
.stdin(std::process::Stdio::null())
.output()
.await
.context("spawn gh pr view")?;
if !out.status.success() {
bail!(
"gh pr view {pr_url}: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
parse_pr_state(&String::from_utf8_lossy(&out.stdout))
}
const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
struct MarkerLock {
path: PathBuf,
}
impl MarkerLock {
fn acquire(marker: &Path) -> Result<Option<Self>> {
let path = marker.with_extension("lock");
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
if Self::try_create(&path)? {
return Ok(Some(Self { path }));
}
if Self::is_stale(&path) {
let _ = std::fs::remove_file(&path);
if Self::try_create(&path)? {
return Ok(Some(Self { path }));
}
}
Ok(None)
}
fn try_create(path: &Path) -> Result<bool> {
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
{
Ok(_) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
}
}
fn is_stale(path: &Path) -> bool {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|m| m.elapsed().ok())
.is_some_and(|age| age >= LOCK_STALE_AFTER)
}
}
impl Drop for MarkerLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
const LOCK_POLL: Duration = Duration::from_secs(5);
const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
}
async fn wait_for_marker_lock_with(
marker: &Path,
poll: Duration,
ceiling: Duration,
) -> Result<Option<MarkerLock>> {
let mut waited = Duration::ZERO;
loop {
if let Some(lock) = MarkerLock::acquire(marker)? {
return Ok(Some(lock));
}
if waited >= ceiling {
return Ok(None);
}
tokio::time::sleep(poll).await;
waited += poll;
}
}
fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
if to.major != from.major {
Some(BumpLevel::Major)
} else if to.minor != from.minor {
Some(BumpLevel::Minor)
} else if to.patch != from.patch {
Some(BumpLevel::Patch)
} else {
None
}
}
fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
#[derive(Deserialize)]
struct Pr {
url: String,
#[serde(rename = "headRefName")]
head_ref_name: String,
}
let list: Vec<Pr> =
serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
Ok(list
.into_iter()
.find(|p| p.head_ref_name.starts_with("chore/release-v"))
.map(|p| (p.head_ref_name, p.url)))
}
async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
let out = tokio::process::Command::new("gh")
.args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
.current_dir(repo)
.quiet()
.stdin(std::process::Stdio::null())
.output()
.await
.context("spawn gh pr list")?;
if !out.status.success() {
bail!(
"gh pr list: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
}
pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
if !state.config.merge.release_bump {
return Ok(());
}
let Some(winner) = state.winner().cloned() else {
return Ok(());
};
let repo = state.repo.clone();
let base = state.base_branch.clone();
let remote = state.config.merge.remote.clone();
let files = git::changed_files(&winner.worktree, &base, &winner.branch)
.await
.unwrap_or_default();
if is_release_only(&files) {
state.event(
"bump",
"the merged change touches only the release manifest; not treating it as a trigger",
);
return Ok(());
}
let marker = marker_path(&run::home(), &repo);
let Some(_lock) = wait_for_marker_lock(&marker).await? else {
state.event(
"bump",
"another release bump decision held the lock past the wait ceiling; skipping this round",
);
return Ok(());
};
git::fetch(&repo, &remote, &base).await.ok();
let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
.await
.context("read Cargo.toml from the base branch")?;
let base_version = current_version(&cargo_toml)?;
let mut pending = read_marker(&marker);
if let Some(p) = &pending {
match coalesce(Some(p), &base_version)? {
Coalesce::Proceed => {
clear_marker(&marker);
pending = None;
}
Coalesce::Skip { target_version } => {
if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
state.event(
"bump",
format!(
"the pending release bump to v{target_version} ({}) is no longer \
open; treating it as abandoned",
p.pr_url
),
);
clear_marker(&marker);
pending = None;
}
}
}
}
if pending.is_none() {
if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
&& let Some(target) = branch
.strip_prefix("chore/release-v")
.and_then(|v| Version::parse(v).ok())
{
let base_parsed = Version::parse(&base_version)?;
if target > base_parsed
&& let Some(level) = level_between(base_parsed, target)
{
let adopted = PendingBump {
target_version: target.to_string(),
level,
branch,
pr_url: url,
};
let _ = write_marker(&marker, &adopted);
pending = Some(adopted);
}
}
}
let title = pr_title(&repo, pr_url).await.unwrap_or_default();
let subject = land::merge_subject(&title, &state.instruction);
let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
.await
.unwrap_or_default();
let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
let spec: AgentSpec = agent::pick(
&state.config.agents,
state.config.roles.chatter.as_deref(),
&agent::installed,
)
.context("choose an agent for the release-bump decision")?;
let mut seat = SeatState::new("bump", &spec.id, state.seed);
let artifacts = agent::artifacts_dir(&state.dir());
let out = agent::invoke(
&spec,
&mut seat,
&Invocation {
cwd: &repo,
prompt: &prompt,
timeout: DECISION_TIMEOUT,
allow_write: false,
sessions: false,
artifacts: &artifacts,
stem: "bump-decision",
run: &state.id,
node: "bump",
cache_dir: state.config.cache_dir().as_deref(),
attachments: &[],
},
)
.await
.context("ask an agent how big the merged change was")?;
if !out.usable() {
bail!(
"the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
out.exit_code,
out.timed_out
);
}
let decision = parse_decision(&out.text).context("parse the release-bump decision")?;
if let Some(p) = pending {
return match pending_action(p.level, decision.level) {
PendingAction::AlreadyCovered => {
state.event(
"bump",
format!(
"a release bump to v{} ({}) already covers at least a {} change; not \
opening another",
p.target_version,
p.pr_url,
decision.level.as_str()
),
);
Ok(())
}
PendingAction::Escalate => {
escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
}
};
}
let next = Version::parse(&base_version)?
.bump(decision.level)
.to_string();
let branch = format!("chore/release-v{next}");
let worktree = state.dir().join("bump");
git::worktree_remove(&repo, &worktree).await.ok();
git::worktree_add_branch(&repo, &worktree, &branch, &format!("{remote}/{base}"))
.await
.context("create the release-bump worktree")?;
let opened = open_bump_pr(state, &worktree, &branch, &next, &decision, pr_url).await;
git::worktree_remove(&repo, &worktree).await.ok();
let (pr_url_opened, automerge_warning) = opened?;
let marker_write = write_marker(
&marker,
&PendingBump {
target_version: next.clone(),
level: decision.level,
branch,
pr_url: pr_url_opened.clone(),
},
);
state.event(
"bump",
format!(
"opened a {} release bump to v{next} ({}): {pr_url_opened}",
decision.level.as_str(),
decision.reason
),
);
if let Err(e) = marker_write {
state.event(
"bump",
format!(
"could not record the pending release bump marker for v{next}: {e:#}; a later \
merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
the forge either"
),
);
}
if let Some(warning) = automerge_warning {
state.event(
"bump",
format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
);
}
Ok(())
}
async fn escalate_pending(
state: &mut RunState,
repo: &Path,
remote: &str,
pending: &PendingBump,
decision: &BumpDecision,
base_version: &str,
marker: &Path,
) -> Result<()> {
let next = Version::parse(base_version)?
.bump(decision.level)
.to_string();
let worktree = state.dir().join("bump");
git::worktree_remove(repo, &worktree).await.ok();
let checked_out = git::git_raw(
repo,
&[
"worktree",
"add",
"--force",
&worktree.to_string_lossy(),
&pending.branch,
],
)
.await?;
if !checked_out.ok() {
bail!(
"checking out the pending release branch {} failed: {}",
pending.branch,
checked_out.stderr
);
}
let pushed: Result<()> = async {
let cargo_toml_path = worktree.join("Cargo.toml");
let toml = tokio::fs::read_to_string(&cargo_toml_path)
.await
.with_context(|| format!("read {}", cargo_toml_path.display()))?;
let rewritten = rewrite_cargo_version(&toml, &next)?;
tokio::fs::write(&cargo_toml_path, rewritten)
.await
.with_context(|| format!("write {}", cargo_toml_path.display()))?;
sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
let committed = git::commit_all(
&worktree,
&format!(
"chore: release v{next} (supersedes v{})",
pending.target_version
),
)
.await
.context("commit the escalated version bump")?;
if !committed {
bail!("escalating the version bump left nothing to commit");
}
let pushed = git::push(&worktree, remote, &pending.branch).await?;
if !pushed.ok() {
bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
}
Ok(())
}
.await;
if let Err(e) = pushed {
git::worktree_remove(repo, &worktree).await.ok();
return Err(e);
}
let title_warning = match gh_pr_edit_title(
&worktree,
&pending.pr_url,
&format!("chore: release v{next}"),
)
.await
{
Ok(()) => None,
Err(e) => Some(e.to_string()),
};
git::worktree_remove(repo, &worktree).await.ok();
let marker_write = write_marker(
marker,
&PendingBump {
target_version: next.clone(),
level: decision.level,
branch: pending.branch.clone(),
pr_url: pending.pr_url.clone(),
},
);
state.event(
"bump",
format!(
"escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
pending.target_version,
decision.level.as_str(),
decision.reason,
pending.pr_url
),
);
if let Err(e) = marker_write {
state.event(
"bump",
format!(
"could not update the pending release bump marker to v{next}: {e:#}; a later \
merge may misjudge whether it is already covered"
),
);
}
if let Some(warning) = title_warning {
state.event(
"bump",
format!(
"pushed v{next} to {} but could not update its title: {warning}; the squashed \
subject may still read the superseded version",
pending.pr_url
),
);
}
Ok(())
}
async fn open_bump_pr(
state: &RunState,
worktree: &Path,
branch: &str,
next_version: &str,
decision: &BumpDecision,
source_pr_url: &str,
) -> Result<(String, Option<String>)> {
let cargo_toml_path = worktree.join("Cargo.toml");
let toml = tokio::fs::read_to_string(&cargo_toml_path)
.await
.with_context(|| format!("read {}", cargo_toml_path.display()))?;
let rewritten = rewrite_cargo_version(&toml, next_version)?;
tokio::fs::write(&cargo_toml_path, rewritten)
.await
.with_context(|| format!("write {}", cargo_toml_path.display()))?;
sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
.await
.context("commit the version bump")?;
if !committed {
bail!("the version bump left nothing to commit");
}
let remote = state.config.merge.remote.clone();
let pushed = git::push(worktree, &remote, branch).await?;
if !pushed.ok() {
bail!("pushing {branch} failed: {}", pushed.stderr);
}
let title = format!("chore: release v{next_version}");
let body = format!(
"Release bump: `{}` to `v{next_version}`.\n\n{}\n\n\
Triggered by run `{}`, which landed {source_pr_url}.\n\n\
version-bump-only; nothing here needs a review \
(AGENTS.md: \"Version-bump-only pull requests\").",
decision.level.as_str(),
decision.reason,
state.id,
);
let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
let automerge_warning = match gh_enable_automerge(worktree, &url).await {
Ok(()) => None,
Err(e) => Some(e.to_string()),
};
Ok((url, automerge_warning))
}
async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
let mut cmd = tokio::process::Command::new("cargo");
cmd.arg("build").current_dir(worktree).quiet();
if let Some(dir) = cache_dir {
cmd.env("CARGO_TARGET_DIR", dir);
}
let out = cmd
.stdin(std::process::Stdio::null())
.output()
.await
.context("spawn cargo build")?;
if !out.status.success() {
bail!(
"cargo build failed while syncing Cargo.lock: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
let out = tokio::process::Command::new("gh")
.args(["pr", "view", pr_url, "--json", "title"])
.current_dir(repo)
.quiet()
.stdin(std::process::Stdio::null())
.output()
.await
.context("spawn gh pr view")?;
if !out.status.success() {
bail!(
"gh pr view {pr_url}: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
#[derive(Deserialize)]
struct Title {
title: String,
}
let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
.context("parse `gh pr view --json title` output")?;
Ok(parsed.title)
}
async fn gh_pr_create(
cwd: &Path,
base: &str,
head: &str,
title: &str,
body: &str,
) -> Result<String> {
let out = tokio::process::Command::new("gh")
.args([
"pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
])
.current_dir(cwd)
.quiet()
.stdin(std::process::Stdio::null())
.output()
.await
.context("spawn gh pr create")?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
} else {
bail!(
"gh pr create: {}",
String::from_utf8_lossy(&out.stderr).trim()
)
}
}
async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
let out = tokio::process::Command::new("gh")
.args([
"pr",
"merge",
pr_url,
"--auto",
"--squash",
"--delete-branch",
])
.current_dir(cwd)
.quiet()
.stdin(std::process::Stdio::null())
.output()
.await
.context("spawn gh pr merge --auto")?;
if out.status.success() {
Ok(())
} else {
bail!(
"gh pr merge --auto: {}",
String::from_utf8_lossy(&out.stderr).trim()
)
}
}
async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
let out = tokio::process::Command::new("gh")
.args(["pr", "edit", pr_url, "--title", title])
.current_dir(cwd)
.quiet()
.stdin(std::process::Stdio::null())
.output()
.await
.context("spawn gh pr edit")?;
if out.status.success() {
Ok(())
} else {
bail!(
"gh pr edit --title: {}",
String::from_utf8_lossy(&out.stderr).trim()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::land::PrLifecycle;
#[tokio::test]
async fn a_disabled_config_does_nothing() {
let config = Config {
merge: crate::config::Merge {
release_bump: false,
..crate::config::Merge::default()
},
..Config::default()
};
let mut state = RunState::new(
PathBuf::from("/no/such/repo"),
"main".to_owned(),
"0000000000000000000000000000000000000000".to_owned(),
"irrelevant".to_owned(),
config,
);
after_merge(&mut state, "https://example.invalid/pull/1")
.await
.expect("a disabled config must return Ok without touching anything");
assert!(
state.events.is_empty(),
"nothing should happen at all, not even a logged event"
);
}
#[test]
fn version_parses_and_bumps_each_digit() {
let v = Version::parse("0.4.0").unwrap();
assert_eq!(
v,
Version {
major: 0,
minor: 4,
patch: 0
}
);
assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
}
#[test]
fn version_tolerates_a_prerelease_suffix_on_patch() {
let v = Version::parse("1.2.3-rc1").unwrap();
assert_eq!(
v,
Version {
major: 1,
minor: 2,
patch: 3
}
);
}
#[test]
fn version_rejects_garbage() {
assert!(Version::parse("not-a-version").is_err());
assert!(Version::parse("1.2").is_err());
}
#[test]
fn decision_parses_each_level() {
for (json, level) in [
(
r#"{"level":"major","reason":"drops a config key"}"#,
BumpLevel::Major,
),
(
r#"{"level":"minor","reason":"adds a new flag"}"#,
BumpLevel::Minor,
),
(
r#"{"level":"patch","reason":"fixes a race"}"#,
BumpLevel::Patch,
),
] {
let decision = parse_decision(json).unwrap();
assert_eq!(decision.level, level);
assert!(!decision.reason.is_empty());
}
}
#[test]
fn decision_wrapped_in_a_fence_and_prose_still_parses() {
let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
let decision = parse_decision(text).unwrap();
assert_eq!(decision.level, BumpLevel::Minor);
assert_eq!(decision.reason, "new HTTP route");
}
#[test]
fn a_broken_reply_is_an_error_not_a_default() {
assert!(parse_decision("I decline to answer.").is_err());
assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
assert!(
parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
"an empty reason must not pass either"
);
assert!(
parse_decision(r#"{"level":"patch"}"#).is_err(),
"a reply with no reason at all must not pass"
);
}
#[test]
fn prompt_states_the_zero_x_rule_and_the_tie_break() {
let prompt = decision_prompt(
"feat: add a phone endpoint",
"add POST /api/widgets",
"1 file changed, 10 insertions(+)",
&["src/web.rs".to_owned()],
"0.8.0",
);
assert!(prompt.contains("0.8.0"), "the current version is stated");
assert!(
prompt.contains("below `1.0.0`")
&& prompt.contains("`minor` is the digit that carries a breaking change"),
"the 0.x rule must be explicit: {prompt}"
);
assert!(
prompt.contains("choose the larger"),
"the tie-break toward the bigger digit must be explicit: {prompt}"
);
}
#[test]
fn release_only_diffs_are_recognised() {
assert!(is_release_only(&["Cargo.toml".to_owned()]));
assert!(is_release_only(&[
"Cargo.toml".to_owned(),
"Cargo.lock".to_owned()
]));
assert!(!is_release_only(&[]));
assert!(!is_release_only(&[
"Cargo.toml".to_owned(),
"src/main.rs".to_owned()
]));
}
#[test]
fn cargo_version_rewrite_touches_only_the_package_table() {
let toml = "\
[package]\n\
# a comment mentioning version on purpose\n\
name = \"magi-cli\"\n\
version = \"0.8.0\"\n\
edition = \"2024\"\n\
\n\
[dependencies]\n\
foo = { version = \"1.2.3\" }\n";
let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
assert!(out.contains("version = \"0.9.0\""));
assert!(
out.contains("foo = { version = \"1.2.3\" }"),
"a dependency's own version pin must survive: {out}"
);
assert!(
out.contains("# a comment mentioning version on purpose"),
"unrelated lines, comments included, must be byte-for-byte preserved: {out}"
);
assert_eq!(
out.lines().count(),
toml.lines().count(),
"the rewrite replaces one line, it does not add or remove any"
);
}
#[test]
fn cargo_version_rewrite_fails_without_a_package_table() {
let toml = "[dependencies]\nfoo = \"1\"\n";
assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
}
#[test]
fn current_version_reads_only_the_package_table() {
let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
assert_eq!(current_version(toml).unwrap(), "0.8.0");
}
#[test]
fn coalesce_proceeds_with_nothing_pending() {
assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
}
fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
PendingBump {
target_version: target_version.to_owned(),
level,
branch: format!("chore/release-v{target_version}"),
pr_url: "https://example.invalid/pull/9".to_owned(),
}
}
#[test]
fn coalesce_skips_while_the_pending_target_is_still_ahead() {
let pending = test_pending("0.9.0", BumpLevel::Minor);
assert_eq!(
coalesce(Some(&pending), "0.8.0").unwrap(),
Coalesce::Skip {
target_version: "0.9.0".to_owned()
}
);
}
#[test]
fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
let pending = test_pending("0.9.0", BumpLevel::Minor);
assert_eq!(
coalesce(Some(&pending), "0.9.0").unwrap(),
Coalesce::Proceed
);
assert_eq!(
coalesce(Some(&pending), "1.0.0").unwrap(),
Coalesce::Proceed
);
}
#[test]
fn pending_action_escalates_only_for_a_more_severe_decision() {
assert_eq!(
pending_action(BumpLevel::Patch, BumpLevel::Patch),
PendingAction::AlreadyCovered
);
assert_eq!(
pending_action(BumpLevel::Patch, BumpLevel::Minor),
PendingAction::Escalate
);
assert_eq!(
pending_action(BumpLevel::Patch, BumpLevel::Major),
PendingAction::Escalate
);
assert_eq!(
pending_action(BumpLevel::Minor, BumpLevel::Patch),
PendingAction::AlreadyCovered
);
assert_eq!(
pending_action(BumpLevel::Major, BumpLevel::Minor),
PendingAction::AlreadyCovered
);
assert_eq!(
pending_action(BumpLevel::Major, BumpLevel::Major),
PendingAction::AlreadyCovered
);
}
#[test]
fn pr_state_parsing_reads_open_and_not_open() {
assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
}
#[test]
fn a_lock_is_exclusive_until_dropped() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
let first = MarkerLock::acquire(&marker)
.unwrap()
.expect("first attempt takes the lock");
assert!(
MarkerLock::acquire(&marker).unwrap().is_none(),
"a second attempt must be refused while the first holds it"
);
drop(first);
assert!(
MarkerLock::acquire(&marker).unwrap().is_some(),
"dropping the guard releases the lock for the next attempt"
);
}
#[test]
fn a_stale_lock_is_reclaimed() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
let lock_path = marker.with_extension("lock");
std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
std::fs::write(&lock_path, b"").unwrap();
let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
std::fs::OpenOptions::new()
.write(true)
.open(&lock_path)
.unwrap()
.set_modified(old)
.unwrap();
assert!(
MarkerLock::acquire(&marker).unwrap().is_some(),
"a lock older than the stale window must be reclaimed rather than block forever"
);
}
#[tokio::test]
async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
let held = MarkerLock::acquire(&marker)
.unwrap()
.expect("seed the contention");
let releaser = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
drop(held);
});
let waited =
wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
.await
.unwrap();
assert!(
waited.is_some(),
"a merge landing behind another's still-running decision must not be dropped - it \
must wait for that decision to finish and then judge against what it left behind"
);
releaser.await.unwrap();
}
#[tokio::test]
async fn a_lock_held_past_the_ceiling_gives_up() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
let waited =
wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
.await
.unwrap();
assert!(
waited.is_none(),
"a lock genuinely held past the ceiling must eventually give up rather than wait \
forever"
);
}
#[test]
fn level_between_reads_off_the_differing_digit() {
assert_eq!(
level_between(
Version::parse("0.8.0").unwrap(),
Version::parse("1.0.0").unwrap()
),
Some(BumpLevel::Major)
);
assert_eq!(
level_between(
Version::parse("0.8.0").unwrap(),
Version::parse("0.9.0").unwrap()
),
Some(BumpLevel::Minor)
);
assert_eq!(
level_between(
Version::parse("0.8.0").unwrap(),
Version::parse("0.8.1").unwrap()
),
Some(BumpLevel::Patch)
);
assert_eq!(
level_between(
Version::parse("0.8.0").unwrap(),
Version::parse("0.8.0").unwrap()
),
None
);
}
#[test]
fn open_release_pr_is_found_among_unrelated_pull_requests() {
let json = r#"[
{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
{"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
]"#;
let found = parse_open_release_pr(json).unwrap();
assert_eq!(
found,
Some((
"chore/release-v0.9.0".to_owned(),
"https://example.invalid/pull/2".to_owned()
))
);
}
#[test]
fn no_open_release_pr_reads_as_none_not_an_error() {
let json =
r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
assert_eq!(parse_open_release_pr(json).unwrap(), None);
assert_eq!(parse_open_release_pr("[]").unwrap(), None);
}
#[test]
fn marker_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let path = marker_path(dir.path(), Path::new("/repos/magi"));
assert!(read_marker(&path).is_none());
let marker = test_pending("0.9.0", BumpLevel::Patch);
write_marker(&path, &marker).unwrap();
let read_back = read_marker(&path).unwrap();
assert_eq!(read_back.target_version, "0.9.0");
assert_eq!(read_back.level, BumpLevel::Patch);
assert_eq!(read_back.pr_url, marker.pr_url);
clear_marker(&path);
assert!(read_marker(&path).is_none());
}
#[test]
fn different_repos_get_different_marker_files() {
let dir = tempfile::tempdir().unwrap();
let a = marker_path(dir.path(), Path::new("/repos/a"));
let b = marker_path(dir.path(), Path::new("/repos/b"));
assert_ne!(a, b);
}
#[test]
fn a_bump_pull_requests_own_merge_does_not_retrigger() {
let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
assert!(
is_release_only(&files),
"the bump pull request's own diff must read as release-only"
);
}
#[test]
fn should_release_bump_reads_only_a_merged_status() {
assert!(should_release_bump(RunStatus::Merged));
for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
assert!(!should_release_bump(other));
}
}
#[test]
fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
let pr = land::PrState {
url: "https://github.com/o/r/pull/1".to_owned(),
number: 1,
state: PrLifecycle::Merged,
checks: land::Checks::Green,
failing: Vec::new(),
review_comments: Vec::new(),
blocking: land::Blocking::No,
};
assert_eq!(
land::decide(&pr, 0, 4, Duration::ZERO),
land::Step::Done { merged: true }
);
assert!(should_release_bump(RunStatus::Merged));
}
#[test]
fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
let pr = land::PrState {
url: "https://github.com/o/r/pull/2".to_owned(),
number: 2,
state: PrLifecycle::Open,
checks: land::Checks::Green,
failing: Vec::new(),
review_comments: Vec::new(),
blocking: land::Blocking::No,
};
assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
assert!(should_release_bump(RunStatus::Merged));
}
#[test]
fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
let argv = land::merge_argv(3, "feat: something");
let outcome = land::merged_after_all(
&argv,
"could not determine current branch: not on any branch",
Some(PrLifecycle::Merged),
);
assert!(outcome.is_some(), "the forge's confirmation must win");
assert!(should_release_bump(RunStatus::Merged));
assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
assert!(land::merged_after_all(&argv, "network error", None).is_none());
}
#[test]
fn a_close_or_a_give_up_does_not_trigger_a_bump() {
let pr = land::PrState {
url: "https://github.com/o/r/pull/4".to_owned(),
number: 4,
state: PrLifecycle::Closed,
checks: land::Checks::Green,
failing: Vec::new(),
review_comments: Vec::new(),
blocking: land::Blocking::No,
};
assert_eq!(
land::decide(&pr, 0, 4, Duration::ZERO),
land::Step::Done { merged: false }
);
assert!(!should_release_bump(RunStatus::Blocked));
}
}