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 s
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct PendingBump {
373 pub target_version: String,
375 pub level: BumpLevel,
378 pub branch: String,
381 pub pr_url: String,
384}
385
386pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
390 let key = repo.to_string_lossy();
391 home.join("bump")
392 .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
393}
394
395pub fn read_marker(path: &Path) -> Option<PendingBump> {
399 let body = std::fs::read_to_string(path).ok()?;
400 serde_json::from_str(&body).ok()
401}
402
403pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
407 if let Some(parent) = path.parent() {
408 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
409 }
410 let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
411 let tmp = path.with_extension("json.tmp");
412 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
413 std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
414 Ok(())
415}
416
417pub fn clear_marker(path: &Path) {
420 let _ = std::fs::remove_file(path);
421}
422
423#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum Coalesce {
428 Proceed,
431 Skip {
433 target_version: String,
435 },
436}
437
438pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
440 let Some(pending) = pending else {
441 return Ok(Coalesce::Proceed);
442 };
443 let current = Version::parse(current_version)?;
444 let target = Version::parse(&pending.target_version)?;
445 if current >= target {
446 return Ok(Coalesce::Proceed);
447 }
448 Ok(Coalesce::Skip {
449 target_version: pending.target_version.clone(),
450 })
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
455pub enum PendingAction {
456 AlreadyCovered,
459 Escalate,
462}
463
464pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
473 if decision_level.severity() > pending_level.severity() {
474 PendingAction::Escalate
475 } else {
476 PendingAction::AlreadyCovered
477 }
478}
479
480fn parse_pr_state(json: &str) -> Result<bool> {
482 #[derive(Deserialize)]
483 struct State {
484 state: String,
485 }
486 let parsed: State =
487 serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
488 Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
489}
490
491async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
504 let out = tokio::process::Command::new("gh")
505 .args(["pr", "view", pr_url, "--json", "state"])
506 .current_dir(repo)
507 .quiet()
508 .stdin(std::process::Stdio::null())
509 .output()
510 .await
511 .context("spawn gh pr view")?;
512 if !out.status.success() {
513 bail!(
514 "gh pr view {pr_url}: {}",
515 String::from_utf8_lossy(&out.stderr).trim()
516 );
517 }
518 parse_pr_state(&String::from_utf8_lossy(&out.stdout))
519}
520
521const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
529
530struct MarkerLock {
542 path: PathBuf,
543}
544
545impl MarkerLock {
546 fn acquire(marker: &Path) -> Result<Option<Self>> {
550 let path = marker.with_extension("lock");
551 if let Some(parent) = path.parent() {
552 std::fs::create_dir_all(parent)
553 .with_context(|| format!("create {}", parent.display()))?;
554 }
555 if Self::try_create(&path)? {
556 return Ok(Some(Self { path }));
557 }
558 if Self::is_stale(&path) {
559 let _ = std::fs::remove_file(&path);
560 if Self::try_create(&path)? {
561 return Ok(Some(Self { path }));
562 }
563 }
564 Ok(None)
565 }
566
567 fn try_create(path: &Path) -> Result<bool> {
568 match std::fs::OpenOptions::new()
569 .write(true)
570 .create_new(true)
571 .open(path)
572 {
573 Ok(_) => Ok(true),
574 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
575 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
576 }
577 }
578
579 fn is_stale(path: &Path) -> bool {
580 std::fs::metadata(path)
581 .and_then(|m| m.modified())
582 .ok()
583 .and_then(|m| m.elapsed().ok())
584 .is_some_and(|age| age >= LOCK_STALE_AFTER)
585 }
586}
587
588impl Drop for MarkerLock {
589 fn drop(&mut self) {
590 let _ = std::fs::remove_file(&self.path);
591 }
592}
593
594const LOCK_POLL: Duration = Duration::from_secs(5);
596
597const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
610
611async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
614 wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
615}
616
617async fn wait_for_marker_lock_with(
621 marker: &Path,
622 poll: Duration,
623 ceiling: Duration,
624) -> Result<Option<MarkerLock>> {
625 let mut waited = Duration::ZERO;
626 loop {
627 if let Some(lock) = MarkerLock::acquire(marker)? {
628 return Ok(Some(lock));
629 }
630 if waited >= ceiling {
631 return Ok(None);
632 }
633 tokio::time::sleep(poll).await;
634 waited += poll;
635 }
636}
637
638fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
646 if to.major != from.major {
647 Some(BumpLevel::Major)
648 } else if to.minor != from.minor {
649 Some(BumpLevel::Minor)
650 } else if to.patch != from.patch {
651 Some(BumpLevel::Patch)
652 } else {
653 None
654 }
655}
656
657fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
660 #[derive(Deserialize)]
661 struct Pr {
662 url: String,
663 #[serde(rename = "headRefName")]
664 head_ref_name: String,
665 }
666 let list: Vec<Pr> =
667 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
668 Ok(list
669 .into_iter()
670 .find(|p| p.head_ref_name.starts_with("chore/release-v"))
671 .map(|p| (p.head_ref_name, p.url)))
672}
673
674async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
689 let out = tokio::process::Command::new("gh")
690 .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
691 .current_dir(repo)
692 .quiet()
693 .stdin(std::process::Stdio::null())
694 .output()
695 .await
696 .context("spawn gh pr list")?;
697 if !out.status.success() {
698 bail!(
699 "gh pr list: {}",
700 String::from_utf8_lossy(&out.stderr).trim()
701 );
702 }
703 parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
704}
705
706pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
715 if !state.config.merge.release_bump {
716 return Ok(());
717 }
718 let Some(winner) = state.winner().cloned() else {
719 return Ok(());
720 };
721 let repo = state.repo.clone();
722 let base = state.base_branch.clone();
723 let remote = state.config.merge.remote.clone();
724
725 let files = git::changed_files(&winner.worktree, &base, &winner.branch)
726 .await
727 .unwrap_or_default();
728 if is_release_only(&files) {
729 state.event(
730 "bump",
731 "the merged change touches only the release manifest; not treating it as a trigger",
732 );
733 return Ok(());
734 }
735
736 let marker = marker_path(&run::home(), &repo);
737 let Some(_lock) = wait_for_marker_lock(&marker).await? else {
744 state.event(
745 "bump",
746 "another release bump decision held the lock past the wait ceiling; skipping this round",
747 );
748 return Ok(());
749 };
750
751 git::fetch(&repo, &remote, &base).await.ok();
752 let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
753 .await
754 .context("read Cargo.toml from the base branch")?;
755 let base_version = current_version(&cargo_toml)?;
756
757 let mut pending = read_marker(&marker);
758 if let Some(p) = &pending {
759 match coalesce(Some(p), &base_version)? {
760 Coalesce::Proceed => {
761 clear_marker(&marker);
764 pending = None;
765 }
766 Coalesce::Skip { target_version } => {
767 if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
768 state.event(
769 "bump",
770 format!(
771 "the pending release bump to v{target_version} ({}) is no longer \
772 open; treating it as abandoned",
773 p.pr_url
774 ),
775 );
776 clear_marker(&marker);
777 pending = None;
778 }
779 }
784 }
785 }
786
787 if pending.is_none() {
788 if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
793 && let Some(target) = branch
794 .strip_prefix("chore/release-v")
795 .and_then(|v| Version::parse(v).ok())
796 {
797 let base_parsed = Version::parse(&base_version)?;
798 if target > base_parsed
799 && let Some(level) = level_between(base_parsed, target)
800 {
801 let adopted = PendingBump {
802 target_version: target.to_string(),
803 level,
804 branch,
805 pr_url: url,
806 };
807 let _ = write_marker(&marker, &adopted);
810 pending = Some(adopted);
811 }
812 }
813 }
814
815 let title = pr_title(&repo, pr_url).await.unwrap_or_default();
816 let subject = land::merge_subject(&title, &state.instruction);
817 let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
818 .await
819 .unwrap_or_default();
820 let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
821
822 let spec: AgentSpec = agent::pick(
828 &state.config.agents,
829 state.config.roles.chatter.as_deref(),
830 &agent::installed,
831 )
832 .context("choose an agent for the release-bump decision")?;
833 let mut seat = SeatState::new("bump", &spec.id, state.seed);
834 let artifacts = agent::artifacts_dir(&state.dir());
835 let out = agent::invoke(
836 &spec,
837 &mut seat,
838 &Invocation {
839 cwd: &repo,
840 prompt: &prompt,
841 timeout: DECISION_TIMEOUT,
842 allow_write: false,
845 sessions: false,
846 artifacts: &artifacts,
847 stem: "bump-decision",
848 run: &state.id,
849 node: "bump",
850 cache_dir: state.config.cache_dir().as_deref(),
851 attachments: &[],
852 },
853 )
854 .await
855 .context("ask an agent how big the merged change was")?;
856 if !out.usable() {
857 bail!(
858 "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
859 out.exit_code,
860 out.timed_out
861 );
862 }
863 let decision = parse_decision(&out.text).context("parse the release-bump decision")?;
864
865 if let Some(p) = pending {
866 return match pending_action(p.level, decision.level) {
867 PendingAction::AlreadyCovered => {
868 state.event(
869 "bump",
870 format!(
871 "a release bump to v{} ({}) already covers at least a {} change; not \
872 opening another",
873 p.target_version,
874 p.pr_url,
875 decision.level.as_str()
876 ),
877 );
878 Ok(())
879 }
880 PendingAction::Escalate => {
881 escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
882 }
883 };
884 }
885
886 let next = Version::parse(&base_version)?
887 .bump(decision.level)
888 .to_string();
889 let branch = format!("chore/release-v{next}");
890 let worktree = state.dir().join("bump");
891 git::worktree_remove(&repo, &worktree).await.ok();
892 git::worktree_add_branch(&repo, &worktree, &branch, &format!("{remote}/{base}"))
893 .await
894 .context("create the release-bump worktree")?;
895 let opened = open_bump_pr(state, &worktree, &branch, &next, &decision, pr_url).await;
896 git::worktree_remove(&repo, &worktree).await.ok();
900 let (pr_url_opened, automerge_warning) = opened?;
901
902 let marker_write = write_marker(
911 &marker,
912 &PendingBump {
913 target_version: next.clone(),
914 level: decision.level,
915 branch,
916 pr_url: pr_url_opened.clone(),
917 },
918 );
919 state.event(
920 "bump",
921 format!(
922 "opened a {} release bump to v{next} ({}): {pr_url_opened}",
923 decision.level.as_str(),
924 decision.reason
925 ),
926 );
927 if let Err(e) = marker_write {
928 state.event(
929 "bump",
930 format!(
931 "could not record the pending release bump marker for v{next}: {e:#}; a later \
932 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
933 the forge either"
934 ),
935 );
936 }
937 if let Some(warning) = automerge_warning {
938 state.event(
939 "bump",
940 format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
941 );
942 }
943 Ok(())
944}
945
946async fn escalate_pending(
955 state: &mut RunState,
956 repo: &Path,
957 remote: &str,
958 pending: &PendingBump,
959 decision: &BumpDecision,
960 base_version: &str,
961 marker: &Path,
962) -> Result<()> {
963 let next = Version::parse(base_version)?
964 .bump(decision.level)
965 .to_string();
966 let worktree = state.dir().join("bump");
967 git::worktree_remove(repo, &worktree).await.ok();
968 let checked_out = git::git_raw(
969 repo,
970 &[
971 "worktree",
972 "add",
973 "--force",
974 &worktree.to_string_lossy(),
975 &pending.branch,
976 ],
977 )
978 .await?;
979 if !checked_out.ok() {
980 bail!(
981 "checking out the pending release branch {} failed: {}",
982 pending.branch,
983 checked_out.stderr
984 );
985 }
986
987 let pushed: Result<()> = async {
992 let cargo_toml_path = worktree.join("Cargo.toml");
993 let toml = tokio::fs::read_to_string(&cargo_toml_path)
994 .await
995 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
996 let rewritten = rewrite_cargo_version(&toml, &next)?;
997 tokio::fs::write(&cargo_toml_path, rewritten)
998 .await
999 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1000 sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1001 let committed = git::commit_all(
1002 &worktree,
1003 &format!(
1004 "chore: release v{next} (supersedes v{})",
1005 pending.target_version
1006 ),
1007 )
1008 .await
1009 .context("commit the escalated version bump")?;
1010 if !committed {
1011 bail!("escalating the version bump left nothing to commit");
1012 }
1013 let pushed = git::push(&worktree, remote, &pending.branch).await?;
1014 if !pushed.ok() {
1015 bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1016 }
1017 Ok(())
1018 }
1019 .await;
1020 if let Err(e) = pushed {
1021 git::worktree_remove(repo, &worktree).await.ok();
1022 return Err(e);
1023 }
1024
1025 let title_warning = match gh_pr_edit_title(
1029 &worktree,
1030 &pending.pr_url,
1031 &format!("chore: release v{next}"),
1032 )
1033 .await
1034 {
1035 Ok(()) => None,
1036 Err(e) => Some(e.to_string()),
1037 };
1038 git::worktree_remove(repo, &worktree).await.ok();
1039
1040 let marker_write = write_marker(
1041 marker,
1042 &PendingBump {
1043 target_version: next.clone(),
1044 level: decision.level,
1045 branch: pending.branch.clone(),
1046 pr_url: pending.pr_url.clone(),
1047 },
1048 );
1049 state.event(
1050 "bump",
1051 format!(
1052 "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1053 pending.target_version,
1054 decision.level.as_str(),
1055 decision.reason,
1056 pending.pr_url
1057 ),
1058 );
1059 if let Err(e) = marker_write {
1060 state.event(
1061 "bump",
1062 format!(
1063 "could not update the pending release bump marker to v{next}: {e:#}; a later \
1064 merge may misjudge whether it is already covered"
1065 ),
1066 );
1067 }
1068 if let Some(warning) = title_warning {
1069 state.event(
1070 "bump",
1071 format!(
1072 "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1073 subject may still read the superseded version",
1074 pending.pr_url
1075 ),
1076 );
1077 }
1078 Ok(())
1079}
1080
1081async fn open_bump_pr(
1087 state: &RunState,
1088 worktree: &Path,
1089 branch: &str,
1090 next_version: &str,
1091 decision: &BumpDecision,
1092 source_pr_url: &str,
1093) -> Result<(String, Option<String>)> {
1094 let cargo_toml_path = worktree.join("Cargo.toml");
1095 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1096 .await
1097 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1098 let rewritten = rewrite_cargo_version(&toml, next_version)?;
1099 tokio::fs::write(&cargo_toml_path, rewritten)
1100 .await
1101 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1102
1103 sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
1104
1105 let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
1106 .await
1107 .context("commit the version bump")?;
1108 if !committed {
1109 bail!("the version bump left nothing to commit");
1110 }
1111
1112 let remote = state.config.merge.remote.clone();
1113 let pushed = git::push(worktree, &remote, branch).await?;
1114 if !pushed.ok() {
1115 bail!("pushing {branch} failed: {}", pushed.stderr);
1116 }
1117
1118 let title = format!("chore: release v{next_version}");
1119 let body = format!(
1120 "Release bump: `{}` to `v{next_version}`.\n\n{}\n\n\
1121 Triggered by run `{}`, which landed {source_pr_url}.\n\n\
1122 version-bump-only; nothing here needs a review \
1123 (AGENTS.md: \"Version-bump-only pull requests\").",
1124 decision.level.as_str(),
1125 decision.reason,
1126 state.id,
1127 );
1128 let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
1129 let automerge_warning = match gh_enable_automerge(worktree, &url).await {
1130 Ok(()) => None,
1131 Err(e) => Some(e.to_string()),
1132 };
1133 Ok((url, automerge_warning))
1134}
1135
1136async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
1144 let mut cmd = tokio::process::Command::new("cargo");
1145 cmd.arg("build").current_dir(worktree).quiet();
1146 if let Some(dir) = cache_dir {
1147 cmd.env("CARGO_TARGET_DIR", dir);
1148 }
1149 let out = cmd
1150 .stdin(std::process::Stdio::null())
1151 .output()
1152 .await
1153 .context("spawn cargo build")?;
1154 if !out.status.success() {
1155 bail!(
1156 "cargo build failed while syncing Cargo.lock: {}",
1157 String::from_utf8_lossy(&out.stderr).trim()
1158 );
1159 }
1160 Ok(())
1161}
1162
1163async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
1165 let out = tokio::process::Command::new("gh")
1166 .args(["pr", "view", pr_url, "--json", "title"])
1167 .current_dir(repo)
1168 .quiet()
1169 .stdin(std::process::Stdio::null())
1170 .output()
1171 .await
1172 .context("spawn gh pr view")?;
1173 if !out.status.success() {
1174 bail!(
1175 "gh pr view {pr_url}: {}",
1176 String::from_utf8_lossy(&out.stderr).trim()
1177 );
1178 }
1179 #[derive(Deserialize)]
1180 struct Title {
1181 title: String,
1182 }
1183 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
1184 .context("parse `gh pr view --json title` output")?;
1185 Ok(parsed.title)
1186}
1187
1188async fn gh_pr_create(
1189 cwd: &Path,
1190 base: &str,
1191 head: &str,
1192 title: &str,
1193 body: &str,
1194) -> Result<String> {
1195 let out = tokio::process::Command::new("gh")
1196 .args([
1197 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
1198 ])
1199 .current_dir(cwd)
1200 .quiet()
1201 .stdin(std::process::Stdio::null())
1202 .output()
1203 .await
1204 .context("spawn gh pr create")?;
1205 if out.status.success() {
1206 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
1207 } else {
1208 bail!(
1209 "gh pr create: {}",
1210 String::from_utf8_lossy(&out.stderr).trim()
1211 )
1212 }
1213}
1214
1215async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
1219 let out = tokio::process::Command::new("gh")
1220 .args([
1221 "pr",
1222 "merge",
1223 pr_url,
1224 "--auto",
1225 "--squash",
1226 "--delete-branch",
1227 ])
1228 .current_dir(cwd)
1229 .quiet()
1230 .stdin(std::process::Stdio::null())
1231 .output()
1232 .await
1233 .context("spawn gh pr merge --auto")?;
1234 if out.status.success() {
1235 Ok(())
1236 } else {
1237 bail!(
1238 "gh pr merge --auto: {}",
1239 String::from_utf8_lossy(&out.stderr).trim()
1240 )
1241 }
1242}
1243
1244async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
1248 let out = tokio::process::Command::new("gh")
1249 .args(["pr", "edit", pr_url, "--title", title])
1250 .current_dir(cwd)
1251 .quiet()
1252 .stdin(std::process::Stdio::null())
1253 .output()
1254 .await
1255 .context("spawn gh pr edit")?;
1256 if out.status.success() {
1257 Ok(())
1258 } else {
1259 bail!(
1260 "gh pr edit --title: {}",
1261 String::from_utf8_lossy(&out.stderr).trim()
1262 )
1263 }
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268 use super::*;
1269 use crate::config::Config;
1270 use crate::land::PrLifecycle;
1271
1272 #[tokio::test]
1278 async fn a_disabled_config_does_nothing() {
1279 let config = Config {
1280 merge: crate::config::Merge {
1281 release_bump: false,
1282 ..crate::config::Merge::default()
1283 },
1284 ..Config::default()
1285 };
1286 let mut state = RunState::new(
1287 PathBuf::from("/no/such/repo"),
1288 "main".to_owned(),
1289 "0000000000000000000000000000000000000000".to_owned(),
1290 "irrelevant".to_owned(),
1291 config,
1292 );
1293 after_merge(&mut state, "https://example.invalid/pull/1")
1294 .await
1295 .expect("a disabled config must return Ok without touching anything");
1296 assert!(
1297 state.events.is_empty(),
1298 "nothing should happen at all, not even a logged event"
1299 );
1300 }
1301
1302 #[test]
1303 fn version_parses_and_bumps_each_digit() {
1304 let v = Version::parse("0.4.0").unwrap();
1305 assert_eq!(
1306 v,
1307 Version {
1308 major: 0,
1309 minor: 4,
1310 patch: 0
1311 }
1312 );
1313
1314 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
1315 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
1316 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
1317 }
1318
1319 #[test]
1320 fn version_tolerates_a_prerelease_suffix_on_patch() {
1321 let v = Version::parse("1.2.3-rc1").unwrap();
1322 assert_eq!(
1323 v,
1324 Version {
1325 major: 1,
1326 minor: 2,
1327 patch: 3
1328 }
1329 );
1330 }
1331
1332 #[test]
1333 fn version_rejects_garbage() {
1334 assert!(Version::parse("not-a-version").is_err());
1335 assert!(Version::parse("1.2").is_err());
1336 }
1337
1338 #[test]
1339 fn decision_parses_each_level() {
1340 for (json, level) in [
1341 (
1342 r#"{"level":"major","reason":"drops a config key"}"#,
1343 BumpLevel::Major,
1344 ),
1345 (
1346 r#"{"level":"minor","reason":"adds a new flag"}"#,
1347 BumpLevel::Minor,
1348 ),
1349 (
1350 r#"{"level":"patch","reason":"fixes a race"}"#,
1351 BumpLevel::Patch,
1352 ),
1353 ] {
1354 let decision = parse_decision(json).unwrap();
1355 assert_eq!(decision.level, level);
1356 assert!(!decision.reason.is_empty());
1357 }
1358 }
1359
1360 #[test]
1361 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
1362 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
1363 let decision = parse_decision(text).unwrap();
1364 assert_eq!(decision.level, BumpLevel::Minor);
1365 assert_eq!(decision.reason, "new HTTP route");
1366 }
1367
1368 #[test]
1369 fn a_broken_reply_is_an_error_not_a_default() {
1370 assert!(parse_decision("I decline to answer.").is_err());
1371 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
1372 assert!(
1373 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
1374 "an empty reason must not pass either"
1375 );
1376 assert!(
1377 parse_decision(r#"{"level":"patch"}"#).is_err(),
1378 "a reply with no reason at all must not pass"
1379 );
1380 }
1381
1382 #[test]
1383 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
1384 let prompt = decision_prompt(
1385 "feat: add a phone endpoint",
1386 "add POST /api/widgets",
1387 "1 file changed, 10 insertions(+)",
1388 &["src/web.rs".to_owned()],
1389 "0.8.0",
1390 );
1391 assert!(prompt.contains("0.8.0"), "the current version is stated");
1392 assert!(
1393 prompt.contains("below `1.0.0`")
1394 && prompt.contains("`minor` is the digit that carries a breaking change"),
1395 "the 0.x rule must be explicit: {prompt}"
1396 );
1397 assert!(
1398 prompt.contains("choose the larger"),
1399 "the tie-break toward the bigger digit must be explicit: {prompt}"
1400 );
1401 }
1402
1403 #[test]
1404 fn release_only_diffs_are_recognised() {
1405 assert!(is_release_only(&["Cargo.toml".to_owned()]));
1406 assert!(is_release_only(&[
1407 "Cargo.toml".to_owned(),
1408 "Cargo.lock".to_owned()
1409 ]));
1410 assert!(!is_release_only(&[]));
1411 assert!(!is_release_only(&[
1412 "Cargo.toml".to_owned(),
1413 "src/main.rs".to_owned()
1414 ]));
1415 }
1416
1417 #[test]
1418 fn cargo_version_rewrite_touches_only_the_package_table() {
1419 let toml = "\
1420[package]\n\
1421# a comment mentioning version on purpose\n\
1422name = \"magi-cli\"\n\
1423version = \"0.8.0\"\n\
1424edition = \"2024\"\n\
1425\n\
1426[dependencies]\n\
1427foo = { version = \"1.2.3\" }\n";
1428 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
1429 assert!(out.contains("version = \"0.9.0\""));
1430 assert!(
1431 out.contains("foo = { version = \"1.2.3\" }"),
1432 "a dependency's own version pin must survive: {out}"
1433 );
1434 assert!(
1435 out.contains("# a comment mentioning version on purpose"),
1436 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
1437 );
1438 assert_eq!(
1439 out.lines().count(),
1440 toml.lines().count(),
1441 "the rewrite replaces one line, it does not add or remove any"
1442 );
1443 }
1444
1445 #[test]
1446 fn cargo_version_rewrite_fails_without_a_package_table() {
1447 let toml = "[dependencies]\nfoo = \"1\"\n";
1448 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
1449 }
1450
1451 #[test]
1457 fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
1458 let toml = "\
1459[workspace]\n\
1460members = [\"crates/a\", \"crates/b\"]\n\
1461\n\
1462[workspace.package]\n\
1463version = \"0.45.18\"\n\
1464edition = \"2024\"\n\
1465\n\
1466[workspace.dependencies]\n\
1467foo = { version = \"1.2.3\" }\n";
1468 let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
1469 assert!(out.contains("version = \"0.45.19\""));
1470 assert!(
1471 out.contains("foo = { version = \"1.2.3\" }"),
1472 "a workspace dependency's own version pin must survive: {out}"
1473 );
1474 assert_eq!(
1475 out.lines().count(),
1476 toml.lines().count(),
1477 "the rewrite replaces one line, it does not add or remove any"
1478 );
1479 }
1480
1481 #[test]
1482 fn current_version_prefers_the_package_table_when_both_exist() {
1483 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
1484 assert_eq!(current_version(toml).unwrap(), "0.8.0");
1485 }
1486
1487 #[test]
1490 fn current_version_falls_back_to_workspace_package_without_a_package_table() {
1491 let toml = "\
1492[workspace]\n\
1493members = [\"crates/a\", \"crates/b\"]\n\
1494\n\
1495[workspace.package]\n\
1496version = \"0.45.18\"\n";
1497 assert_eq!(current_version(toml).unwrap(), "0.45.18");
1498 }
1499
1500 #[test]
1501 fn coalesce_proceeds_with_nothing_pending() {
1502 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
1503 }
1504
1505 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
1508 PendingBump {
1509 target_version: target_version.to_owned(),
1510 level,
1511 branch: format!("chore/release-v{target_version}"),
1512 pr_url: "https://example.invalid/pull/9".to_owned(),
1513 }
1514 }
1515
1516 #[test]
1517 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
1518 let pending = test_pending("0.9.0", BumpLevel::Minor);
1519 assert_eq!(
1520 coalesce(Some(&pending), "0.8.0").unwrap(),
1521 Coalesce::Skip {
1522 target_version: "0.9.0".to_owned()
1523 }
1524 );
1525 }
1526
1527 #[test]
1528 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
1529 let pending = test_pending("0.9.0", BumpLevel::Minor);
1530 assert_eq!(
1532 coalesce(Some(&pending), "0.9.0").unwrap(),
1533 Coalesce::Proceed
1534 );
1535 assert_eq!(
1537 coalesce(Some(&pending), "1.0.0").unwrap(),
1538 Coalesce::Proceed
1539 );
1540 }
1541
1542 #[test]
1543 fn pending_action_escalates_only_for_a_more_severe_decision() {
1544 assert_eq!(
1545 pending_action(BumpLevel::Patch, BumpLevel::Patch),
1546 PendingAction::AlreadyCovered
1547 );
1548 assert_eq!(
1549 pending_action(BumpLevel::Patch, BumpLevel::Minor),
1550 PendingAction::Escalate
1551 );
1552 assert_eq!(
1553 pending_action(BumpLevel::Patch, BumpLevel::Major),
1554 PendingAction::Escalate
1555 );
1556 assert_eq!(
1557 pending_action(BumpLevel::Minor, BumpLevel::Patch),
1558 PendingAction::AlreadyCovered
1559 );
1560 assert_eq!(
1561 pending_action(BumpLevel::Major, BumpLevel::Minor),
1562 PendingAction::AlreadyCovered
1563 );
1564 assert_eq!(
1565 pending_action(BumpLevel::Major, BumpLevel::Major),
1566 PendingAction::AlreadyCovered
1567 );
1568 }
1569
1570 #[test]
1571 fn pr_state_parsing_reads_open_and_not_open() {
1572 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
1573 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
1574 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
1575 }
1576
1577 #[test]
1578 fn a_lock_is_exclusive_until_dropped() {
1579 let dir = tempfile::tempdir().unwrap();
1580 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1581 let first = MarkerLock::acquire(&marker)
1582 .unwrap()
1583 .expect("first attempt takes the lock");
1584 assert!(
1585 MarkerLock::acquire(&marker).unwrap().is_none(),
1586 "a second attempt must be refused while the first holds it"
1587 );
1588 drop(first);
1589 assert!(
1590 MarkerLock::acquire(&marker).unwrap().is_some(),
1591 "dropping the guard releases the lock for the next attempt"
1592 );
1593 }
1594
1595 #[test]
1596 fn a_stale_lock_is_reclaimed() {
1597 let dir = tempfile::tempdir().unwrap();
1598 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1599 let lock_path = marker.with_extension("lock");
1600 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1601 std::fs::write(&lock_path, b"").unwrap();
1602 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
1603 std::fs::OpenOptions::new()
1604 .write(true)
1605 .open(&lock_path)
1606 .unwrap()
1607 .set_modified(old)
1608 .unwrap();
1609 assert!(
1610 MarkerLock::acquire(&marker).unwrap().is_some(),
1611 "a lock older than the stale window must be reclaimed rather than block forever"
1612 );
1613 }
1614
1615 #[tokio::test]
1616 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
1617 let dir = tempfile::tempdir().unwrap();
1618 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1619 let held = MarkerLock::acquire(&marker)
1620 .unwrap()
1621 .expect("seed the contention");
1622 let releaser = tokio::spawn(async move {
1623 tokio::time::sleep(Duration::from_millis(20)).await;
1624 drop(held);
1625 });
1626 let waited =
1627 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
1628 .await
1629 .unwrap();
1630 assert!(
1631 waited.is_some(),
1632 "a merge landing behind another's still-running decision must not be dropped - it \
1633 must wait for that decision to finish and then judge against what it left behind"
1634 );
1635 releaser.await.unwrap();
1636 }
1637
1638 #[tokio::test]
1639 async fn a_lock_held_past_the_ceiling_gives_up() {
1640 let dir = tempfile::tempdir().unwrap();
1641 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1642 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
1643 let waited =
1644 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
1645 .await
1646 .unwrap();
1647 assert!(
1648 waited.is_none(),
1649 "a lock genuinely held past the ceiling must eventually give up rather than wait \
1650 forever"
1651 );
1652 }
1653
1654 #[test]
1655 fn level_between_reads_off_the_differing_digit() {
1656 assert_eq!(
1657 level_between(
1658 Version::parse("0.8.0").unwrap(),
1659 Version::parse("1.0.0").unwrap()
1660 ),
1661 Some(BumpLevel::Major)
1662 );
1663 assert_eq!(
1664 level_between(
1665 Version::parse("0.8.0").unwrap(),
1666 Version::parse("0.9.0").unwrap()
1667 ),
1668 Some(BumpLevel::Minor)
1669 );
1670 assert_eq!(
1671 level_between(
1672 Version::parse("0.8.0").unwrap(),
1673 Version::parse("0.8.1").unwrap()
1674 ),
1675 Some(BumpLevel::Patch)
1676 );
1677 assert_eq!(
1678 level_between(
1679 Version::parse("0.8.0").unwrap(),
1680 Version::parse("0.8.0").unwrap()
1681 ),
1682 None
1683 );
1684 }
1685
1686 #[test]
1687 fn open_release_pr_is_found_among_unrelated_pull_requests() {
1688 let json = r#"[
1689 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
1690 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
1691 ]"#;
1692 let found = parse_open_release_pr(json).unwrap();
1693 assert_eq!(
1694 found,
1695 Some((
1696 "chore/release-v0.9.0".to_owned(),
1697 "https://example.invalid/pull/2".to_owned()
1698 ))
1699 );
1700 }
1701
1702 #[test]
1703 fn no_open_release_pr_reads_as_none_not_an_error() {
1704 let json =
1705 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
1706 assert_eq!(parse_open_release_pr(json).unwrap(), None);
1707 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
1708 }
1709
1710 #[test]
1711 fn marker_round_trips_through_disk() {
1712 let dir = tempfile::tempdir().unwrap();
1713 let path = marker_path(dir.path(), Path::new("/repos/magi"));
1714 assert!(read_marker(&path).is_none());
1715
1716 let marker = test_pending("0.9.0", BumpLevel::Patch);
1717 write_marker(&path, &marker).unwrap();
1718 let read_back = read_marker(&path).unwrap();
1719 assert_eq!(read_back.target_version, "0.9.0");
1720 assert_eq!(read_back.level, BumpLevel::Patch);
1721 assert_eq!(read_back.pr_url, marker.pr_url);
1722
1723 clear_marker(&path);
1724 assert!(read_marker(&path).is_none());
1725 }
1726
1727 #[test]
1728 fn different_repos_get_different_marker_files() {
1729 let dir = tempfile::tempdir().unwrap();
1730 let a = marker_path(dir.path(), Path::new("/repos/a"));
1731 let b = marker_path(dir.path(), Path::new("/repos/b"));
1732 assert_ne!(a, b);
1733 }
1734
1735 #[test]
1739 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
1740 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
1741 assert!(
1742 is_release_only(&files),
1743 "the bump pull request's own diff must read as release-only"
1744 );
1745 }
1746
1747 #[test]
1748 fn should_release_bump_reads_only_a_merged_status() {
1749 assert!(should_release_bump(RunStatus::Merged));
1750 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
1751 assert!(!should_release_bump(other));
1752 }
1753 }
1754
1755 #[test]
1759 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
1760 let pr = land::PrState {
1761 url: "https://github.com/o/r/pull/1".to_owned(),
1762 number: 1,
1763 state: PrLifecycle::Merged,
1764 checks: land::Checks::Green,
1765 failing: Vec::new(),
1766 review_comments: Vec::new(),
1767 blocking: land::Blocking::No,
1768 };
1769 assert_eq!(
1770 land::decide(&pr, 0, 4, Duration::ZERO),
1771 land::Step::Done { merged: true }
1772 );
1773 assert!(should_release_bump(RunStatus::Merged));
1774 }
1775
1776 #[test]
1781 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
1782 let pr = land::PrState {
1783 url: "https://github.com/o/r/pull/2".to_owned(),
1784 number: 2,
1785 state: PrLifecycle::Open,
1786 checks: land::Checks::Green,
1787 failing: Vec::new(),
1788 review_comments: Vec::new(),
1789 blocking: land::Blocking::No,
1790 };
1791 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
1792 assert!(should_release_bump(RunStatus::Merged));
1795 }
1796
1797 #[test]
1800 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
1801 let argv = land::merge_argv(3, "feat: something");
1802 let outcome = land::merged_after_all(
1803 &argv,
1804 "could not determine current branch: not on any branch",
1805 Some(PrLifecycle::Merged),
1806 );
1807 assert!(outcome.is_some(), "the forge's confirmation must win");
1808 assert!(should_release_bump(RunStatus::Merged));
1809
1810 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
1813 assert!(land::merged_after_all(&argv, "network error", None).is_none());
1814 }
1815
1816 #[test]
1818 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
1819 let pr = land::PrState {
1820 url: "https://github.com/o/r/pull/4".to_owned(),
1821 number: 4,
1822 state: PrLifecycle::Closed,
1823 checks: land::Checks::Green,
1824 failing: Vec::new(),
1825 review_comments: Vec::new(),
1826 blocking: land::Blocking::No,
1827 };
1828 assert_eq!(
1829 land::decide(&pr, 0, 4, Duration::ZERO),
1830 land::Step::Done { merged: false }
1831 );
1832 assert!(!should_release_bump(RunStatus::Blocked));
1833 }
1834}