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
212fn rewrite_table_version(toml: &str, table: &str, new_version: &str) -> Result<String> {
223 let mut out = String::with_capacity(toml.len() + 8);
224 let mut in_table = 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_table = trimmed == table;
230 }
231 if !done && in_table && 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 `{table}`");
241 }
242 Ok(out)
243}
244
245pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
257 rewrite_table_version(toml, "[package]", new_version)
258 .or_else(|_| rewrite_table_version(toml, "[workspace.package]", new_version))
259 .context("no `version` field found under `[package]` or `[workspace.package]`")
260}
261
262fn version_in_table(toml: &str, table: &str) -> Option<String> {
264 let mut in_table = false;
265 for line in toml.lines() {
266 let trimmed = line.trim();
267 if trimmed.starts_with('[') {
268 in_table = trimmed == table;
269 continue;
270 }
271 if !in_table {
272 continue;
273 }
274 let mut parts = trimmed.splitn(2, '=');
275 let key = parts.next().map(str::trim);
276 let Some(value) = parts.next() else {
277 continue;
278 };
279 if key == Some("version") {
280 return Some(value.trim().trim_matches('"').to_owned());
281 }
282 }
283 None
284}
285
286fn current_version(toml: &str) -> Result<String> {
291 version_in_table(toml, "[package]")
292 .or_else(|| version_in_table(toml, "[workspace.package]"))
293 .context("no `version` field found under `[package]` or `[workspace.package]`")
294}
295
296pub fn decision_prompt(
306 subject: &str,
307 instruction: &str,
308 diffstat: &str,
309 files: &[String],
310 current_version: &str,
311) -> String {
312 let mut s = format!(
313 "A pull request just merged into the base branch. Decide which digit \
314 of this project's `major.minor.patch` version this change earns, so \
315 a release bump can be opened for exactly it.\n\n\
316 Current version: {current_version}\n\n\
317 # Merge subject\n\n{subject}\n\n\
318 # The task that produced it\n\n{instruction}\n\n\
319 # Files changed ({} total)\n\n",
320 files.len()
321 );
322 const MAX_FILES: usize = 50;
323 for f in files.iter().take(MAX_FILES) {
324 let _ = writeln!(s, "- {f}");
325 }
326 if files.len() > MAX_FILES {
327 let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
328 }
329 let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
330
331 s.push_str(
332 "\n# How to decide\n\n\
333 This project is below version `1.0.0`. At that stage **`minor` is \
334 the digit that carries a breaking change** - do not spend `major` \
335 below `1.0.0`.\n\n\
336 A change is breaking, and earns `minor`, when it changes any of: \
337 the public API reachable from `src/lib.rs`, a CLI subcommand or \
338 flag, an HTTP API route or response shape, a configuration key, or \
339 the on-disk shape of persisted state.\n\n\
340 A user-visible new capability that breaks none of the above also \
341 earns `minor`.\n\n\
342 A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
343 **When it is not obvious which digit applies, choose the larger \
344 one.** An oversized bump costs nothing; a breaking change shipped as \
345 `patch` breaks every downstream update that pins a range.\n\n\
346 # Output\n\n\
347 Reply with exactly one fenced JSON object and nothing that matters \
348 outside it:\n\n\
349 ```json\n\
350 {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
351 ```\n",
352 );
353 let _ = write!(
355 s,
356 "\n{}\n\nThe `reason` goes into a GitHub pull request body, so write it \
357 in English.\n",
358 crate::prompt::GITHUB_ENGLISH_HEADING
359 );
360 s
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct PendingBump {
380 pub target_version: String,
382 pub level: BumpLevel,
385 pub branch: String,
388 pub pr_url: String,
391}
392
393pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
397 let key = repo.to_string_lossy();
398 home.join("bump")
399 .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
400}
401
402pub fn read_marker(path: &Path) -> Option<PendingBump> {
406 let body = std::fs::read_to_string(path).ok()?;
407 serde_json::from_str(&body).ok()
408}
409
410pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
414 if let Some(parent) = path.parent() {
415 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
416 }
417 let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
418 let tmp = path.with_extension("json.tmp");
419 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
420 std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
421 Ok(())
422}
423
424pub fn clear_marker(path: &Path) {
427 let _ = std::fs::remove_file(path);
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
434pub enum Coalesce {
435 Proceed,
438 Skip {
440 target_version: String,
442 },
443}
444
445pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
447 let Some(pending) = pending else {
448 return Ok(Coalesce::Proceed);
449 };
450 let current = Version::parse(current_version)?;
451 let target = Version::parse(&pending.target_version)?;
452 if current >= target {
453 return Ok(Coalesce::Proceed);
454 }
455 Ok(Coalesce::Skip {
456 target_version: pending.target_version.clone(),
457 })
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub enum PendingAction {
463 AlreadyCovered,
466 Escalate,
469}
470
471pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
480 if decision_level.severity() > pending_level.severity() {
481 PendingAction::Escalate
482 } else {
483 PendingAction::AlreadyCovered
484 }
485}
486
487fn parse_pr_state(json: &str) -> Result<bool> {
489 #[derive(Deserialize)]
490 struct State {
491 state: String,
492 }
493 let parsed: State =
494 serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
495 Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
496}
497
498async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
511 let out = tokio::process::Command::new("gh")
512 .args(["pr", "view", pr_url, "--json", "state"])
513 .current_dir(repo)
514 .quiet()
515 .stdin(std::process::Stdio::null())
516 .output()
517 .await
518 .context("spawn gh pr view")?;
519 if !out.status.success() {
520 bail!(
521 "gh pr view {pr_url}: {}",
522 String::from_utf8_lossy(&out.stderr).trim()
523 );
524 }
525 parse_pr_state(&String::from_utf8_lossy(&out.stdout))
526}
527
528const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
536
537struct MarkerLock {
549 path: PathBuf,
550}
551
552impl MarkerLock {
553 fn acquire(marker: &Path) -> Result<Option<Self>> {
557 let path = marker.with_extension("lock");
558 if let Some(parent) = path.parent() {
559 std::fs::create_dir_all(parent)
560 .with_context(|| format!("create {}", parent.display()))?;
561 }
562 if Self::try_create(&path)? {
563 return Ok(Some(Self { path }));
564 }
565 if Self::is_stale(&path) {
566 let _ = std::fs::remove_file(&path);
567 if Self::try_create(&path)? {
568 return Ok(Some(Self { path }));
569 }
570 }
571 Ok(None)
572 }
573
574 fn try_create(path: &Path) -> Result<bool> {
575 match std::fs::OpenOptions::new()
576 .write(true)
577 .create_new(true)
578 .open(path)
579 {
580 Ok(_) => Ok(true),
581 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
582 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
583 }
584 }
585
586 fn is_stale(path: &Path) -> bool {
587 std::fs::metadata(path)
588 .and_then(|m| m.modified())
589 .ok()
590 .and_then(|m| m.elapsed().ok())
591 .is_some_and(|age| age >= LOCK_STALE_AFTER)
592 }
593}
594
595impl Drop for MarkerLock {
596 fn drop(&mut self) {
597 let _ = std::fs::remove_file(&self.path);
598 }
599}
600
601const LOCK_POLL: Duration = Duration::from_secs(5);
603
604const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
617
618async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
621 wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
622}
623
624async fn wait_for_marker_lock_with(
628 marker: &Path,
629 poll: Duration,
630 ceiling: Duration,
631) -> Result<Option<MarkerLock>> {
632 let mut waited = Duration::ZERO;
633 loop {
634 if let Some(lock) = MarkerLock::acquire(marker)? {
635 return Ok(Some(lock));
636 }
637 if waited >= ceiling {
638 return Ok(None);
639 }
640 tokio::time::sleep(poll).await;
641 waited += poll;
642 }
643}
644
645fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
653 if to.major != from.major {
654 Some(BumpLevel::Major)
655 } else if to.minor != from.minor {
656 Some(BumpLevel::Minor)
657 } else if to.patch != from.patch {
658 Some(BumpLevel::Patch)
659 } else {
660 None
661 }
662}
663
664fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
667 #[derive(Deserialize)]
668 struct Pr {
669 url: String,
670 #[serde(rename = "headRefName")]
671 head_ref_name: String,
672 }
673 let list: Vec<Pr> =
674 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
675 Ok(list
676 .into_iter()
677 .find(|p| p.head_ref_name.starts_with("chore/release-v"))
678 .map(|p| (p.head_ref_name, p.url)))
679}
680
681async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
696 let out = tokio::process::Command::new("gh")
697 .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
698 .current_dir(repo)
699 .quiet()
700 .stdin(std::process::Stdio::null())
701 .output()
702 .await
703 .context("spawn gh pr list")?;
704 if !out.status.success() {
705 bail!(
706 "gh pr list: {}",
707 String::from_utf8_lossy(&out.stderr).trim()
708 );
709 }
710 parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
711}
712
713pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
722 after_merge_at(state, pr_url, None).await
723}
724
725async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
731 let out = git::git(
732 repo,
733 &[
734 "ls-tree",
735 "--name-only",
736 &format!("{remote}/{base}"),
737 "--",
738 "Cargo.toml",
739 ],
740 )
741 .await
742 .context("look for Cargo.toml on the base branch")?;
743 Ok(!out.trim().is_empty())
744}
745
746async fn after_merge_at(state: &mut RunState, pr_url: &str, home: Option<&Path>) -> Result<()> {
749 if !state.config.merge.release_bump {
750 return Ok(());
751 }
752 let Some(winner) = state.winner().cloned() else {
753 return Ok(());
754 };
755 let repo = state.repo.clone();
756 let base = state.base_branch.clone();
757 let remote = state.config.merge.remote.clone();
758
759 let files = git::changed_files(&winner.worktree, &base, &winner.branch)
760 .await
761 .unwrap_or_default();
762 if is_release_only(&files) {
763 state.event(
764 "bump",
765 "the merged change touches only the release manifest; not treating it as a trigger",
766 );
767 return Ok(());
768 }
769
770 git::fetch(&repo, &remote, &base).await.ok();
773 if !base_has_cargo_toml(&repo, &remote, &base).await? {
774 state.event(
775 "bump",
776 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping",
777 );
778 return Ok(());
779 }
780
781 let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
782 let Some(_lock) = wait_for_marker_lock(&marker).await? else {
789 state.event(
790 "bump",
791 "another release bump decision held the lock past the wait ceiling; skipping this round",
792 );
793 return Ok(());
794 };
795
796 git::fetch(&repo, &remote, &base).await.ok();
797 let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
798 .await
799 .context("read Cargo.toml from the base branch")?;
800 let base_version = current_version(&cargo_toml)?;
801
802 let mut pending = read_marker(&marker);
803 if let Some(p) = &pending {
804 match coalesce(Some(p), &base_version)? {
805 Coalesce::Proceed => {
806 clear_marker(&marker);
809 pending = None;
810 }
811 Coalesce::Skip { target_version } => {
812 if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
813 state.event(
814 "bump",
815 format!(
816 "the pending release bump to v{target_version} ({}) is no longer \
817 open; treating it as abandoned",
818 p.pr_url
819 ),
820 );
821 clear_marker(&marker);
822 pending = None;
823 }
824 }
829 }
830 }
831
832 if pending.is_none() {
833 if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
838 && let Some(target) = branch
839 .strip_prefix("chore/release-v")
840 .and_then(|v| Version::parse(v).ok())
841 {
842 let base_parsed = Version::parse(&base_version)?;
843 if target > base_parsed
844 && let Some(level) = level_between(base_parsed, target)
845 {
846 let adopted = PendingBump {
847 target_version: target.to_string(),
848 level,
849 branch,
850 pr_url: url,
851 };
852 let _ = write_marker(&marker, &adopted);
855 pending = Some(adopted);
856 }
857 }
858 }
859
860 let title = pr_title(&repo, pr_url).await.unwrap_or_default();
861 let subject = land::merge_subject(&title, &state.instruction);
862 let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
863 .await
864 .unwrap_or_default();
865 let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
866
867 let spec: AgentSpec = agent::pick(
873 &state.config.agents,
874 state.config.roles.chatter.as_deref(),
875 &agent::installed,
876 )
877 .context("choose an agent for the release-bump decision")?;
878 let mut seat = SeatState::new("bump", &spec.id, state.seed);
879 let artifacts = agent::artifacts_dir(&state.dir());
880 let out = agent::invoke(
881 &spec,
882 &mut seat,
883 &Invocation {
884 cwd: &repo,
885 prompt: &prompt,
886 timeout: DECISION_TIMEOUT,
887 allow_write: false,
890 sessions: false,
891 artifacts: &artifacts,
892 stem: "bump-decision",
893 run: &state.id,
894 node: "bump",
895 cache_dir: state.config.cache_dir().as_deref(),
896 attachments: &[],
897 },
898 )
899 .await
900 .context("ask an agent how big the merged change was")?;
901 if !out.usable() {
902 bail!(
903 "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
904 out.exit_code,
905 out.timed_out
906 );
907 }
908 let decision = parse_decision(&out.text).context("parse the release-bump decision")?;
909
910 if let Some(p) = pending {
911 return match pending_action(p.level, decision.level) {
912 PendingAction::AlreadyCovered => {
913 state.event(
914 "bump",
915 format!(
916 "a release bump to v{} ({}) already covers at least a {} change; not \
917 opening another",
918 p.target_version,
919 p.pr_url,
920 decision.level.as_str()
921 ),
922 );
923 Ok(())
924 }
925 PendingAction::Escalate => {
926 escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
927 }
928 };
929 }
930
931 let next = Version::parse(&base_version)?
932 .bump(decision.level)
933 .to_string();
934 let branch = format!("chore/release-v{next}");
935 let worktree = state.dir().join("bump");
936 git::worktree_remove(&repo, &worktree).await.ok();
937 git::worktree_add_branch(&repo, &worktree, &branch, &format!("{remote}/{base}"))
938 .await
939 .context("create the release-bump worktree")?;
940 let opened = open_bump_pr(state, &worktree, &branch, &next, &decision, pr_url).await;
941 git::worktree_remove(&repo, &worktree).await.ok();
945 let (pr_url_opened, automerge_warning) = opened?;
946
947 let marker_write = write_marker(
956 &marker,
957 &PendingBump {
958 target_version: next.clone(),
959 level: decision.level,
960 branch,
961 pr_url: pr_url_opened.clone(),
962 },
963 );
964 state.event(
965 "bump",
966 format!(
967 "opened a {} release bump to v{next} ({}): {pr_url_opened}",
968 decision.level.as_str(),
969 decision.reason
970 ),
971 );
972 if let Err(e) = marker_write {
973 state.event(
974 "bump",
975 format!(
976 "could not record the pending release bump marker for v{next}: {e:#}; a later \
977 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
978 the forge either"
979 ),
980 );
981 }
982 if let Some(warning) = automerge_warning {
983 state.event(
984 "bump",
985 format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
986 );
987 }
988 Ok(())
989}
990
991async fn escalate_pending(
1000 state: &mut RunState,
1001 repo: &Path,
1002 remote: &str,
1003 pending: &PendingBump,
1004 decision: &BumpDecision,
1005 base_version: &str,
1006 marker: &Path,
1007) -> Result<()> {
1008 let next = Version::parse(base_version)?
1009 .bump(decision.level)
1010 .to_string();
1011 let worktree = state.dir().join("bump");
1012 git::worktree_remove(repo, &worktree).await.ok();
1013 let checked_out = git::git_raw(
1014 repo,
1015 &[
1016 "worktree",
1017 "add",
1018 "--force",
1019 &worktree.to_string_lossy(),
1020 &pending.branch,
1021 ],
1022 )
1023 .await?;
1024 if !checked_out.ok() {
1025 bail!(
1026 "checking out the pending release branch {} failed: {}",
1027 pending.branch,
1028 checked_out.stderr
1029 );
1030 }
1031
1032 let pushed: Result<()> = async {
1037 let cargo_toml_path = worktree.join("Cargo.toml");
1038 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1039 .await
1040 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1041 let rewritten = rewrite_cargo_version(&toml, &next)?;
1042 tokio::fs::write(&cargo_toml_path, rewritten)
1043 .await
1044 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1045 sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1046 let committed = git::commit_all(
1047 &worktree,
1048 &format!(
1049 "chore: release v{next} (supersedes v{})",
1050 pending.target_version
1051 ),
1052 )
1053 .await
1054 .context("commit the escalated version bump")?;
1055 if !committed {
1056 bail!("escalating the version bump left nothing to commit");
1057 }
1058 let pushed = git::push(&worktree, remote, &pending.branch).await?;
1059 if !pushed.ok() {
1060 bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1061 }
1062 Ok(())
1063 }
1064 .await;
1065 if let Err(e) = pushed {
1066 git::worktree_remove(repo, &worktree).await.ok();
1067 return Err(e);
1068 }
1069
1070 let title_warning = match gh_pr_edit_title(
1074 &worktree,
1075 &pending.pr_url,
1076 &format!("chore: release v{next}"),
1077 )
1078 .await
1079 {
1080 Ok(()) => None,
1081 Err(e) => Some(e.to_string()),
1082 };
1083 git::worktree_remove(repo, &worktree).await.ok();
1084
1085 let marker_write = write_marker(
1086 marker,
1087 &PendingBump {
1088 target_version: next.clone(),
1089 level: decision.level,
1090 branch: pending.branch.clone(),
1091 pr_url: pending.pr_url.clone(),
1092 },
1093 );
1094 state.event(
1095 "bump",
1096 format!(
1097 "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1098 pending.target_version,
1099 decision.level.as_str(),
1100 decision.reason,
1101 pending.pr_url
1102 ),
1103 );
1104 if let Err(e) = marker_write {
1105 state.event(
1106 "bump",
1107 format!(
1108 "could not update the pending release bump marker to v{next}: {e:#}; a later \
1109 merge may misjudge whether it is already covered"
1110 ),
1111 );
1112 }
1113 if let Some(warning) = title_warning {
1114 state.event(
1115 "bump",
1116 format!(
1117 "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1118 subject may still read the superseded version",
1119 pending.pr_url
1120 ),
1121 );
1122 }
1123 Ok(())
1124}
1125
1126async fn open_bump_pr(
1132 state: &RunState,
1133 worktree: &Path,
1134 branch: &str,
1135 next_version: &str,
1136 decision: &BumpDecision,
1137 source_pr_url: &str,
1138) -> Result<(String, Option<String>)> {
1139 let cargo_toml_path = worktree.join("Cargo.toml");
1140 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1141 .await
1142 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1143 let rewritten = rewrite_cargo_version(&toml, next_version)?;
1144 tokio::fs::write(&cargo_toml_path, rewritten)
1145 .await
1146 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1147
1148 sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
1149
1150 let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
1151 .await
1152 .context("commit the version bump")?;
1153 if !committed {
1154 bail!("the version bump left nothing to commit");
1155 }
1156
1157 let remote = state.config.merge.remote.clone();
1158 let pushed = git::push(worktree, &remote, branch).await?;
1159 if !pushed.ok() {
1160 bail!("pushing {branch} failed: {}", pushed.stderr);
1161 }
1162
1163 let (title, body) = release_pr(
1164 decision.level.as_str(),
1165 &decision.reason,
1166 next_version,
1167 &state.id,
1168 source_pr_url,
1169 );
1170 let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
1171 let automerge_warning = match gh_enable_automerge(worktree, &url).await {
1172 Ok(()) => None,
1173 Err(e) => Some(e.to_string()),
1174 };
1175 Ok((url, automerge_warning))
1176}
1177
1178async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
1186 let mut cmd = tokio::process::Command::new("cargo");
1187 cmd.arg("build").current_dir(worktree).quiet();
1188 if let Some(dir) = cache_dir {
1189 cmd.env("CARGO_TARGET_DIR", dir);
1190 }
1191 let out = cmd
1192 .stdin(std::process::Stdio::null())
1193 .output()
1194 .await
1195 .context("spawn cargo build")?;
1196 if !out.status.success() {
1197 bail!(
1198 "cargo build failed while syncing Cargo.lock: {}",
1199 String::from_utf8_lossy(&out.stderr).trim()
1200 );
1201 }
1202 Ok(())
1203}
1204
1205fn release_pr(
1210 level: &str,
1211 reason: &str,
1212 next_version: &str,
1213 run_id: &str,
1214 source_pr_url: &str,
1215) -> (String, String) {
1216 let title = format!("chore: release v{next_version}");
1217 let body = format!(
1218 "Release bump: `{level}` to `v{next_version}`.\n\n{reason}\n\n\
1219 Triggered by run `{run_id}`, which landed {source_pr_url}.\n\n\
1220 version-bump-only; nothing here needs a review \
1221 (AGENTS.md: \"Version-bump-only pull requests\").",
1222 );
1223 (title, body)
1224}
1225
1226async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
1228 let out = tokio::process::Command::new("gh")
1229 .args(["pr", "view", pr_url, "--json", "title"])
1230 .current_dir(repo)
1231 .quiet()
1232 .stdin(std::process::Stdio::null())
1233 .output()
1234 .await
1235 .context("spawn gh pr view")?;
1236 if !out.status.success() {
1237 bail!(
1238 "gh pr view {pr_url}: {}",
1239 String::from_utf8_lossy(&out.stderr).trim()
1240 );
1241 }
1242 #[derive(Deserialize)]
1243 struct Title {
1244 title: String,
1245 }
1246 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
1247 .context("parse `gh pr view --json title` output")?;
1248 Ok(parsed.title)
1249}
1250
1251async fn gh_pr_create(
1252 cwd: &Path,
1253 base: &str,
1254 head: &str,
1255 title: &str,
1256 body: &str,
1257) -> Result<String> {
1258 let out = tokio::process::Command::new("gh")
1259 .args([
1260 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
1261 ])
1262 .current_dir(cwd)
1263 .quiet()
1264 .stdin(std::process::Stdio::null())
1265 .output()
1266 .await
1267 .context("spawn gh pr create")?;
1268 if out.status.success() {
1269 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
1270 } else {
1271 bail!(
1272 "gh pr create: {}",
1273 String::from_utf8_lossy(&out.stderr).trim()
1274 )
1275 }
1276}
1277
1278async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
1282 let out = tokio::process::Command::new("gh")
1283 .args([
1284 "pr",
1285 "merge",
1286 pr_url,
1287 "--auto",
1288 "--squash",
1289 "--delete-branch",
1290 ])
1291 .current_dir(cwd)
1292 .quiet()
1293 .stdin(std::process::Stdio::null())
1294 .output()
1295 .await
1296 .context("spawn gh pr merge --auto")?;
1297 if out.status.success() {
1298 Ok(())
1299 } else {
1300 bail!(
1301 "gh pr merge --auto: {}",
1302 String::from_utf8_lossy(&out.stderr).trim()
1303 )
1304 }
1305}
1306
1307async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
1311 let out = tokio::process::Command::new("gh")
1312 .args(["pr", "edit", pr_url, "--title", title])
1313 .current_dir(cwd)
1314 .quiet()
1315 .stdin(std::process::Stdio::null())
1316 .output()
1317 .await
1318 .context("spawn gh pr edit")?;
1319 if out.status.success() {
1320 Ok(())
1321 } else {
1322 bail!(
1323 "gh pr edit --title: {}",
1324 String::from_utf8_lossy(&out.stderr).trim()
1325 )
1326 }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331 use super::*;
1332
1333 #[test]
1334 fn github_facing_bump_text_is_english() {
1335 let (title, body) =
1336 release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
1337 assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
1338 assert_eq!(title, "chore: release v0.37.0");
1339 let p = decision_prompt("s", "i", "d", &[], "0.36.5");
1340 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
1341 }
1342 use crate::config::Config;
1343 use crate::land::PrLifecycle;
1344
1345 #[tokio::test]
1351 async fn a_disabled_config_does_nothing() {
1352 let config = Config {
1353 merge: crate::config::Merge {
1354 release_bump: false,
1355 ..crate::config::Merge::default()
1356 },
1357 ..Config::default()
1358 };
1359 let mut state = RunState::new(
1360 PathBuf::from("/no/such/repo"),
1361 "main".to_owned(),
1362 "0000000000000000000000000000000000000000".to_owned(),
1363 "irrelevant".to_owned(),
1364 config,
1365 );
1366 after_merge(&mut state, "https://example.invalid/pull/1")
1367 .await
1368 .expect("a disabled config must return Ok without touching anything");
1369 assert!(
1370 state.events.is_empty(),
1371 "nothing should happen at all, not even a logged event"
1372 );
1373 }
1374
1375 async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
1377 let dir = tempfile::tempdir().unwrap();
1378 let origin = dir.path().join("origin.git");
1379 let repo = dir.path().join("repo");
1380 let o = origin.to_string_lossy().into_owned();
1381 git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
1382 .await
1383 .unwrap();
1384 tokio::fs::create_dir_all(&repo).await.unwrap();
1385 git::git(&repo, &["init", "-b", "main"]).await.unwrap();
1386 git::git(&repo, &["config", "user.name", "test"])
1387 .await
1388 .unwrap();
1389 git::git(&repo, &["config", "user.email", "test@example.com"])
1390 .await
1391 .unwrap();
1392 for (name, body) in files {
1393 tokio::fs::write(repo.join(name), body).await.unwrap();
1394 }
1395 git::git(&repo, &["add", "-A"]).await.unwrap();
1396 git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
1397 git::git(&repo, &["remote", "add", "origin", &o])
1398 .await
1399 .unwrap();
1400 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
1401 (dir, repo)
1402 }
1403
1404 #[tokio::test]
1405 async fn base_has_cargo_toml_tells_rust_from_non_rust() {
1406 let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
1407 assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
1408 let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
1409 assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
1410 assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
1412 }
1413
1414 #[tokio::test]
1415 async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
1416 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
1417 let home = tempfile::tempdir().unwrap();
1418 let mut state = RunState::new(
1419 repo.clone(),
1420 "main".to_owned(),
1421 "0000000000000000000000000000000000000000".to_owned(),
1422 "task".to_owned(),
1423 Config::default(),
1424 );
1425 state.candidates.push(crate::run::Candidate {
1426 index: 0,
1427 label: 'A',
1428 agent: "x".to_owned(),
1429 branch: "main".to_owned(),
1430 worktree: repo.clone(),
1431 summary: String::new(),
1432 stat: String::new(),
1433 files: 1,
1434 commits: 1,
1435 empty: false,
1436 failed: None,
1437 verified_noop: None,
1438 duration_ms: 0,
1439 folded: false,
1440 });
1441 state.tally = Some(
1442 serde_json::from_value(serde_json::json!({
1443 "first_choice": {}, "borda": {}, "winner": "A",
1444 "unanimous_initial": true, "deliberated": false,
1445 "changed_votes": 0, "unanimous_final": true,
1446 }))
1447 .unwrap(),
1448 );
1449 after_merge_at(
1450 &mut state,
1451 "https://example.invalid/pull/1",
1452 Some(home.path()),
1453 )
1454 .await
1455 .expect("a non-Rust repository is not an error");
1456 let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
1457 assert_eq!(bumps.len(), 1, "{:?}", state.events);
1458 assert_eq!(
1459 bumps[0].message,
1460 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
1461 );
1462 assert!(
1463 std::fs::read_dir(home.path()).unwrap().next().is_none(),
1464 "no marker and no lock may be created"
1465 );
1466 }
1467
1468 #[test]
1469 fn version_parses_and_bumps_each_digit() {
1470 let v = Version::parse("0.4.0").unwrap();
1471 assert_eq!(
1472 v,
1473 Version {
1474 major: 0,
1475 minor: 4,
1476 patch: 0
1477 }
1478 );
1479
1480 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
1481 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
1482 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
1483 }
1484
1485 #[test]
1486 fn version_tolerates_a_prerelease_suffix_on_patch() {
1487 let v = Version::parse("1.2.3-rc1").unwrap();
1488 assert_eq!(
1489 v,
1490 Version {
1491 major: 1,
1492 minor: 2,
1493 patch: 3
1494 }
1495 );
1496 }
1497
1498 #[test]
1499 fn version_rejects_garbage() {
1500 assert!(Version::parse("not-a-version").is_err());
1501 assert!(Version::parse("1.2").is_err());
1502 }
1503
1504 #[test]
1505 fn decision_parses_each_level() {
1506 for (json, level) in [
1507 (
1508 r#"{"level":"major","reason":"drops a config key"}"#,
1509 BumpLevel::Major,
1510 ),
1511 (
1512 r#"{"level":"minor","reason":"adds a new flag"}"#,
1513 BumpLevel::Minor,
1514 ),
1515 (
1516 r#"{"level":"patch","reason":"fixes a race"}"#,
1517 BumpLevel::Patch,
1518 ),
1519 ] {
1520 let decision = parse_decision(json).unwrap();
1521 assert_eq!(decision.level, level);
1522 assert!(!decision.reason.is_empty());
1523 }
1524 }
1525
1526 #[test]
1527 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
1528 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
1529 let decision = parse_decision(text).unwrap();
1530 assert_eq!(decision.level, BumpLevel::Minor);
1531 assert_eq!(decision.reason, "new HTTP route");
1532 }
1533
1534 #[test]
1535 fn a_broken_reply_is_an_error_not_a_default() {
1536 assert!(parse_decision("I decline to answer.").is_err());
1537 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
1538 assert!(
1539 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
1540 "an empty reason must not pass either"
1541 );
1542 assert!(
1543 parse_decision(r#"{"level":"patch"}"#).is_err(),
1544 "a reply with no reason at all must not pass"
1545 );
1546 }
1547
1548 #[test]
1549 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
1550 let prompt = decision_prompt(
1551 "feat: add a phone endpoint",
1552 "add POST /api/widgets",
1553 "1 file changed, 10 insertions(+)",
1554 &["src/web.rs".to_owned()],
1555 "0.8.0",
1556 );
1557 assert!(prompt.contains("0.8.0"), "the current version is stated");
1558 assert!(
1559 prompt.contains("below `1.0.0`")
1560 && prompt.contains("`minor` is the digit that carries a breaking change"),
1561 "the 0.x rule must be explicit: {prompt}"
1562 );
1563 assert!(
1564 prompt.contains("choose the larger"),
1565 "the tie-break toward the bigger digit must be explicit: {prompt}"
1566 );
1567 }
1568
1569 #[test]
1570 fn release_only_diffs_are_recognised() {
1571 assert!(is_release_only(&["Cargo.toml".to_owned()]));
1572 assert!(is_release_only(&[
1573 "Cargo.toml".to_owned(),
1574 "Cargo.lock".to_owned()
1575 ]));
1576 assert!(!is_release_only(&[]));
1577 assert!(!is_release_only(&[
1578 "Cargo.toml".to_owned(),
1579 "src/main.rs".to_owned()
1580 ]));
1581 }
1582
1583 #[test]
1584 fn cargo_version_rewrite_touches_only_the_package_table() {
1585 let toml = "\
1586[package]\n\
1587# a comment mentioning version on purpose\n\
1588name = \"magi-cli\"\n\
1589version = \"0.8.0\"\n\
1590edition = \"2024\"\n\
1591\n\
1592[dependencies]\n\
1593foo = { version = \"1.2.3\" }\n";
1594 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
1595 assert!(out.contains("version = \"0.9.0\""));
1596 assert!(
1597 out.contains("foo = { version = \"1.2.3\" }"),
1598 "a dependency's own version pin must survive: {out}"
1599 );
1600 assert!(
1601 out.contains("# a comment mentioning version on purpose"),
1602 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
1603 );
1604 assert_eq!(
1605 out.lines().count(),
1606 toml.lines().count(),
1607 "the rewrite replaces one line, it does not add or remove any"
1608 );
1609 }
1610
1611 #[test]
1612 fn cargo_version_rewrite_fails_without_a_package_table() {
1613 let toml = "[dependencies]\nfoo = \"1\"\n";
1614 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
1615 }
1616
1617 #[test]
1623 fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
1624 let toml = "\
1625[workspace]\n\
1626members = [\"crates/a\", \"crates/b\"]\n\
1627\n\
1628[workspace.package]\n\
1629version = \"0.45.18\"\n\
1630edition = \"2024\"\n\
1631\n\
1632[workspace.dependencies]\n\
1633foo = { version = \"1.2.3\" }\n";
1634 let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
1635 assert!(out.contains("version = \"0.45.19\""));
1636 assert!(
1637 out.contains("foo = { version = \"1.2.3\" }"),
1638 "a workspace dependency's own version pin must survive: {out}"
1639 );
1640 assert_eq!(
1641 out.lines().count(),
1642 toml.lines().count(),
1643 "the rewrite replaces one line, it does not add or remove any"
1644 );
1645 }
1646
1647 #[test]
1648 fn current_version_prefers_the_package_table_when_both_exist() {
1649 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
1650 assert_eq!(current_version(toml).unwrap(), "0.8.0");
1651 }
1652
1653 #[test]
1656 fn current_version_falls_back_to_workspace_package_without_a_package_table() {
1657 let toml = "\
1658[workspace]\n\
1659members = [\"crates/a\", \"crates/b\"]\n\
1660\n\
1661[workspace.package]\n\
1662version = \"0.45.18\"\n";
1663 assert_eq!(current_version(toml).unwrap(), "0.45.18");
1664 }
1665
1666 #[test]
1667 fn coalesce_proceeds_with_nothing_pending() {
1668 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
1669 }
1670
1671 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
1674 PendingBump {
1675 target_version: target_version.to_owned(),
1676 level,
1677 branch: format!("chore/release-v{target_version}"),
1678 pr_url: "https://example.invalid/pull/9".to_owned(),
1679 }
1680 }
1681
1682 #[test]
1683 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
1684 let pending = test_pending("0.9.0", BumpLevel::Minor);
1685 assert_eq!(
1686 coalesce(Some(&pending), "0.8.0").unwrap(),
1687 Coalesce::Skip {
1688 target_version: "0.9.0".to_owned()
1689 }
1690 );
1691 }
1692
1693 #[test]
1694 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
1695 let pending = test_pending("0.9.0", BumpLevel::Minor);
1696 assert_eq!(
1698 coalesce(Some(&pending), "0.9.0").unwrap(),
1699 Coalesce::Proceed
1700 );
1701 assert_eq!(
1703 coalesce(Some(&pending), "1.0.0").unwrap(),
1704 Coalesce::Proceed
1705 );
1706 }
1707
1708 #[test]
1709 fn pending_action_escalates_only_for_a_more_severe_decision() {
1710 assert_eq!(
1711 pending_action(BumpLevel::Patch, BumpLevel::Patch),
1712 PendingAction::AlreadyCovered
1713 );
1714 assert_eq!(
1715 pending_action(BumpLevel::Patch, BumpLevel::Minor),
1716 PendingAction::Escalate
1717 );
1718 assert_eq!(
1719 pending_action(BumpLevel::Patch, BumpLevel::Major),
1720 PendingAction::Escalate
1721 );
1722 assert_eq!(
1723 pending_action(BumpLevel::Minor, BumpLevel::Patch),
1724 PendingAction::AlreadyCovered
1725 );
1726 assert_eq!(
1727 pending_action(BumpLevel::Major, BumpLevel::Minor),
1728 PendingAction::AlreadyCovered
1729 );
1730 assert_eq!(
1731 pending_action(BumpLevel::Major, BumpLevel::Major),
1732 PendingAction::AlreadyCovered
1733 );
1734 }
1735
1736 #[test]
1737 fn pr_state_parsing_reads_open_and_not_open() {
1738 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
1739 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
1740 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
1741 }
1742
1743 #[test]
1744 fn a_lock_is_exclusive_until_dropped() {
1745 let dir = tempfile::tempdir().unwrap();
1746 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1747 let first = MarkerLock::acquire(&marker)
1748 .unwrap()
1749 .expect("first attempt takes the lock");
1750 assert!(
1751 MarkerLock::acquire(&marker).unwrap().is_none(),
1752 "a second attempt must be refused while the first holds it"
1753 );
1754 drop(first);
1755 assert!(
1756 MarkerLock::acquire(&marker).unwrap().is_some(),
1757 "dropping the guard releases the lock for the next attempt"
1758 );
1759 }
1760
1761 #[test]
1762 fn a_stale_lock_is_reclaimed() {
1763 let dir = tempfile::tempdir().unwrap();
1764 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1765 let lock_path = marker.with_extension("lock");
1766 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1767 std::fs::write(&lock_path, b"").unwrap();
1768 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
1769 std::fs::OpenOptions::new()
1770 .write(true)
1771 .open(&lock_path)
1772 .unwrap()
1773 .set_modified(old)
1774 .unwrap();
1775 assert!(
1776 MarkerLock::acquire(&marker).unwrap().is_some(),
1777 "a lock older than the stale window must be reclaimed rather than block forever"
1778 );
1779 }
1780
1781 #[tokio::test]
1782 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
1783 let dir = tempfile::tempdir().unwrap();
1784 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1785 let held = MarkerLock::acquire(&marker)
1786 .unwrap()
1787 .expect("seed the contention");
1788 let releaser = tokio::spawn(async move {
1789 tokio::time::sleep(Duration::from_millis(20)).await;
1790 drop(held);
1791 });
1792 let waited =
1793 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
1794 .await
1795 .unwrap();
1796 assert!(
1797 waited.is_some(),
1798 "a merge landing behind another's still-running decision must not be dropped - it \
1799 must wait for that decision to finish and then judge against what it left behind"
1800 );
1801 releaser.await.unwrap();
1802 }
1803
1804 #[tokio::test]
1805 async fn a_lock_held_past_the_ceiling_gives_up() {
1806 let dir = tempfile::tempdir().unwrap();
1807 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1808 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
1809 let waited =
1810 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
1811 .await
1812 .unwrap();
1813 assert!(
1814 waited.is_none(),
1815 "a lock genuinely held past the ceiling must eventually give up rather than wait \
1816 forever"
1817 );
1818 }
1819
1820 #[test]
1821 fn level_between_reads_off_the_differing_digit() {
1822 assert_eq!(
1823 level_between(
1824 Version::parse("0.8.0").unwrap(),
1825 Version::parse("1.0.0").unwrap()
1826 ),
1827 Some(BumpLevel::Major)
1828 );
1829 assert_eq!(
1830 level_between(
1831 Version::parse("0.8.0").unwrap(),
1832 Version::parse("0.9.0").unwrap()
1833 ),
1834 Some(BumpLevel::Minor)
1835 );
1836 assert_eq!(
1837 level_between(
1838 Version::parse("0.8.0").unwrap(),
1839 Version::parse("0.8.1").unwrap()
1840 ),
1841 Some(BumpLevel::Patch)
1842 );
1843 assert_eq!(
1844 level_between(
1845 Version::parse("0.8.0").unwrap(),
1846 Version::parse("0.8.0").unwrap()
1847 ),
1848 None
1849 );
1850 }
1851
1852 #[test]
1853 fn open_release_pr_is_found_among_unrelated_pull_requests() {
1854 let json = r#"[
1855 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
1856 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
1857 ]"#;
1858 let found = parse_open_release_pr(json).unwrap();
1859 assert_eq!(
1860 found,
1861 Some((
1862 "chore/release-v0.9.0".to_owned(),
1863 "https://example.invalid/pull/2".to_owned()
1864 ))
1865 );
1866 }
1867
1868 #[test]
1869 fn no_open_release_pr_reads_as_none_not_an_error() {
1870 let json =
1871 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
1872 assert_eq!(parse_open_release_pr(json).unwrap(), None);
1873 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
1874 }
1875
1876 #[test]
1877 fn marker_round_trips_through_disk() {
1878 let dir = tempfile::tempdir().unwrap();
1879 let path = marker_path(dir.path(), Path::new("/repos/magi"));
1880 assert!(read_marker(&path).is_none());
1881
1882 let marker = test_pending("0.9.0", BumpLevel::Patch);
1883 write_marker(&path, &marker).unwrap();
1884 let read_back = read_marker(&path).unwrap();
1885 assert_eq!(read_back.target_version, "0.9.0");
1886 assert_eq!(read_back.level, BumpLevel::Patch);
1887 assert_eq!(read_back.pr_url, marker.pr_url);
1888
1889 clear_marker(&path);
1890 assert!(read_marker(&path).is_none());
1891 }
1892
1893 #[test]
1894 fn different_repos_get_different_marker_files() {
1895 let dir = tempfile::tempdir().unwrap();
1896 let a = marker_path(dir.path(), Path::new("/repos/a"));
1897 let b = marker_path(dir.path(), Path::new("/repos/b"));
1898 assert_ne!(a, b);
1899 }
1900
1901 #[test]
1905 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
1906 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
1907 assert!(
1908 is_release_only(&files),
1909 "the bump pull request's own diff must read as release-only"
1910 );
1911 }
1912
1913 #[test]
1914 fn should_release_bump_reads_only_a_merged_status() {
1915 assert!(should_release_bump(RunStatus::Merged));
1916 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
1917 assert!(!should_release_bump(other));
1918 }
1919 }
1920
1921 #[test]
1925 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
1926 let pr = land::PrState {
1927 url: "https://github.com/o/r/pull/1".to_owned(),
1928 number: 1,
1929 state: PrLifecycle::Merged,
1930 checks: land::Checks::Green,
1931 failing: Vec::new(),
1932 review_comments: Vec::new(),
1933 blocking: land::Blocking::No,
1934 };
1935 assert_eq!(
1936 land::decide(&pr, 0, 4, Duration::ZERO),
1937 land::Step::Done { merged: true }
1938 );
1939 assert!(should_release_bump(RunStatus::Merged));
1940 }
1941
1942 #[test]
1947 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
1948 let pr = land::PrState {
1949 url: "https://github.com/o/r/pull/2".to_owned(),
1950 number: 2,
1951 state: PrLifecycle::Open,
1952 checks: land::Checks::Green,
1953 failing: Vec::new(),
1954 review_comments: Vec::new(),
1955 blocking: land::Blocking::No,
1956 };
1957 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
1958 assert!(should_release_bump(RunStatus::Merged));
1961 }
1962
1963 #[test]
1966 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
1967 let argv = land::merge_argv(3, "feat: something");
1968 let outcome = land::merged_after_all(
1969 &argv,
1970 "could not determine current branch: not on any branch",
1971 Some(PrLifecycle::Merged),
1972 );
1973 assert!(outcome.is_some(), "the forge's confirmation must win");
1974 assert!(should_release_bump(RunStatus::Merged));
1975
1976 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
1979 assert!(land::merged_after_all(&argv, "network error", None).is_none());
1980 }
1981
1982 #[test]
1984 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
1985 let pr = land::PrState {
1986 url: "https://github.com/o/r/pull/4".to_owned(),
1987 number: 4,
1988 state: PrLifecycle::Closed,
1989 checks: land::Checks::Green,
1990 failing: Vec::new(),
1991 review_comments: Vec::new(),
1992 blocking: land::Blocking::No,
1993 };
1994 assert_eq!(
1995 land::decide(&pr, 0, 4, Duration::ZERO),
1996 land::Step::Done { merged: false }
1997 );
1998 assert!(!should_release_bump(RunStatus::Blocked));
1999 }
2000}