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