1use std::fmt::Write as _;
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26use anyhow::{Context as _, Result, bail};
27use serde::{Deserialize, Serialize};
28
29use crate::agent::{self, Invocation, SeatState};
30use crate::config::AgentSpec;
31use crate::git;
32use crate::land;
33use crate::proc::Quiet as _;
34use crate::run::{self, RunState, RunStatus};
35use crate::verdict;
36
37const DECISION_TIMEOUT: Duration = Duration::from_secs(600);
44
45pub fn should_release_bump(status: RunStatus) -> bool {
61 status == RunStatus::Merged
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum BumpLevel {
68 Major,
70 Minor,
72 Patch,
74}
75
76impl BumpLevel {
77 pub fn as_str(self) -> &'static str {
79 match self {
80 Self::Major => "major",
81 Self::Minor => "minor",
82 Self::Patch => "patch",
83 }
84 }
85
86 fn severity(self) -> u8 {
91 match self {
92 Self::Patch => 0,
93 Self::Minor => 1,
94 Self::Major => 2,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Deserialize)]
106pub struct BumpDecision {
107 pub level: BumpLevel,
109 pub reason: String,
112}
113
114pub fn parse_decision(text: &str) -> Result<BumpDecision> {
118 let decision: BumpDecision = verdict::extract_json(text)?;
119 if decision.reason.trim().is_empty() {
120 bail!("the bump decision carried no reason");
121 }
122 Ok(decision)
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
128pub struct Version {
129 pub major: u64,
131 pub minor: u64,
133 pub patch: u64,
135}
136
137impl Version {
138 pub fn parse(s: &str) -> Result<Self> {
143 let s = s.trim();
144 let mut parts = s.splitn(3, '.');
145 let major = parts
146 .next()
147 .with_context(|| format!("`{s}` has no major component"))?;
148 let minor = parts
149 .next()
150 .with_context(|| format!("`{s}` has no minor component"))?;
151 let patch = parts
152 .next()
153 .with_context(|| format!("`{s}` has no patch component"))?;
154 let patch_digits: String = patch.chars().take_while(char::is_ascii_digit).collect();
155 Ok(Self {
156 major: major
157 .trim()
158 .parse()
159 .with_context(|| format!("`{major}` is not a number"))?,
160 minor: minor
161 .trim()
162 .parse()
163 .with_context(|| format!("`{minor}` is not a number"))?,
164 patch: patch_digits
165 .parse()
166 .with_context(|| format!("`{patch}` has no numeric patch component"))?,
167 })
168 }
169
170 #[must_use]
173 pub fn bump(self, level: BumpLevel) -> Self {
174 match level {
175 BumpLevel::Major => Self {
176 major: self.major + 1,
177 minor: 0,
178 patch: 0,
179 },
180 BumpLevel::Minor => Self {
181 major: self.major,
182 minor: self.minor + 1,
183 patch: 0,
184 },
185 BumpLevel::Patch => Self {
186 major: self.major,
187 minor: self.minor,
188 patch: self.patch + 1,
189 },
190 }
191 }
192}
193
194impl std::fmt::Display for Version {
195 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
197 }
198}
199
200pub fn is_release_only(files: &[String]) -> bool {
209 !files.is_empty() && files.iter().all(|f| f == "Cargo.toml" || f == "Cargo.lock")
210}
211
212pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
223 let mut out = String::with_capacity(toml.len() + 8);
224 let mut in_package = false;
225 let mut done = false;
226 for line in toml.split_inclusive('\n') {
227 let trimmed = line.trim();
228 if trimmed.starts_with('[') {
229 in_package = trimmed == "[package]";
230 }
231 if !done && in_package && trimmed.split('=').next().map(str::trim) == Some("version") {
232 let newline = if line.ends_with("\r\n") { "\r\n" } else { "\n" };
233 let _ = write!(out, "version = \"{new_version}\"{newline}");
234 done = true;
235 continue;
236 }
237 out.push_str(line);
238 }
239 if !done {
240 bail!("no `version` field found under `[package]`");
241 }
242 Ok(out)
243}
244
245fn current_version(toml: &str) -> Result<String> {
248 let mut in_package = false;
249 for line in toml.lines() {
250 let trimmed = line.trim();
251 if trimmed.starts_with('[') {
252 in_package = trimmed == "[package]";
253 continue;
254 }
255 if !in_package {
256 continue;
257 }
258 let mut parts = trimmed.splitn(2, '=');
259 let key = parts.next().map(str::trim);
260 let Some(value) = parts.next() else { continue };
261 if key == Some("version") {
262 return Ok(value.trim().trim_matches('"').to_owned());
263 }
264 }
265 bail!("no `version` field found under `[package]`")
266}
267
268pub fn decision_prompt(
278 subject: &str,
279 instruction: &str,
280 diffstat: &str,
281 files: &[String],
282 current_version: &str,
283) -> String {
284 let mut s = format!(
285 "A pull request just merged into the base branch. Decide which digit \
286 of this project's `major.minor.patch` version this change earns, so \
287 a release bump can be opened for exactly it.\n\n\
288 Current version: {current_version}\n\n\
289 # Merge subject\n\n{subject}\n\n\
290 # The task that produced it\n\n{instruction}\n\n\
291 # Files changed ({} total)\n\n",
292 files.len()
293 );
294 const MAX_FILES: usize = 50;
295 for f in files.iter().take(MAX_FILES) {
296 let _ = writeln!(s, "- {f}");
297 }
298 if files.len() > MAX_FILES {
299 let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
300 }
301 let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
302
303 s.push_str(
304 "\n# How to decide\n\n\
305 This project is below version `1.0.0`. At that stage **`minor` is \
306 the digit that carries a breaking change** - do not spend `major` \
307 below `1.0.0`.\n\n\
308 A change is breaking, and earns `minor`, when it changes any of: \
309 the public API reachable from `src/lib.rs`, a CLI subcommand or \
310 flag, an HTTP API route or response shape, a configuration key, or \
311 the on-disk shape of persisted state.\n\n\
312 A user-visible new capability that breaks none of the above also \
313 earns `minor`.\n\n\
314 A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
315 **When it is not obvious which digit applies, choose the larger \
316 one.** An oversized bump costs nothing; a breaking change shipped as \
317 `patch` breaks every downstream update that pins a range.\n\n\
318 # Output\n\n\
319 Reply with exactly one fenced JSON object and nothing that matters \
320 outside it:\n\n\
321 ```json\n\
322 {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
323 ```\n",
324 );
325 s
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct PendingBump {
345 pub target_version: String,
347 pub level: BumpLevel,
350 pub branch: String,
353 pub pr_url: String,
356}
357
358pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
362 let key = repo.to_string_lossy();
363 home.join("bump")
364 .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
365}
366
367pub fn read_marker(path: &Path) -> Option<PendingBump> {
371 let body = std::fs::read_to_string(path).ok()?;
372 serde_json::from_str(&body).ok()
373}
374
375pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
379 if let Some(parent) = path.parent() {
380 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
381 }
382 let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
383 let tmp = path.with_extension("json.tmp");
384 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
385 std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
386 Ok(())
387}
388
389pub fn clear_marker(path: &Path) {
392 let _ = std::fs::remove_file(path);
393}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
399pub enum Coalesce {
400 Proceed,
403 Skip {
405 target_version: String,
407 },
408}
409
410pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
412 let Some(pending) = pending else {
413 return Ok(Coalesce::Proceed);
414 };
415 let current = Version::parse(current_version)?;
416 let target = Version::parse(&pending.target_version)?;
417 if current >= target {
418 return Ok(Coalesce::Proceed);
419 }
420 Ok(Coalesce::Skip {
421 target_version: pending.target_version.clone(),
422 })
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub enum PendingAction {
428 AlreadyCovered,
431 Escalate,
434}
435
436pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
445 if decision_level.severity() > pending_level.severity() {
446 PendingAction::Escalate
447 } else {
448 PendingAction::AlreadyCovered
449 }
450}
451
452fn parse_pr_state(json: &str) -> Result<bool> {
454 #[derive(Deserialize)]
455 struct State {
456 state: String,
457 }
458 let parsed: State =
459 serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
460 Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
461}
462
463async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
476 let out = tokio::process::Command::new("gh")
477 .args(["pr", "view", pr_url, "--json", "state"])
478 .current_dir(repo)
479 .quiet()
480 .stdin(std::process::Stdio::null())
481 .output()
482 .await
483 .context("spawn gh pr view")?;
484 if !out.status.success() {
485 bail!(
486 "gh pr view {pr_url}: {}",
487 String::from_utf8_lossy(&out.stderr).trim()
488 );
489 }
490 parse_pr_state(&String::from_utf8_lossy(&out.stdout))
491}
492
493const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
501
502struct MarkerLock {
514 path: PathBuf,
515}
516
517impl MarkerLock {
518 fn acquire(marker: &Path) -> Result<Option<Self>> {
522 let path = marker.with_extension("lock");
523 if let Some(parent) = path.parent() {
524 std::fs::create_dir_all(parent)
525 .with_context(|| format!("create {}", parent.display()))?;
526 }
527 if Self::try_create(&path)? {
528 return Ok(Some(Self { path }));
529 }
530 if Self::is_stale(&path) {
531 let _ = std::fs::remove_file(&path);
532 if Self::try_create(&path)? {
533 return Ok(Some(Self { path }));
534 }
535 }
536 Ok(None)
537 }
538
539 fn try_create(path: &Path) -> Result<bool> {
540 match std::fs::OpenOptions::new()
541 .write(true)
542 .create_new(true)
543 .open(path)
544 {
545 Ok(_) => Ok(true),
546 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
547 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
548 }
549 }
550
551 fn is_stale(path: &Path) -> bool {
552 std::fs::metadata(path)
553 .and_then(|m| m.modified())
554 .ok()
555 .and_then(|m| m.elapsed().ok())
556 .is_some_and(|age| age >= LOCK_STALE_AFTER)
557 }
558}
559
560impl Drop for MarkerLock {
561 fn drop(&mut self) {
562 let _ = std::fs::remove_file(&self.path);
563 }
564}
565
566const LOCK_POLL: Duration = Duration::from_secs(5);
568
569const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
582
583async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
586 wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
587}
588
589async fn wait_for_marker_lock_with(
593 marker: &Path,
594 poll: Duration,
595 ceiling: Duration,
596) -> Result<Option<MarkerLock>> {
597 let mut waited = Duration::ZERO;
598 loop {
599 if let Some(lock) = MarkerLock::acquire(marker)? {
600 return Ok(Some(lock));
601 }
602 if waited >= ceiling {
603 return Ok(None);
604 }
605 tokio::time::sleep(poll).await;
606 waited += poll;
607 }
608}
609
610fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
618 if to.major != from.major {
619 Some(BumpLevel::Major)
620 } else if to.minor != from.minor {
621 Some(BumpLevel::Minor)
622 } else if to.patch != from.patch {
623 Some(BumpLevel::Patch)
624 } else {
625 None
626 }
627}
628
629fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
632 #[derive(Deserialize)]
633 struct Pr {
634 url: String,
635 #[serde(rename = "headRefName")]
636 head_ref_name: String,
637 }
638 let list: Vec<Pr> =
639 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
640 Ok(list
641 .into_iter()
642 .find(|p| p.head_ref_name.starts_with("chore/release-v"))
643 .map(|p| (p.head_ref_name, p.url)))
644}
645
646async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
661 let out = tokio::process::Command::new("gh")
662 .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
663 .current_dir(repo)
664 .quiet()
665 .stdin(std::process::Stdio::null())
666 .output()
667 .await
668 .context("spawn gh pr list")?;
669 if !out.status.success() {
670 bail!(
671 "gh pr list: {}",
672 String::from_utf8_lossy(&out.stderr).trim()
673 );
674 }
675 parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
676}
677
678pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
687 if !state.config.merge.release_bump {
688 return Ok(());
689 }
690 let Some(winner) = state.winner().cloned() else {
691 return Ok(());
692 };
693 let repo = state.repo.clone();
694 let base = state.base_branch.clone();
695 let remote = state.config.merge.remote.clone();
696
697 let files = git::changed_files(&winner.worktree, &base, &winner.branch)
698 .await
699 .unwrap_or_default();
700 if is_release_only(&files) {
701 state.event(
702 "bump",
703 "the merged change touches only the release manifest; not treating it as a trigger",
704 );
705 return Ok(());
706 }
707
708 let marker = marker_path(&run::home(), &repo);
709 let Some(_lock) = wait_for_marker_lock(&marker).await? else {
716 state.event(
717 "bump",
718 "another release bump decision held the lock past the wait ceiling; skipping this round",
719 );
720 return Ok(());
721 };
722
723 git::fetch(&repo, &remote, &base).await.ok();
724 let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
725 .await
726 .context("read Cargo.toml from the base branch")?;
727 let base_version = current_version(&cargo_toml)?;
728
729 let mut pending = read_marker(&marker);
730 if let Some(p) = &pending {
731 match coalesce(Some(p), &base_version)? {
732 Coalesce::Proceed => {
733 clear_marker(&marker);
736 pending = None;
737 }
738 Coalesce::Skip { target_version } => {
739 if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
740 state.event(
741 "bump",
742 format!(
743 "the pending release bump to v{target_version} ({}) is no longer \
744 open; treating it as abandoned",
745 p.pr_url
746 ),
747 );
748 clear_marker(&marker);
749 pending = None;
750 }
751 }
756 }
757 }
758
759 if pending.is_none() {
760 if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
765 && let Some(target) = branch
766 .strip_prefix("chore/release-v")
767 .and_then(|v| Version::parse(v).ok())
768 {
769 let base_parsed = Version::parse(&base_version)?;
770 if target > base_parsed
771 && let Some(level) = level_between(base_parsed, target)
772 {
773 let adopted = PendingBump {
774 target_version: target.to_string(),
775 level,
776 branch,
777 pr_url: url,
778 };
779 let _ = write_marker(&marker, &adopted);
782 pending = Some(adopted);
783 }
784 }
785 }
786
787 let title = pr_title(&repo, pr_url).await.unwrap_or_default();
788 let subject = land::merge_subject(&title, &state.instruction);
789 let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
790 .await
791 .unwrap_or_default();
792 let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
793
794 let spec: AgentSpec = agent::pick(
800 &state.config.agents,
801 state.config.roles.chatter.as_deref(),
802 &agent::installed,
803 )
804 .context("choose an agent for the release-bump decision")?;
805 let mut seat = SeatState::new("bump", &spec.id, state.seed);
806 let artifacts = agent::artifacts_dir(&state.dir());
807 let out = agent::invoke(
808 &spec,
809 &mut seat,
810 &Invocation {
811 cwd: &repo,
812 prompt: &prompt,
813 timeout: DECISION_TIMEOUT,
814 allow_write: false,
817 sessions: false,
818 artifacts: &artifacts,
819 stem: "bump-decision",
820 run: &state.id,
821 node: "bump",
822 cache_dir: state.config.cache_dir().as_deref(),
823 attachments: &[],
824 },
825 )
826 .await
827 .context("ask an agent how big the merged change was")?;
828 if !out.usable() {
829 bail!(
830 "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
831 out.exit_code,
832 out.timed_out
833 );
834 }
835 let decision = parse_decision(&out.text).context("parse the release-bump decision")?;
836
837 if let Some(p) = pending {
838 return match pending_action(p.level, decision.level) {
839 PendingAction::AlreadyCovered => {
840 state.event(
841 "bump",
842 format!(
843 "a release bump to v{} ({}) already covers at least a {} change; not \
844 opening another",
845 p.target_version,
846 p.pr_url,
847 decision.level.as_str()
848 ),
849 );
850 Ok(())
851 }
852 PendingAction::Escalate => {
853 escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
854 }
855 };
856 }
857
858 let next = Version::parse(&base_version)?
859 .bump(decision.level)
860 .to_string();
861 let branch = format!("chore/release-v{next}");
862 let worktree = state.dir().join("bump");
863 git::worktree_remove(&repo, &worktree).await.ok();
864 git::worktree_add_branch(&repo, &worktree, &branch, &format!("{remote}/{base}"))
865 .await
866 .context("create the release-bump worktree")?;
867 let opened = open_bump_pr(state, &worktree, &branch, &next, &decision, pr_url).await;
868 git::worktree_remove(&repo, &worktree).await.ok();
872 let (pr_url_opened, automerge_warning) = opened?;
873
874 let marker_write = write_marker(
883 &marker,
884 &PendingBump {
885 target_version: next.clone(),
886 level: decision.level,
887 branch,
888 pr_url: pr_url_opened.clone(),
889 },
890 );
891 state.event(
892 "bump",
893 format!(
894 "opened a {} release bump to v{next} ({}): {pr_url_opened}",
895 decision.level.as_str(),
896 decision.reason
897 ),
898 );
899 if let Err(e) = marker_write {
900 state.event(
901 "bump",
902 format!(
903 "could not record the pending release bump marker for v{next}: {e:#}; a later \
904 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
905 the forge either"
906 ),
907 );
908 }
909 if let Some(warning) = automerge_warning {
910 state.event(
911 "bump",
912 format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
913 );
914 }
915 Ok(())
916}
917
918async fn escalate_pending(
927 state: &mut RunState,
928 repo: &Path,
929 remote: &str,
930 pending: &PendingBump,
931 decision: &BumpDecision,
932 base_version: &str,
933 marker: &Path,
934) -> Result<()> {
935 let next = Version::parse(base_version)?
936 .bump(decision.level)
937 .to_string();
938 let worktree = state.dir().join("bump");
939 git::worktree_remove(repo, &worktree).await.ok();
940 let checked_out = git::git_raw(
941 repo,
942 &[
943 "worktree",
944 "add",
945 "--force",
946 &worktree.to_string_lossy(),
947 &pending.branch,
948 ],
949 )
950 .await?;
951 if !checked_out.ok() {
952 bail!(
953 "checking out the pending release branch {} failed: {}",
954 pending.branch,
955 checked_out.stderr
956 );
957 }
958
959 let pushed: Result<()> = async {
964 let cargo_toml_path = worktree.join("Cargo.toml");
965 let toml = tokio::fs::read_to_string(&cargo_toml_path)
966 .await
967 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
968 let rewritten = rewrite_cargo_version(&toml, &next)?;
969 tokio::fs::write(&cargo_toml_path, rewritten)
970 .await
971 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
972 sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
973 let committed = git::commit_all(
974 &worktree,
975 &format!(
976 "chore: release v{next} (supersedes v{})",
977 pending.target_version
978 ),
979 )
980 .await
981 .context("commit the escalated version bump")?;
982 if !committed {
983 bail!("escalating the version bump left nothing to commit");
984 }
985 let pushed = git::push(&worktree, remote, &pending.branch).await?;
986 if !pushed.ok() {
987 bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
988 }
989 Ok(())
990 }
991 .await;
992 if let Err(e) = pushed {
993 git::worktree_remove(repo, &worktree).await.ok();
994 return Err(e);
995 }
996
997 let title_warning = match gh_pr_edit_title(
1001 &worktree,
1002 &pending.pr_url,
1003 &format!("chore: release v{next}"),
1004 )
1005 .await
1006 {
1007 Ok(()) => None,
1008 Err(e) => Some(e.to_string()),
1009 };
1010 git::worktree_remove(repo, &worktree).await.ok();
1011
1012 let marker_write = write_marker(
1013 marker,
1014 &PendingBump {
1015 target_version: next.clone(),
1016 level: decision.level,
1017 branch: pending.branch.clone(),
1018 pr_url: pending.pr_url.clone(),
1019 },
1020 );
1021 state.event(
1022 "bump",
1023 format!(
1024 "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1025 pending.target_version,
1026 decision.level.as_str(),
1027 decision.reason,
1028 pending.pr_url
1029 ),
1030 );
1031 if let Err(e) = marker_write {
1032 state.event(
1033 "bump",
1034 format!(
1035 "could not update the pending release bump marker to v{next}: {e:#}; a later \
1036 merge may misjudge whether it is already covered"
1037 ),
1038 );
1039 }
1040 if let Some(warning) = title_warning {
1041 state.event(
1042 "bump",
1043 format!(
1044 "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1045 subject may still read the superseded version",
1046 pending.pr_url
1047 ),
1048 );
1049 }
1050 Ok(())
1051}
1052
1053async fn open_bump_pr(
1059 state: &RunState,
1060 worktree: &Path,
1061 branch: &str,
1062 next_version: &str,
1063 decision: &BumpDecision,
1064 source_pr_url: &str,
1065) -> Result<(String, Option<String>)> {
1066 let cargo_toml_path = worktree.join("Cargo.toml");
1067 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1068 .await
1069 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1070 let rewritten = rewrite_cargo_version(&toml, next_version)?;
1071 tokio::fs::write(&cargo_toml_path, rewritten)
1072 .await
1073 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1074
1075 sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
1076
1077 let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
1078 .await
1079 .context("commit the version bump")?;
1080 if !committed {
1081 bail!("the version bump left nothing to commit");
1082 }
1083
1084 let remote = state.config.merge.remote.clone();
1085 let pushed = git::push(worktree, &remote, branch).await?;
1086 if !pushed.ok() {
1087 bail!("pushing {branch} failed: {}", pushed.stderr);
1088 }
1089
1090 let title = format!("chore: release v{next_version}");
1091 let body = format!(
1092 "Release bump: `{}` to `v{next_version}`.\n\n{}\n\n\
1093 Triggered by run `{}`, which landed {source_pr_url}.\n\n\
1094 version-bump-only; nothing here needs a review \
1095 (AGENTS.md: \"Version-bump-only pull requests\").",
1096 decision.level.as_str(),
1097 decision.reason,
1098 state.id,
1099 );
1100 let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
1101 let automerge_warning = match gh_enable_automerge(worktree, &url).await {
1102 Ok(()) => None,
1103 Err(e) => Some(e.to_string()),
1104 };
1105 Ok((url, automerge_warning))
1106}
1107
1108async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
1116 let mut cmd = tokio::process::Command::new("cargo");
1117 cmd.arg("build").current_dir(worktree).quiet();
1118 if let Some(dir) = cache_dir {
1119 cmd.env("CARGO_TARGET_DIR", dir);
1120 }
1121 let out = cmd
1122 .stdin(std::process::Stdio::null())
1123 .output()
1124 .await
1125 .context("spawn cargo build")?;
1126 if !out.status.success() {
1127 bail!(
1128 "cargo build failed while syncing Cargo.lock: {}",
1129 String::from_utf8_lossy(&out.stderr).trim()
1130 );
1131 }
1132 Ok(())
1133}
1134
1135async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
1137 let out = tokio::process::Command::new("gh")
1138 .args(["pr", "view", pr_url, "--json", "title"])
1139 .current_dir(repo)
1140 .quiet()
1141 .stdin(std::process::Stdio::null())
1142 .output()
1143 .await
1144 .context("spawn gh pr view")?;
1145 if !out.status.success() {
1146 bail!(
1147 "gh pr view {pr_url}: {}",
1148 String::from_utf8_lossy(&out.stderr).trim()
1149 );
1150 }
1151 #[derive(Deserialize)]
1152 struct Title {
1153 title: String,
1154 }
1155 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
1156 .context("parse `gh pr view --json title` output")?;
1157 Ok(parsed.title)
1158}
1159
1160async fn gh_pr_create(
1161 cwd: &Path,
1162 base: &str,
1163 head: &str,
1164 title: &str,
1165 body: &str,
1166) -> Result<String> {
1167 let out = tokio::process::Command::new("gh")
1168 .args([
1169 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
1170 ])
1171 .current_dir(cwd)
1172 .quiet()
1173 .stdin(std::process::Stdio::null())
1174 .output()
1175 .await
1176 .context("spawn gh pr create")?;
1177 if out.status.success() {
1178 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
1179 } else {
1180 bail!(
1181 "gh pr create: {}",
1182 String::from_utf8_lossy(&out.stderr).trim()
1183 )
1184 }
1185}
1186
1187async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
1191 let out = tokio::process::Command::new("gh")
1192 .args([
1193 "pr",
1194 "merge",
1195 pr_url,
1196 "--auto",
1197 "--squash",
1198 "--delete-branch",
1199 ])
1200 .current_dir(cwd)
1201 .quiet()
1202 .stdin(std::process::Stdio::null())
1203 .output()
1204 .await
1205 .context("spawn gh pr merge --auto")?;
1206 if out.status.success() {
1207 Ok(())
1208 } else {
1209 bail!(
1210 "gh pr merge --auto: {}",
1211 String::from_utf8_lossy(&out.stderr).trim()
1212 )
1213 }
1214}
1215
1216async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
1220 let out = tokio::process::Command::new("gh")
1221 .args(["pr", "edit", pr_url, "--title", title])
1222 .current_dir(cwd)
1223 .quiet()
1224 .stdin(std::process::Stdio::null())
1225 .output()
1226 .await
1227 .context("spawn gh pr edit")?;
1228 if out.status.success() {
1229 Ok(())
1230 } else {
1231 bail!(
1232 "gh pr edit --title: {}",
1233 String::from_utf8_lossy(&out.stderr).trim()
1234 )
1235 }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240 use super::*;
1241 use crate::config::Config;
1242 use crate::land::PrLifecycle;
1243
1244 #[tokio::test]
1250 async fn a_disabled_config_does_nothing() {
1251 let config = Config {
1252 merge: crate::config::Merge {
1253 release_bump: false,
1254 ..crate::config::Merge::default()
1255 },
1256 ..Config::default()
1257 };
1258 let mut state = RunState::new(
1259 PathBuf::from("/no/such/repo"),
1260 "main".to_owned(),
1261 "0000000000000000000000000000000000000000".to_owned(),
1262 "irrelevant".to_owned(),
1263 config,
1264 );
1265 after_merge(&mut state, "https://example.invalid/pull/1")
1266 .await
1267 .expect("a disabled config must return Ok without touching anything");
1268 assert!(
1269 state.events.is_empty(),
1270 "nothing should happen at all, not even a logged event"
1271 );
1272 }
1273
1274 #[test]
1275 fn version_parses_and_bumps_each_digit() {
1276 let v = Version::parse("0.4.0").unwrap();
1277 assert_eq!(
1278 v,
1279 Version {
1280 major: 0,
1281 minor: 4,
1282 patch: 0
1283 }
1284 );
1285
1286 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
1287 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
1288 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
1289 }
1290
1291 #[test]
1292 fn version_tolerates_a_prerelease_suffix_on_patch() {
1293 let v = Version::parse("1.2.3-rc1").unwrap();
1294 assert_eq!(
1295 v,
1296 Version {
1297 major: 1,
1298 minor: 2,
1299 patch: 3
1300 }
1301 );
1302 }
1303
1304 #[test]
1305 fn version_rejects_garbage() {
1306 assert!(Version::parse("not-a-version").is_err());
1307 assert!(Version::parse("1.2").is_err());
1308 }
1309
1310 #[test]
1311 fn decision_parses_each_level() {
1312 for (json, level) in [
1313 (
1314 r#"{"level":"major","reason":"drops a config key"}"#,
1315 BumpLevel::Major,
1316 ),
1317 (
1318 r#"{"level":"minor","reason":"adds a new flag"}"#,
1319 BumpLevel::Minor,
1320 ),
1321 (
1322 r#"{"level":"patch","reason":"fixes a race"}"#,
1323 BumpLevel::Patch,
1324 ),
1325 ] {
1326 let decision = parse_decision(json).unwrap();
1327 assert_eq!(decision.level, level);
1328 assert!(!decision.reason.is_empty());
1329 }
1330 }
1331
1332 #[test]
1333 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
1334 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
1335 let decision = parse_decision(text).unwrap();
1336 assert_eq!(decision.level, BumpLevel::Minor);
1337 assert_eq!(decision.reason, "new HTTP route");
1338 }
1339
1340 #[test]
1341 fn a_broken_reply_is_an_error_not_a_default() {
1342 assert!(parse_decision("I decline to answer.").is_err());
1343 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
1344 assert!(
1345 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
1346 "an empty reason must not pass either"
1347 );
1348 assert!(
1349 parse_decision(r#"{"level":"patch"}"#).is_err(),
1350 "a reply with no reason at all must not pass"
1351 );
1352 }
1353
1354 #[test]
1355 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
1356 let prompt = decision_prompt(
1357 "feat: add a phone endpoint",
1358 "add POST /api/widgets",
1359 "1 file changed, 10 insertions(+)",
1360 &["src/web.rs".to_owned()],
1361 "0.8.0",
1362 );
1363 assert!(prompt.contains("0.8.0"), "the current version is stated");
1364 assert!(
1365 prompt.contains("below `1.0.0`")
1366 && prompt.contains("`minor` is the digit that carries a breaking change"),
1367 "the 0.x rule must be explicit: {prompt}"
1368 );
1369 assert!(
1370 prompt.contains("choose the larger"),
1371 "the tie-break toward the bigger digit must be explicit: {prompt}"
1372 );
1373 }
1374
1375 #[test]
1376 fn release_only_diffs_are_recognised() {
1377 assert!(is_release_only(&["Cargo.toml".to_owned()]));
1378 assert!(is_release_only(&[
1379 "Cargo.toml".to_owned(),
1380 "Cargo.lock".to_owned()
1381 ]));
1382 assert!(!is_release_only(&[]));
1383 assert!(!is_release_only(&[
1384 "Cargo.toml".to_owned(),
1385 "src/main.rs".to_owned()
1386 ]));
1387 }
1388
1389 #[test]
1390 fn cargo_version_rewrite_touches_only_the_package_table() {
1391 let toml = "\
1392[package]\n\
1393# a comment mentioning version on purpose\n\
1394name = \"magi-cli\"\n\
1395version = \"0.8.0\"\n\
1396edition = \"2024\"\n\
1397\n\
1398[dependencies]\n\
1399foo = { version = \"1.2.3\" }\n";
1400 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
1401 assert!(out.contains("version = \"0.9.0\""));
1402 assert!(
1403 out.contains("foo = { version = \"1.2.3\" }"),
1404 "a dependency's own version pin must survive: {out}"
1405 );
1406 assert!(
1407 out.contains("# a comment mentioning version on purpose"),
1408 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
1409 );
1410 assert_eq!(
1411 out.lines().count(),
1412 toml.lines().count(),
1413 "the rewrite replaces one line, it does not add or remove any"
1414 );
1415 }
1416
1417 #[test]
1418 fn cargo_version_rewrite_fails_without_a_package_table() {
1419 let toml = "[dependencies]\nfoo = \"1\"\n";
1420 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
1421 }
1422
1423 #[test]
1424 fn current_version_reads_only_the_package_table() {
1425 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
1426 assert_eq!(current_version(toml).unwrap(), "0.8.0");
1427 }
1428
1429 #[test]
1430 fn coalesce_proceeds_with_nothing_pending() {
1431 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
1432 }
1433
1434 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
1437 PendingBump {
1438 target_version: target_version.to_owned(),
1439 level,
1440 branch: format!("chore/release-v{target_version}"),
1441 pr_url: "https://example.invalid/pull/9".to_owned(),
1442 }
1443 }
1444
1445 #[test]
1446 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
1447 let pending = test_pending("0.9.0", BumpLevel::Minor);
1448 assert_eq!(
1449 coalesce(Some(&pending), "0.8.0").unwrap(),
1450 Coalesce::Skip {
1451 target_version: "0.9.0".to_owned()
1452 }
1453 );
1454 }
1455
1456 #[test]
1457 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
1458 let pending = test_pending("0.9.0", BumpLevel::Minor);
1459 assert_eq!(
1461 coalesce(Some(&pending), "0.9.0").unwrap(),
1462 Coalesce::Proceed
1463 );
1464 assert_eq!(
1466 coalesce(Some(&pending), "1.0.0").unwrap(),
1467 Coalesce::Proceed
1468 );
1469 }
1470
1471 #[test]
1472 fn pending_action_escalates_only_for_a_more_severe_decision() {
1473 assert_eq!(
1474 pending_action(BumpLevel::Patch, BumpLevel::Patch),
1475 PendingAction::AlreadyCovered
1476 );
1477 assert_eq!(
1478 pending_action(BumpLevel::Patch, BumpLevel::Minor),
1479 PendingAction::Escalate
1480 );
1481 assert_eq!(
1482 pending_action(BumpLevel::Patch, BumpLevel::Major),
1483 PendingAction::Escalate
1484 );
1485 assert_eq!(
1486 pending_action(BumpLevel::Minor, BumpLevel::Patch),
1487 PendingAction::AlreadyCovered
1488 );
1489 assert_eq!(
1490 pending_action(BumpLevel::Major, BumpLevel::Minor),
1491 PendingAction::AlreadyCovered
1492 );
1493 assert_eq!(
1494 pending_action(BumpLevel::Major, BumpLevel::Major),
1495 PendingAction::AlreadyCovered
1496 );
1497 }
1498
1499 #[test]
1500 fn pr_state_parsing_reads_open_and_not_open() {
1501 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
1502 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
1503 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
1504 }
1505
1506 #[test]
1507 fn a_lock_is_exclusive_until_dropped() {
1508 let dir = tempfile::tempdir().unwrap();
1509 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1510 let first = MarkerLock::acquire(&marker)
1511 .unwrap()
1512 .expect("first attempt takes the lock");
1513 assert!(
1514 MarkerLock::acquire(&marker).unwrap().is_none(),
1515 "a second attempt must be refused while the first holds it"
1516 );
1517 drop(first);
1518 assert!(
1519 MarkerLock::acquire(&marker).unwrap().is_some(),
1520 "dropping the guard releases the lock for the next attempt"
1521 );
1522 }
1523
1524 #[test]
1525 fn a_stale_lock_is_reclaimed() {
1526 let dir = tempfile::tempdir().unwrap();
1527 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1528 let lock_path = marker.with_extension("lock");
1529 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1530 std::fs::write(&lock_path, b"").unwrap();
1531 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
1532 std::fs::OpenOptions::new()
1533 .write(true)
1534 .open(&lock_path)
1535 .unwrap()
1536 .set_modified(old)
1537 .unwrap();
1538 assert!(
1539 MarkerLock::acquire(&marker).unwrap().is_some(),
1540 "a lock older than the stale window must be reclaimed rather than block forever"
1541 );
1542 }
1543
1544 #[tokio::test]
1545 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
1546 let dir = tempfile::tempdir().unwrap();
1547 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1548 let held = MarkerLock::acquire(&marker)
1549 .unwrap()
1550 .expect("seed the contention");
1551 let releaser = tokio::spawn(async move {
1552 tokio::time::sleep(Duration::from_millis(20)).await;
1553 drop(held);
1554 });
1555 let waited =
1556 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
1557 .await
1558 .unwrap();
1559 assert!(
1560 waited.is_some(),
1561 "a merge landing behind another's still-running decision must not be dropped - it \
1562 must wait for that decision to finish and then judge against what it left behind"
1563 );
1564 releaser.await.unwrap();
1565 }
1566
1567 #[tokio::test]
1568 async fn a_lock_held_past_the_ceiling_gives_up() {
1569 let dir = tempfile::tempdir().unwrap();
1570 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1571 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
1572 let waited =
1573 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
1574 .await
1575 .unwrap();
1576 assert!(
1577 waited.is_none(),
1578 "a lock genuinely held past the ceiling must eventually give up rather than wait \
1579 forever"
1580 );
1581 }
1582
1583 #[test]
1584 fn level_between_reads_off_the_differing_digit() {
1585 assert_eq!(
1586 level_between(
1587 Version::parse("0.8.0").unwrap(),
1588 Version::parse("1.0.0").unwrap()
1589 ),
1590 Some(BumpLevel::Major)
1591 );
1592 assert_eq!(
1593 level_between(
1594 Version::parse("0.8.0").unwrap(),
1595 Version::parse("0.9.0").unwrap()
1596 ),
1597 Some(BumpLevel::Minor)
1598 );
1599 assert_eq!(
1600 level_between(
1601 Version::parse("0.8.0").unwrap(),
1602 Version::parse("0.8.1").unwrap()
1603 ),
1604 Some(BumpLevel::Patch)
1605 );
1606 assert_eq!(
1607 level_between(
1608 Version::parse("0.8.0").unwrap(),
1609 Version::parse("0.8.0").unwrap()
1610 ),
1611 None
1612 );
1613 }
1614
1615 #[test]
1616 fn open_release_pr_is_found_among_unrelated_pull_requests() {
1617 let json = r#"[
1618 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
1619 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
1620 ]"#;
1621 let found = parse_open_release_pr(json).unwrap();
1622 assert_eq!(
1623 found,
1624 Some((
1625 "chore/release-v0.9.0".to_owned(),
1626 "https://example.invalid/pull/2".to_owned()
1627 ))
1628 );
1629 }
1630
1631 #[test]
1632 fn no_open_release_pr_reads_as_none_not_an_error() {
1633 let json =
1634 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
1635 assert_eq!(parse_open_release_pr(json).unwrap(), None);
1636 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
1637 }
1638
1639 #[test]
1640 fn marker_round_trips_through_disk() {
1641 let dir = tempfile::tempdir().unwrap();
1642 let path = marker_path(dir.path(), Path::new("/repos/magi"));
1643 assert!(read_marker(&path).is_none());
1644
1645 let marker = test_pending("0.9.0", BumpLevel::Patch);
1646 write_marker(&path, &marker).unwrap();
1647 let read_back = read_marker(&path).unwrap();
1648 assert_eq!(read_back.target_version, "0.9.0");
1649 assert_eq!(read_back.level, BumpLevel::Patch);
1650 assert_eq!(read_back.pr_url, marker.pr_url);
1651
1652 clear_marker(&path);
1653 assert!(read_marker(&path).is_none());
1654 }
1655
1656 #[test]
1657 fn different_repos_get_different_marker_files() {
1658 let dir = tempfile::tempdir().unwrap();
1659 let a = marker_path(dir.path(), Path::new("/repos/a"));
1660 let b = marker_path(dir.path(), Path::new("/repos/b"));
1661 assert_ne!(a, b);
1662 }
1663
1664 #[test]
1668 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
1669 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
1670 assert!(
1671 is_release_only(&files),
1672 "the bump pull request's own diff must read as release-only"
1673 );
1674 }
1675
1676 #[test]
1677 fn should_release_bump_reads_only_a_merged_status() {
1678 assert!(should_release_bump(RunStatus::Merged));
1679 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
1680 assert!(!should_release_bump(other));
1681 }
1682 }
1683
1684 #[test]
1688 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
1689 let pr = land::PrState {
1690 url: "https://github.com/o/r/pull/1".to_owned(),
1691 number: 1,
1692 state: PrLifecycle::Merged,
1693 checks: land::Checks::Green,
1694 failing: Vec::new(),
1695 review_comments: Vec::new(),
1696 blocking: land::Blocking::No,
1697 };
1698 assert_eq!(
1699 land::decide(&pr, 0, 4, Duration::ZERO),
1700 land::Step::Done { merged: true }
1701 );
1702 assert!(should_release_bump(RunStatus::Merged));
1703 }
1704
1705 #[test]
1710 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
1711 let pr = land::PrState {
1712 url: "https://github.com/o/r/pull/2".to_owned(),
1713 number: 2,
1714 state: PrLifecycle::Open,
1715 checks: land::Checks::Green,
1716 failing: Vec::new(),
1717 review_comments: Vec::new(),
1718 blocking: land::Blocking::No,
1719 };
1720 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
1721 assert!(should_release_bump(RunStatus::Merged));
1724 }
1725
1726 #[test]
1729 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
1730 let argv = land::merge_argv(3, "feat: something");
1731 let outcome = land::merged_after_all(
1732 &argv,
1733 "could not determine current branch: not on any branch",
1734 Some(PrLifecycle::Merged),
1735 );
1736 assert!(outcome.is_some(), "the forge's confirmation must win");
1737 assert!(should_release_bump(RunStatus::Merged));
1738
1739 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
1742 assert!(land::merged_after_all(&argv, "network error", None).is_none());
1743 }
1744
1745 #[test]
1747 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
1748 let pr = land::PrState {
1749 url: "https://github.com/o/r/pull/4".to_owned(),
1750 number: 4,
1751 state: PrLifecycle::Closed,
1752 checks: land::Checks::Green,
1753 failing: Vec::new(),
1754 review_comments: Vec::new(),
1755 blocking: land::Blocking::No,
1756 };
1757 assert_eq!(
1758 land::decide(&pr, 0, 4, Duration::ZERO),
1759 land::Step::Done { merged: false }
1760 );
1761 assert!(!should_release_bump(RunStatus::Blocked));
1762 }
1763}