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 if !state.config.merge.release_bump {
723 return Ok(());
724 }
725 let Some(winner) = state.winner().cloned() else {
726 return Ok(());
727 };
728 let repo = state.repo.clone();
729 let base = state.base_branch.clone();
730 let remote = state.config.merge.remote.clone();
731
732 let files = git::changed_files(&winner.worktree, &base, &winner.branch)
733 .await
734 .unwrap_or_default();
735 if is_release_only(&files) {
736 state.event(
737 "bump",
738 "the merged change touches only the release manifest; not treating it as a trigger",
739 );
740 return Ok(());
741 }
742
743 let marker = marker_path(&run::home(), &repo);
744 let Some(_lock) = wait_for_marker_lock(&marker).await? else {
751 state.event(
752 "bump",
753 "another release bump decision held the lock past the wait ceiling; skipping this round",
754 );
755 return Ok(());
756 };
757
758 git::fetch(&repo, &remote, &base).await.ok();
759 let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
760 .await
761 .context("read Cargo.toml from the base branch")?;
762 let base_version = current_version(&cargo_toml)?;
763
764 let mut pending = read_marker(&marker);
765 if let Some(p) = &pending {
766 match coalesce(Some(p), &base_version)? {
767 Coalesce::Proceed => {
768 clear_marker(&marker);
771 pending = None;
772 }
773 Coalesce::Skip { target_version } => {
774 if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
775 state.event(
776 "bump",
777 format!(
778 "the pending release bump to v{target_version} ({}) is no longer \
779 open; treating it as abandoned",
780 p.pr_url
781 ),
782 );
783 clear_marker(&marker);
784 pending = None;
785 }
786 }
791 }
792 }
793
794 if pending.is_none() {
795 if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
800 && let Some(target) = branch
801 .strip_prefix("chore/release-v")
802 .and_then(|v| Version::parse(v).ok())
803 {
804 let base_parsed = Version::parse(&base_version)?;
805 if target > base_parsed
806 && let Some(level) = level_between(base_parsed, target)
807 {
808 let adopted = PendingBump {
809 target_version: target.to_string(),
810 level,
811 branch,
812 pr_url: url,
813 };
814 let _ = write_marker(&marker, &adopted);
817 pending = Some(adopted);
818 }
819 }
820 }
821
822 let title = pr_title(&repo, pr_url).await.unwrap_or_default();
823 let subject = land::merge_subject(&title, &state.instruction);
824 let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
825 .await
826 .unwrap_or_default();
827 let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
828
829 let spec: AgentSpec = agent::pick(
835 &state.config.agents,
836 state.config.roles.chatter.as_deref(),
837 &agent::installed,
838 )
839 .context("choose an agent for the release-bump decision")?;
840 let mut seat = SeatState::new("bump", &spec.id, state.seed);
841 let artifacts = agent::artifacts_dir(&state.dir());
842 let out = agent::invoke(
843 &spec,
844 &mut seat,
845 &Invocation {
846 cwd: &repo,
847 prompt: &prompt,
848 timeout: DECISION_TIMEOUT,
849 allow_write: false,
852 sessions: false,
853 artifacts: &artifacts,
854 stem: "bump-decision",
855 run: &state.id,
856 node: "bump",
857 cache_dir: state.config.cache_dir().as_deref(),
858 attachments: &[],
859 },
860 )
861 .await
862 .context("ask an agent how big the merged change was")?;
863 if !out.usable() {
864 bail!(
865 "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
866 out.exit_code,
867 out.timed_out
868 );
869 }
870 let decision = parse_decision(&out.text).context("parse the release-bump decision")?;
871
872 if let Some(p) = pending {
873 return match pending_action(p.level, decision.level) {
874 PendingAction::AlreadyCovered => {
875 state.event(
876 "bump",
877 format!(
878 "a release bump to v{} ({}) already covers at least a {} change; not \
879 opening another",
880 p.target_version,
881 p.pr_url,
882 decision.level.as_str()
883 ),
884 );
885 Ok(())
886 }
887 PendingAction::Escalate => {
888 escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
889 }
890 };
891 }
892
893 let next = Version::parse(&base_version)?
894 .bump(decision.level)
895 .to_string();
896 let branch = format!("chore/release-v{next}");
897 let worktree = state.dir().join("bump");
898 git::worktree_remove(&repo, &worktree).await.ok();
899 git::worktree_add_branch(&repo, &worktree, &branch, &format!("{remote}/{base}"))
900 .await
901 .context("create the release-bump worktree")?;
902 let opened = open_bump_pr(state, &worktree, &branch, &next, &decision, pr_url).await;
903 git::worktree_remove(&repo, &worktree).await.ok();
907 let (pr_url_opened, automerge_warning) = opened?;
908
909 let marker_write = write_marker(
918 &marker,
919 &PendingBump {
920 target_version: next.clone(),
921 level: decision.level,
922 branch,
923 pr_url: pr_url_opened.clone(),
924 },
925 );
926 state.event(
927 "bump",
928 format!(
929 "opened a {} release bump to v{next} ({}): {pr_url_opened}",
930 decision.level.as_str(),
931 decision.reason
932 ),
933 );
934 if let Err(e) = marker_write {
935 state.event(
936 "bump",
937 format!(
938 "could not record the pending release bump marker for v{next}: {e:#}; a later \
939 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
940 the forge either"
941 ),
942 );
943 }
944 if let Some(warning) = automerge_warning {
945 state.event(
946 "bump",
947 format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
948 );
949 }
950 Ok(())
951}
952
953async fn escalate_pending(
962 state: &mut RunState,
963 repo: &Path,
964 remote: &str,
965 pending: &PendingBump,
966 decision: &BumpDecision,
967 base_version: &str,
968 marker: &Path,
969) -> Result<()> {
970 let next = Version::parse(base_version)?
971 .bump(decision.level)
972 .to_string();
973 let worktree = state.dir().join("bump");
974 git::worktree_remove(repo, &worktree).await.ok();
975 let checked_out = git::git_raw(
976 repo,
977 &[
978 "worktree",
979 "add",
980 "--force",
981 &worktree.to_string_lossy(),
982 &pending.branch,
983 ],
984 )
985 .await?;
986 if !checked_out.ok() {
987 bail!(
988 "checking out the pending release branch {} failed: {}",
989 pending.branch,
990 checked_out.stderr
991 );
992 }
993
994 let pushed: Result<()> = async {
999 let cargo_toml_path = worktree.join("Cargo.toml");
1000 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1001 .await
1002 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1003 let rewritten = rewrite_cargo_version(&toml, &next)?;
1004 tokio::fs::write(&cargo_toml_path, rewritten)
1005 .await
1006 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1007 sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1008 let committed = git::commit_all(
1009 &worktree,
1010 &format!(
1011 "chore: release v{next} (supersedes v{})",
1012 pending.target_version
1013 ),
1014 )
1015 .await
1016 .context("commit the escalated version bump")?;
1017 if !committed {
1018 bail!("escalating the version bump left nothing to commit");
1019 }
1020 let pushed = git::push(&worktree, remote, &pending.branch).await?;
1021 if !pushed.ok() {
1022 bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1023 }
1024 Ok(())
1025 }
1026 .await;
1027 if let Err(e) = pushed {
1028 git::worktree_remove(repo, &worktree).await.ok();
1029 return Err(e);
1030 }
1031
1032 let title_warning = match gh_pr_edit_title(
1036 &worktree,
1037 &pending.pr_url,
1038 &format!("chore: release v{next}"),
1039 )
1040 .await
1041 {
1042 Ok(()) => None,
1043 Err(e) => Some(e.to_string()),
1044 };
1045 git::worktree_remove(repo, &worktree).await.ok();
1046
1047 let marker_write = write_marker(
1048 marker,
1049 &PendingBump {
1050 target_version: next.clone(),
1051 level: decision.level,
1052 branch: pending.branch.clone(),
1053 pr_url: pending.pr_url.clone(),
1054 },
1055 );
1056 state.event(
1057 "bump",
1058 format!(
1059 "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1060 pending.target_version,
1061 decision.level.as_str(),
1062 decision.reason,
1063 pending.pr_url
1064 ),
1065 );
1066 if let Err(e) = marker_write {
1067 state.event(
1068 "bump",
1069 format!(
1070 "could not update the pending release bump marker to v{next}: {e:#}; a later \
1071 merge may misjudge whether it is already covered"
1072 ),
1073 );
1074 }
1075 if let Some(warning) = title_warning {
1076 state.event(
1077 "bump",
1078 format!(
1079 "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1080 subject may still read the superseded version",
1081 pending.pr_url
1082 ),
1083 );
1084 }
1085 Ok(())
1086}
1087
1088async fn open_bump_pr(
1094 state: &RunState,
1095 worktree: &Path,
1096 branch: &str,
1097 next_version: &str,
1098 decision: &BumpDecision,
1099 source_pr_url: &str,
1100) -> Result<(String, Option<String>)> {
1101 let cargo_toml_path = worktree.join("Cargo.toml");
1102 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1103 .await
1104 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1105 let rewritten = rewrite_cargo_version(&toml, next_version)?;
1106 tokio::fs::write(&cargo_toml_path, rewritten)
1107 .await
1108 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1109
1110 sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
1111
1112 let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
1113 .await
1114 .context("commit the version bump")?;
1115 if !committed {
1116 bail!("the version bump left nothing to commit");
1117 }
1118
1119 let remote = state.config.merge.remote.clone();
1120 let pushed = git::push(worktree, &remote, branch).await?;
1121 if !pushed.ok() {
1122 bail!("pushing {branch} failed: {}", pushed.stderr);
1123 }
1124
1125 let (title, body) = release_pr(
1126 decision.level.as_str(),
1127 &decision.reason,
1128 next_version,
1129 &state.id,
1130 source_pr_url,
1131 );
1132 let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
1133 let automerge_warning = match gh_enable_automerge(worktree, &url).await {
1134 Ok(()) => None,
1135 Err(e) => Some(e.to_string()),
1136 };
1137 Ok((url, automerge_warning))
1138}
1139
1140async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
1148 let mut cmd = tokio::process::Command::new("cargo");
1149 cmd.arg("build").current_dir(worktree).quiet();
1150 if let Some(dir) = cache_dir {
1151 cmd.env("CARGO_TARGET_DIR", dir);
1152 }
1153 let out = cmd
1154 .stdin(std::process::Stdio::null())
1155 .output()
1156 .await
1157 .context("spawn cargo build")?;
1158 if !out.status.success() {
1159 bail!(
1160 "cargo build failed while syncing Cargo.lock: {}",
1161 String::from_utf8_lossy(&out.stderr).trim()
1162 );
1163 }
1164 Ok(())
1165}
1166
1167fn release_pr(
1172 level: &str,
1173 reason: &str,
1174 next_version: &str,
1175 run_id: &str,
1176 source_pr_url: &str,
1177) -> (String, String) {
1178 let title = format!("chore: release v{next_version}");
1179 let body = format!(
1180 "Release bump: `{level}` to `v{next_version}`.\n\n{reason}\n\n\
1181 Triggered by run `{run_id}`, which landed {source_pr_url}.\n\n\
1182 version-bump-only; nothing here needs a review \
1183 (AGENTS.md: \"Version-bump-only pull requests\").",
1184 );
1185 (title, body)
1186}
1187
1188async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
1190 let out = tokio::process::Command::new("gh")
1191 .args(["pr", "view", pr_url, "--json", "title"])
1192 .current_dir(repo)
1193 .quiet()
1194 .stdin(std::process::Stdio::null())
1195 .output()
1196 .await
1197 .context("spawn gh pr view")?;
1198 if !out.status.success() {
1199 bail!(
1200 "gh pr view {pr_url}: {}",
1201 String::from_utf8_lossy(&out.stderr).trim()
1202 );
1203 }
1204 #[derive(Deserialize)]
1205 struct Title {
1206 title: String,
1207 }
1208 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
1209 .context("parse `gh pr view --json title` output")?;
1210 Ok(parsed.title)
1211}
1212
1213async fn gh_pr_create(
1214 cwd: &Path,
1215 base: &str,
1216 head: &str,
1217 title: &str,
1218 body: &str,
1219) -> Result<String> {
1220 let out = tokio::process::Command::new("gh")
1221 .args([
1222 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
1223 ])
1224 .current_dir(cwd)
1225 .quiet()
1226 .stdin(std::process::Stdio::null())
1227 .output()
1228 .await
1229 .context("spawn gh pr create")?;
1230 if out.status.success() {
1231 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
1232 } else {
1233 bail!(
1234 "gh pr create: {}",
1235 String::from_utf8_lossy(&out.stderr).trim()
1236 )
1237 }
1238}
1239
1240async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
1244 let out = tokio::process::Command::new("gh")
1245 .args([
1246 "pr",
1247 "merge",
1248 pr_url,
1249 "--auto",
1250 "--squash",
1251 "--delete-branch",
1252 ])
1253 .current_dir(cwd)
1254 .quiet()
1255 .stdin(std::process::Stdio::null())
1256 .output()
1257 .await
1258 .context("spawn gh pr merge --auto")?;
1259 if out.status.success() {
1260 Ok(())
1261 } else {
1262 bail!(
1263 "gh pr merge --auto: {}",
1264 String::from_utf8_lossy(&out.stderr).trim()
1265 )
1266 }
1267}
1268
1269async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
1273 let out = tokio::process::Command::new("gh")
1274 .args(["pr", "edit", pr_url, "--title", title])
1275 .current_dir(cwd)
1276 .quiet()
1277 .stdin(std::process::Stdio::null())
1278 .output()
1279 .await
1280 .context("spawn gh pr edit")?;
1281 if out.status.success() {
1282 Ok(())
1283 } else {
1284 bail!(
1285 "gh pr edit --title: {}",
1286 String::from_utf8_lossy(&out.stderr).trim()
1287 )
1288 }
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294
1295 #[test]
1296 fn github_facing_bump_text_is_english() {
1297 let (title, body) =
1298 release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
1299 assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
1300 assert_eq!(title, "chore: release v0.37.0");
1301 let p = decision_prompt("s", "i", "d", &[], "0.36.5");
1302 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
1303 }
1304 use crate::config::Config;
1305 use crate::land::PrLifecycle;
1306
1307 #[tokio::test]
1313 async fn a_disabled_config_does_nothing() {
1314 let config = Config {
1315 merge: crate::config::Merge {
1316 release_bump: false,
1317 ..crate::config::Merge::default()
1318 },
1319 ..Config::default()
1320 };
1321 let mut state = RunState::new(
1322 PathBuf::from("/no/such/repo"),
1323 "main".to_owned(),
1324 "0000000000000000000000000000000000000000".to_owned(),
1325 "irrelevant".to_owned(),
1326 config,
1327 );
1328 after_merge(&mut state, "https://example.invalid/pull/1")
1329 .await
1330 .expect("a disabled config must return Ok without touching anything");
1331 assert!(
1332 state.events.is_empty(),
1333 "nothing should happen at all, not even a logged event"
1334 );
1335 }
1336
1337 #[test]
1338 fn version_parses_and_bumps_each_digit() {
1339 let v = Version::parse("0.4.0").unwrap();
1340 assert_eq!(
1341 v,
1342 Version {
1343 major: 0,
1344 minor: 4,
1345 patch: 0
1346 }
1347 );
1348
1349 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
1350 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
1351 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
1352 }
1353
1354 #[test]
1355 fn version_tolerates_a_prerelease_suffix_on_patch() {
1356 let v = Version::parse("1.2.3-rc1").unwrap();
1357 assert_eq!(
1358 v,
1359 Version {
1360 major: 1,
1361 minor: 2,
1362 patch: 3
1363 }
1364 );
1365 }
1366
1367 #[test]
1368 fn version_rejects_garbage() {
1369 assert!(Version::parse("not-a-version").is_err());
1370 assert!(Version::parse("1.2").is_err());
1371 }
1372
1373 #[test]
1374 fn decision_parses_each_level() {
1375 for (json, level) in [
1376 (
1377 r#"{"level":"major","reason":"drops a config key"}"#,
1378 BumpLevel::Major,
1379 ),
1380 (
1381 r#"{"level":"minor","reason":"adds a new flag"}"#,
1382 BumpLevel::Minor,
1383 ),
1384 (
1385 r#"{"level":"patch","reason":"fixes a race"}"#,
1386 BumpLevel::Patch,
1387 ),
1388 ] {
1389 let decision = parse_decision(json).unwrap();
1390 assert_eq!(decision.level, level);
1391 assert!(!decision.reason.is_empty());
1392 }
1393 }
1394
1395 #[test]
1396 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
1397 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
1398 let decision = parse_decision(text).unwrap();
1399 assert_eq!(decision.level, BumpLevel::Minor);
1400 assert_eq!(decision.reason, "new HTTP route");
1401 }
1402
1403 #[test]
1404 fn a_broken_reply_is_an_error_not_a_default() {
1405 assert!(parse_decision("I decline to answer.").is_err());
1406 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
1407 assert!(
1408 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
1409 "an empty reason must not pass either"
1410 );
1411 assert!(
1412 parse_decision(r#"{"level":"patch"}"#).is_err(),
1413 "a reply with no reason at all must not pass"
1414 );
1415 }
1416
1417 #[test]
1418 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
1419 let prompt = decision_prompt(
1420 "feat: add a phone endpoint",
1421 "add POST /api/widgets",
1422 "1 file changed, 10 insertions(+)",
1423 &["src/web.rs".to_owned()],
1424 "0.8.0",
1425 );
1426 assert!(prompt.contains("0.8.0"), "the current version is stated");
1427 assert!(
1428 prompt.contains("below `1.0.0`")
1429 && prompt.contains("`minor` is the digit that carries a breaking change"),
1430 "the 0.x rule must be explicit: {prompt}"
1431 );
1432 assert!(
1433 prompt.contains("choose the larger"),
1434 "the tie-break toward the bigger digit must be explicit: {prompt}"
1435 );
1436 }
1437
1438 #[test]
1439 fn release_only_diffs_are_recognised() {
1440 assert!(is_release_only(&["Cargo.toml".to_owned()]));
1441 assert!(is_release_only(&[
1442 "Cargo.toml".to_owned(),
1443 "Cargo.lock".to_owned()
1444 ]));
1445 assert!(!is_release_only(&[]));
1446 assert!(!is_release_only(&[
1447 "Cargo.toml".to_owned(),
1448 "src/main.rs".to_owned()
1449 ]));
1450 }
1451
1452 #[test]
1453 fn cargo_version_rewrite_touches_only_the_package_table() {
1454 let toml = "\
1455[package]\n\
1456# a comment mentioning version on purpose\n\
1457name = \"magi-cli\"\n\
1458version = \"0.8.0\"\n\
1459edition = \"2024\"\n\
1460\n\
1461[dependencies]\n\
1462foo = { version = \"1.2.3\" }\n";
1463 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
1464 assert!(out.contains("version = \"0.9.0\""));
1465 assert!(
1466 out.contains("foo = { version = \"1.2.3\" }"),
1467 "a dependency's own version pin must survive: {out}"
1468 );
1469 assert!(
1470 out.contains("# a comment mentioning version on purpose"),
1471 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
1472 );
1473 assert_eq!(
1474 out.lines().count(),
1475 toml.lines().count(),
1476 "the rewrite replaces one line, it does not add or remove any"
1477 );
1478 }
1479
1480 #[test]
1481 fn cargo_version_rewrite_fails_without_a_package_table() {
1482 let toml = "[dependencies]\nfoo = \"1\"\n";
1483 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
1484 }
1485
1486 #[test]
1492 fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
1493 let toml = "\
1494[workspace]\n\
1495members = [\"crates/a\", \"crates/b\"]\n\
1496\n\
1497[workspace.package]\n\
1498version = \"0.45.18\"\n\
1499edition = \"2024\"\n\
1500\n\
1501[workspace.dependencies]\n\
1502foo = { version = \"1.2.3\" }\n";
1503 let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
1504 assert!(out.contains("version = \"0.45.19\""));
1505 assert!(
1506 out.contains("foo = { version = \"1.2.3\" }"),
1507 "a workspace dependency's own version pin must survive: {out}"
1508 );
1509 assert_eq!(
1510 out.lines().count(),
1511 toml.lines().count(),
1512 "the rewrite replaces one line, it does not add or remove any"
1513 );
1514 }
1515
1516 #[test]
1517 fn current_version_prefers_the_package_table_when_both_exist() {
1518 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
1519 assert_eq!(current_version(toml).unwrap(), "0.8.0");
1520 }
1521
1522 #[test]
1525 fn current_version_falls_back_to_workspace_package_without_a_package_table() {
1526 let toml = "\
1527[workspace]\n\
1528members = [\"crates/a\", \"crates/b\"]\n\
1529\n\
1530[workspace.package]\n\
1531version = \"0.45.18\"\n";
1532 assert_eq!(current_version(toml).unwrap(), "0.45.18");
1533 }
1534
1535 #[test]
1536 fn coalesce_proceeds_with_nothing_pending() {
1537 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
1538 }
1539
1540 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
1543 PendingBump {
1544 target_version: target_version.to_owned(),
1545 level,
1546 branch: format!("chore/release-v{target_version}"),
1547 pr_url: "https://example.invalid/pull/9".to_owned(),
1548 }
1549 }
1550
1551 #[test]
1552 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
1553 let pending = test_pending("0.9.0", BumpLevel::Minor);
1554 assert_eq!(
1555 coalesce(Some(&pending), "0.8.0").unwrap(),
1556 Coalesce::Skip {
1557 target_version: "0.9.0".to_owned()
1558 }
1559 );
1560 }
1561
1562 #[test]
1563 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
1564 let pending = test_pending("0.9.0", BumpLevel::Minor);
1565 assert_eq!(
1567 coalesce(Some(&pending), "0.9.0").unwrap(),
1568 Coalesce::Proceed
1569 );
1570 assert_eq!(
1572 coalesce(Some(&pending), "1.0.0").unwrap(),
1573 Coalesce::Proceed
1574 );
1575 }
1576
1577 #[test]
1578 fn pending_action_escalates_only_for_a_more_severe_decision() {
1579 assert_eq!(
1580 pending_action(BumpLevel::Patch, BumpLevel::Patch),
1581 PendingAction::AlreadyCovered
1582 );
1583 assert_eq!(
1584 pending_action(BumpLevel::Patch, BumpLevel::Minor),
1585 PendingAction::Escalate
1586 );
1587 assert_eq!(
1588 pending_action(BumpLevel::Patch, BumpLevel::Major),
1589 PendingAction::Escalate
1590 );
1591 assert_eq!(
1592 pending_action(BumpLevel::Minor, BumpLevel::Patch),
1593 PendingAction::AlreadyCovered
1594 );
1595 assert_eq!(
1596 pending_action(BumpLevel::Major, BumpLevel::Minor),
1597 PendingAction::AlreadyCovered
1598 );
1599 assert_eq!(
1600 pending_action(BumpLevel::Major, BumpLevel::Major),
1601 PendingAction::AlreadyCovered
1602 );
1603 }
1604
1605 #[test]
1606 fn pr_state_parsing_reads_open_and_not_open() {
1607 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
1608 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
1609 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
1610 }
1611
1612 #[test]
1613 fn a_lock_is_exclusive_until_dropped() {
1614 let dir = tempfile::tempdir().unwrap();
1615 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1616 let first = MarkerLock::acquire(&marker)
1617 .unwrap()
1618 .expect("first attempt takes the lock");
1619 assert!(
1620 MarkerLock::acquire(&marker).unwrap().is_none(),
1621 "a second attempt must be refused while the first holds it"
1622 );
1623 drop(first);
1624 assert!(
1625 MarkerLock::acquire(&marker).unwrap().is_some(),
1626 "dropping the guard releases the lock for the next attempt"
1627 );
1628 }
1629
1630 #[test]
1631 fn a_stale_lock_is_reclaimed() {
1632 let dir = tempfile::tempdir().unwrap();
1633 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1634 let lock_path = marker.with_extension("lock");
1635 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
1636 std::fs::write(&lock_path, b"").unwrap();
1637 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
1638 std::fs::OpenOptions::new()
1639 .write(true)
1640 .open(&lock_path)
1641 .unwrap()
1642 .set_modified(old)
1643 .unwrap();
1644 assert!(
1645 MarkerLock::acquire(&marker).unwrap().is_some(),
1646 "a lock older than the stale window must be reclaimed rather than block forever"
1647 );
1648 }
1649
1650 #[tokio::test]
1651 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
1652 let dir = tempfile::tempdir().unwrap();
1653 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1654 let held = MarkerLock::acquire(&marker)
1655 .unwrap()
1656 .expect("seed the contention");
1657 let releaser = tokio::spawn(async move {
1658 tokio::time::sleep(Duration::from_millis(20)).await;
1659 drop(held);
1660 });
1661 let waited =
1662 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
1663 .await
1664 .unwrap();
1665 assert!(
1666 waited.is_some(),
1667 "a merge landing behind another's still-running decision must not be dropped - it \
1668 must wait for that decision to finish and then judge against what it left behind"
1669 );
1670 releaser.await.unwrap();
1671 }
1672
1673 #[tokio::test]
1674 async fn a_lock_held_past_the_ceiling_gives_up() {
1675 let dir = tempfile::tempdir().unwrap();
1676 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
1677 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
1678 let waited =
1679 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
1680 .await
1681 .unwrap();
1682 assert!(
1683 waited.is_none(),
1684 "a lock genuinely held past the ceiling must eventually give up rather than wait \
1685 forever"
1686 );
1687 }
1688
1689 #[test]
1690 fn level_between_reads_off_the_differing_digit() {
1691 assert_eq!(
1692 level_between(
1693 Version::parse("0.8.0").unwrap(),
1694 Version::parse("1.0.0").unwrap()
1695 ),
1696 Some(BumpLevel::Major)
1697 );
1698 assert_eq!(
1699 level_between(
1700 Version::parse("0.8.0").unwrap(),
1701 Version::parse("0.9.0").unwrap()
1702 ),
1703 Some(BumpLevel::Minor)
1704 );
1705 assert_eq!(
1706 level_between(
1707 Version::parse("0.8.0").unwrap(),
1708 Version::parse("0.8.1").unwrap()
1709 ),
1710 Some(BumpLevel::Patch)
1711 );
1712 assert_eq!(
1713 level_between(
1714 Version::parse("0.8.0").unwrap(),
1715 Version::parse("0.8.0").unwrap()
1716 ),
1717 None
1718 );
1719 }
1720
1721 #[test]
1722 fn open_release_pr_is_found_among_unrelated_pull_requests() {
1723 let json = r#"[
1724 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
1725 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
1726 ]"#;
1727 let found = parse_open_release_pr(json).unwrap();
1728 assert_eq!(
1729 found,
1730 Some((
1731 "chore/release-v0.9.0".to_owned(),
1732 "https://example.invalid/pull/2".to_owned()
1733 ))
1734 );
1735 }
1736
1737 #[test]
1738 fn no_open_release_pr_reads_as_none_not_an_error() {
1739 let json =
1740 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
1741 assert_eq!(parse_open_release_pr(json).unwrap(), None);
1742 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
1743 }
1744
1745 #[test]
1746 fn marker_round_trips_through_disk() {
1747 let dir = tempfile::tempdir().unwrap();
1748 let path = marker_path(dir.path(), Path::new("/repos/magi"));
1749 assert!(read_marker(&path).is_none());
1750
1751 let marker = test_pending("0.9.0", BumpLevel::Patch);
1752 write_marker(&path, &marker).unwrap();
1753 let read_back = read_marker(&path).unwrap();
1754 assert_eq!(read_back.target_version, "0.9.0");
1755 assert_eq!(read_back.level, BumpLevel::Patch);
1756 assert_eq!(read_back.pr_url, marker.pr_url);
1757
1758 clear_marker(&path);
1759 assert!(read_marker(&path).is_none());
1760 }
1761
1762 #[test]
1763 fn different_repos_get_different_marker_files() {
1764 let dir = tempfile::tempdir().unwrap();
1765 let a = marker_path(dir.path(), Path::new("/repos/a"));
1766 let b = marker_path(dir.path(), Path::new("/repos/b"));
1767 assert_ne!(a, b);
1768 }
1769
1770 #[test]
1774 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
1775 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
1776 assert!(
1777 is_release_only(&files),
1778 "the bump pull request's own diff must read as release-only"
1779 );
1780 }
1781
1782 #[test]
1783 fn should_release_bump_reads_only_a_merged_status() {
1784 assert!(should_release_bump(RunStatus::Merged));
1785 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
1786 assert!(!should_release_bump(other));
1787 }
1788 }
1789
1790 #[test]
1794 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
1795 let pr = land::PrState {
1796 url: "https://github.com/o/r/pull/1".to_owned(),
1797 number: 1,
1798 state: PrLifecycle::Merged,
1799 checks: land::Checks::Green,
1800 failing: Vec::new(),
1801 review_comments: Vec::new(),
1802 blocking: land::Blocking::No,
1803 };
1804 assert_eq!(
1805 land::decide(&pr, 0, 4, Duration::ZERO),
1806 land::Step::Done { merged: true }
1807 );
1808 assert!(should_release_bump(RunStatus::Merged));
1809 }
1810
1811 #[test]
1816 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
1817 let pr = land::PrState {
1818 url: "https://github.com/o/r/pull/2".to_owned(),
1819 number: 2,
1820 state: PrLifecycle::Open,
1821 checks: land::Checks::Green,
1822 failing: Vec::new(),
1823 review_comments: Vec::new(),
1824 blocking: land::Blocking::No,
1825 };
1826 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
1827 assert!(should_release_bump(RunStatus::Merged));
1830 }
1831
1832 #[test]
1835 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
1836 let argv = land::merge_argv(3, "feat: something");
1837 let outcome = land::merged_after_all(
1838 &argv,
1839 "could not determine current branch: not on any branch",
1840 Some(PrLifecycle::Merged),
1841 );
1842 assert!(outcome.is_some(), "the forge's confirmation must win");
1843 assert!(should_release_bump(RunStatus::Merged));
1844
1845 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
1848 assert!(land::merged_after_all(&argv, "network error", None).is_none());
1849 }
1850
1851 #[test]
1853 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
1854 let pr = land::PrState {
1855 url: "https://github.com/o/r/pull/4".to_owned(),
1856 number: 4,
1857 state: PrLifecycle::Closed,
1858 checks: land::Checks::Green,
1859 failing: Vec::new(),
1860 review_comments: Vec::new(),
1861 blocking: land::Blocking::No,
1862 };
1863 assert_eq!(
1864 land::decide(&pr, 0, 4, Duration::ZERO),
1865 land::Step::Done { merged: false }
1866 );
1867 assert!(!should_release_bump(RunStatus::Blocked));
1868 }
1869}