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::ask;
31use crate::config::AgentSpec;
32use crate::git;
33use crate::land;
34use crate::notices::{Link, Notice, Notices};
35use crate::proc::Quiet as _;
36use crate::run::{self, RunState, RunStatus};
37use crate::verdict;
38
39const DECISION_TIMEOUT: Duration = Duration::from_secs(600);
46
47pub fn should_release_bump(status: RunStatus) -> bool {
63 status == RunStatus::Merged
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum BumpLevel {
70 Major,
72 Minor,
74 Patch,
76}
77
78impl BumpLevel {
79 pub fn as_str(self) -> &'static str {
81 match self {
82 Self::Major => "major",
83 Self::Minor => "minor",
84 Self::Patch => "patch",
85 }
86 }
87
88 fn severity(self) -> u8 {
93 match self {
94 Self::Patch => 0,
95 Self::Minor => 1,
96 Self::Major => 2,
97 }
98 }
99}
100
101#[derive(Debug, Clone, Deserialize)]
108pub struct BumpDecision {
109 pub level: BumpLevel,
111 pub reason: String,
114}
115
116pub fn parse_decision(text: &str) -> Result<BumpDecision> {
120 let decision: BumpDecision = verdict::extract_json(text)?;
121 if decision.reason.trim().is_empty() {
122 bail!("the bump decision carried no reason");
123 }
124 Ok(decision)
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
130pub struct Version {
131 pub major: u64,
133 pub minor: u64,
135 pub patch: u64,
137}
138
139impl Version {
140 pub fn parse(s: &str) -> Result<Self> {
145 let s = s.trim();
146 let mut parts = s.splitn(3, '.');
147 let major = parts
148 .next()
149 .with_context(|| format!("`{s}` has no major component"))?;
150 let minor = parts
151 .next()
152 .with_context(|| format!("`{s}` has no minor component"))?;
153 let patch = parts
154 .next()
155 .with_context(|| format!("`{s}` has no patch component"))?;
156 let patch_digits: String = patch.chars().take_while(char::is_ascii_digit).collect();
157 Ok(Self {
158 major: major
159 .trim()
160 .parse()
161 .with_context(|| format!("`{major}` is not a number"))?,
162 minor: minor
163 .trim()
164 .parse()
165 .with_context(|| format!("`{minor}` is not a number"))?,
166 patch: patch_digits
167 .parse()
168 .with_context(|| format!("`{patch}` has no numeric patch component"))?,
169 })
170 }
171
172 #[must_use]
175 pub fn bump(self, level: BumpLevel) -> Self {
176 match level {
177 BumpLevel::Major => Self {
178 major: self.major + 1,
179 minor: 0,
180 patch: 0,
181 },
182 BumpLevel::Minor => Self {
183 major: self.major,
184 minor: self.minor + 1,
185 patch: 0,
186 },
187 BumpLevel::Patch => Self {
188 major: self.major,
189 minor: self.minor,
190 patch: self.patch + 1,
191 },
192 }
193 }
194}
195
196impl std::fmt::Display for Version {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
199 }
200}
201
202pub fn is_release_only(files: &[String]) -> bool {
211 !files.is_empty() && files.iter().all(|f| f == "Cargo.toml" || f == "Cargo.lock")
212}
213
214fn rewrite_table_version(toml: &str, table: &str, new_version: &str) -> Result<String> {
225 let mut out = String::with_capacity(toml.len() + 8);
226 let mut in_table = false;
227 let mut done = false;
228 for line in toml.split_inclusive('\n') {
229 let trimmed = line.trim();
230 if trimmed.starts_with('[') {
231 in_table = trimmed == table;
232 }
233 if !done && in_table && trimmed.split('=').next().map(str::trim) == Some("version") {
234 let newline = if line.ends_with("\r\n") { "\r\n" } else { "\n" };
235 let _ = write!(out, "version = \"{new_version}\"{newline}");
236 done = true;
237 continue;
238 }
239 out.push_str(line);
240 }
241 if !done {
242 bail!("no `version` field found under `{table}`");
243 }
244 Ok(out)
245}
246
247pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
259 rewrite_table_version(toml, "[package]", new_version)
260 .or_else(|_| rewrite_table_version(toml, "[workspace.package]", new_version))
261 .context("no `version` field found under `[package]` or `[workspace.package]`")
262}
263
264fn version_in_table(toml: &str, table: &str) -> Option<String> {
266 let mut in_table = false;
267 for line in toml.lines() {
268 let trimmed = line.trim();
269 if trimmed.starts_with('[') {
270 in_table = trimmed == table;
271 continue;
272 }
273 if !in_table {
274 continue;
275 }
276 let mut parts = trimmed.splitn(2, '=');
277 let key = parts.next().map(str::trim);
278 let Some(value) = parts.next() else {
279 continue;
280 };
281 if key == Some("version") {
282 return Some(value.trim().trim_matches('"').to_owned());
283 }
284 }
285 None
286}
287
288fn current_version(toml: &str) -> Result<String> {
293 version_in_table(toml, "[package]")
294 .or_else(|| version_in_table(toml, "[workspace.package]"))
295 .context("no `version` field found under `[package]` or `[workspace.package]`")
296}
297
298pub fn decision_prompt(
308 subject: &str,
309 instruction: &str,
310 diffstat: &str,
311 files: &[String],
312 current_version: &str,
313) -> String {
314 let mut s = format!(
315 "A pull request just merged into the base branch. Decide which digit \
316 of this project's `major.minor.patch` version this change earns, so \
317 a release bump can be opened for exactly it.\n\n\
318 Current version: {current_version}\n\n\
319 # Merge subject\n\n{subject}\n\n\
320 # The task that produced it\n\n{instruction}\n\n\
321 # Files changed ({} total)\n\n",
322 files.len()
323 );
324 const MAX_FILES: usize = 50;
325 for f in files.iter().take(MAX_FILES) {
326 let _ = writeln!(s, "- {f}");
327 }
328 if files.len() > MAX_FILES {
329 let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
330 }
331 let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
332
333 s.push_str(
334 "\n# How to decide\n\n\
335 This project is below version `1.0.0`. At that stage **`minor` is \
336 the digit that carries a breaking change** - do not spend `major` \
337 below `1.0.0`.\n\n\
338 A change is breaking, and earns `minor`, when it changes any of: \
339 the public API reachable from `src/lib.rs`, a CLI subcommand or \
340 flag, an HTTP API route or response shape, a configuration key, or \
341 the on-disk shape of persisted state.\n\n\
342 A user-visible new capability that breaks none of the above also \
343 earns `minor`.\n\n\
344 A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
345 **When it is not obvious which digit applies, choose the larger \
346 one.** An oversized bump costs nothing; a breaking change shipped as \
347 `patch` breaks every downstream update that pins a range.\n\n\
348 # Output\n\n\
349 Reply with exactly one fenced JSON object and nothing that matters \
350 outside it:\n\n\
351 ```json\n\
352 {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
353 ```\n",
354 );
355 let _ = write!(
357 s,
358 "\n{}\n\nThe `reason` goes into a GitHub pull request body, so write it \
359 in English.\n",
360 crate::prompt::GITHUB_ENGLISH_HEADING
361 );
362 s
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct PendingBump {
382 pub target_version: String,
384 pub level: BumpLevel,
387 pub branch: String,
390 pub pr_url: String,
393}
394
395pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
399 let key = repo.to_string_lossy();
400 home.join("bump")
401 .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
402}
403
404pub fn read_marker(path: &Path) -> Option<PendingBump> {
408 let body = std::fs::read_to_string(path).ok()?;
409 serde_json::from_str(&body).ok()
410}
411
412pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
416 if let Some(parent) = path.parent() {
417 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
418 }
419 let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
420 let tmp = path.with_extension("json.tmp");
421 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
422 std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
423 Ok(())
424}
425
426pub fn clear_marker(path: &Path) {
429 let _ = std::fs::remove_file(path);
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
436pub enum Coalesce {
437 Proceed,
440 Skip {
442 target_version: String,
444 },
445}
446
447pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
449 let Some(pending) = pending else {
450 return Ok(Coalesce::Proceed);
451 };
452 let current = Version::parse(current_version)?;
453 let target = Version::parse(&pending.target_version)?;
454 if current >= target {
455 return Ok(Coalesce::Proceed);
456 }
457 Ok(Coalesce::Skip {
458 target_version: pending.target_version.clone(),
459 })
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum PendingAction {
465 AlreadyCovered,
468 Escalate,
471}
472
473pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
482 if decision_level.severity() > pending_level.severity() {
483 PendingAction::Escalate
484 } else {
485 PendingAction::AlreadyCovered
486 }
487}
488
489fn parse_pr_state(json: &str) -> Result<bool> {
491 #[derive(Deserialize)]
492 struct State {
493 state: String,
494 }
495 let parsed: State =
496 serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
497 Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
498}
499
500async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
513 let out = tokio::process::Command::new("gh")
514 .args(["pr", "view", pr_url, "--json", "state"])
515 .current_dir(repo)
516 .quiet()
517 .stdin(std::process::Stdio::null())
518 .output()
519 .await
520 .context("spawn gh pr view")?;
521 if !out.status.success() {
522 bail!(
523 "gh pr view {pr_url}: {}",
524 String::from_utf8_lossy(&out.stderr).trim()
525 );
526 }
527 parse_pr_state(&String::from_utf8_lossy(&out.stdout))
528}
529
530const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
538
539struct MarkerLock {
551 path: PathBuf,
552}
553
554impl MarkerLock {
555 fn acquire(marker: &Path) -> Result<Option<Self>> {
559 let path = marker.with_extension("lock");
560 if let Some(parent) = path.parent() {
561 std::fs::create_dir_all(parent)
562 .with_context(|| format!("create {}", parent.display()))?;
563 }
564 if Self::try_create(&path)? {
565 return Ok(Some(Self { path }));
566 }
567 if Self::is_stale(&path) {
568 let _ = std::fs::remove_file(&path);
569 if Self::try_create(&path)? {
570 return Ok(Some(Self { path }));
571 }
572 }
573 Ok(None)
574 }
575
576 fn try_create(path: &Path) -> Result<bool> {
577 match std::fs::OpenOptions::new()
578 .write(true)
579 .create_new(true)
580 .open(path)
581 {
582 Ok(_) => Ok(true),
583 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
584 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
585 }
586 }
587
588 fn is_stale(path: &Path) -> bool {
589 std::fs::metadata(path)
590 .and_then(|m| m.modified())
591 .ok()
592 .and_then(|m| m.elapsed().ok())
593 .is_some_and(|age| age >= LOCK_STALE_AFTER)
594 }
595}
596
597impl Drop for MarkerLock {
598 fn drop(&mut self) {
599 let _ = std::fs::remove_file(&self.path);
600 }
601}
602
603const LOCK_POLL: Duration = Duration::from_secs(5);
605
606const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
619
620async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
623 wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
624}
625
626async fn wait_for_marker_lock_with(
630 marker: &Path,
631 poll: Duration,
632 ceiling: Duration,
633) -> Result<Option<MarkerLock>> {
634 let mut waited = Duration::ZERO;
635 loop {
636 if let Some(lock) = MarkerLock::acquire(marker)? {
637 return Ok(Some(lock));
638 }
639 if waited >= ceiling {
640 return Ok(None);
641 }
642 tokio::time::sleep(poll).await;
643 waited += poll;
644 }
645}
646
647fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
655 if to.major != from.major {
656 Some(BumpLevel::Major)
657 } else if to.minor != from.minor {
658 Some(BumpLevel::Minor)
659 } else if to.patch != from.patch {
660 Some(BumpLevel::Patch)
661 } else {
662 None
663 }
664}
665
666fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
669 #[derive(Deserialize)]
670 struct Pr {
671 url: String,
672 #[serde(rename = "headRefName")]
673 head_ref_name: String,
674 }
675 let list: Vec<Pr> =
676 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
677 Ok(list
678 .into_iter()
679 .find(|p| p.head_ref_name.starts_with("chore/release-v"))
680 .map(|p| (p.head_ref_name, p.url)))
681}
682
683async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
698 let out = tokio::process::Command::new("gh")
699 .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
700 .current_dir(repo)
701 .quiet()
702 .stdin(std::process::Stdio::null())
703 .output()
704 .await
705 .context("spawn gh pr list")?;
706 if !out.status.success() {
707 bail!(
708 "gh pr list: {}",
709 String::from_utf8_lossy(&out.stderr).trim()
710 );
711 }
712 parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
713}
714
715pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
724 after_merge_at(state, pr_url, None).await
725}
726
727async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
733 let out = git::git(
734 repo,
735 &[
736 "ls-tree",
737 "--name-only",
738 &format!("{remote}/{base}"),
739 "--",
740 "Cargo.toml",
741 ],
742 )
743 .await
744 .context("look for Cargo.toml on the base branch")?;
745 Ok(!out.trim().is_empty())
746}
747
748async fn after_merge_at(state: &mut RunState, pr_url: &str, home: Option<&Path>) -> Result<()> {
751 if !state.config.merge.release_bump {
752 return Ok(());
753 }
754 let Some(winner) = state.winner().cloned() else {
755 return Ok(());
756 };
757 let repo = state.repo.clone();
758 let base = state.base_branch.clone();
759 let remote = state.config.merge.remote.clone();
760
761 let files = git::changed_files(&winner.worktree, &base, &winner.branch)
762 .await
763 .unwrap_or_default();
764 if is_release_only(&files) {
765 state.event(
766 "bump",
767 "the merged change touches only the release manifest; not treating it as a trigger",
768 );
769 return Ok(());
770 }
771
772 git::fetch(&repo, &remote, &base).await.ok();
775 if !base_has_cargo_toml(&repo, &remote, &base).await? {
776 state.event(
777 "bump",
778 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping",
779 );
780 return Ok(());
781 }
782
783 let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
784 let Some(_lock) = wait_for_marker_lock(&marker).await? else {
791 state.event(
792 "bump",
793 "another release bump decision held the lock past the wait ceiling; skipping this round",
794 );
795 return Ok(());
796 };
797
798 git::fetch(&repo, &remote, &base).await.ok();
799 let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
800 .await
801 .context("read Cargo.toml from the base branch")?;
802 let base_version = current_version(&cargo_toml)?;
803
804 let mut pending = read_marker(&marker);
805 if let Some(p) = &pending {
806 match coalesce(Some(p), &base_version)? {
807 Coalesce::Proceed => {
808 clear_marker(&marker);
811 pending = None;
812 }
813 Coalesce::Skip { target_version } => {
814 if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
815 state.event(
816 "bump",
817 format!(
818 "the pending release bump to v{target_version} ({}) is no longer \
819 open; treating it as abandoned",
820 p.pr_url
821 ),
822 );
823 clear_marker(&marker);
824 pending = None;
825 }
826 }
831 }
832 }
833
834 if pending.is_none() {
835 if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
840 && let Some(target) = branch
841 .strip_prefix("chore/release-v")
842 .and_then(|v| Version::parse(v).ok())
843 {
844 let base_parsed = Version::parse(&base_version)?;
845 if target > base_parsed
846 && let Some(level) = level_between(base_parsed, target)
847 {
848 let adopted = PendingBump {
849 target_version: target.to_string(),
850 level,
851 branch,
852 pr_url: url,
853 };
854 let _ = write_marker(&marker, &adopted);
857 pending = Some(adopted);
858 }
859 }
860 }
861
862 let title = pr_title(&repo, pr_url).await.unwrap_or_default();
863 let subject = land::merge_subject(&title, &state.instruction);
864 let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
865 .await
866 .unwrap_or_default();
867 let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
868
869 let spec: AgentSpec = agent::pick(
875 &state.config.agents,
876 state.config.roles.chatter.as_deref(),
877 &agent::installed,
878 )
879 .context("choose an agent for the release-bump decision")?;
880 let mut seat = SeatState::new("bump", &spec.id, state.seed);
881 let artifacts = agent::artifacts_dir(&state.dir());
882 let out = agent::invoke(
883 &spec,
884 &mut seat,
885 &Invocation {
886 cwd: &repo,
887 prompt: &prompt,
888 timeout: DECISION_TIMEOUT,
889 allow_write: false,
892 sessions: false,
893 artifacts: &artifacts,
894 stem: "bump-decision",
895 run: &state.id,
896 node: "bump",
897 cache_dir: state.config.cache_dir().as_deref(),
898 attachments: &[],
899 },
900 )
901 .await
902 .context("ask an agent how big the merged change was")?;
903 if !out.usable() {
904 bail!(
905 "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
906 out.exit_code,
907 out.timed_out
908 );
909 }
910 let decision = parse_decision(&out.text).context("parse the release-bump decision")?;
911
912 if let Some(p) = pending {
913 return match pending_action(p.level, decision.level) {
914 PendingAction::AlreadyCovered => {
915 state.event(
916 "bump",
917 format!(
918 "a release bump to v{} ({}) already covers at least a {} change; not \
919 opening another",
920 p.target_version,
921 p.pr_url,
922 decision.level.as_str()
923 ),
924 );
925 Ok(())
926 }
927 PendingAction::Escalate => {
928 escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
929 }
930 };
931 }
932
933 let next = Version::parse(&base_version)?
934 .bump(decision.level)
935 .to_string();
936 let branch = format!("chore/release-v{next}");
937 let worktree = state.dir().join("bump");
938 git::worktree_remove(&repo, &worktree).await.ok();
939 git::worktree_add_branch(&repo, &worktree, &branch, &format!("{remote}/{base}"))
940 .await
941 .context("create the release-bump worktree")?;
942 let opened = open_bump_pr(state, &worktree, &branch, &next, &decision, pr_url).await;
943 git::worktree_remove(&repo, &worktree).await.ok();
947 let (pr_url_opened, outcome) = opened?;
948 let (automerge_warning, merged_detail) = match outcome {
949 AutomergeOutcome::Enabled => (None, None),
950 AutomergeOutcome::MergedDirectly { detail } => (None, Some(detail)),
951 AutomergeOutcome::Failed { reason } => (Some(reason), None),
952 };
953
954 let marker_write = write_marker(
963 &marker,
964 &PendingBump {
965 target_version: next.clone(),
966 level: decision.level,
967 branch,
968 pr_url: pr_url_opened.clone(),
969 },
970 );
971 state.event(
972 "bump",
973 format!(
974 "opened a {} release bump to v{next} ({}): {pr_url_opened}",
975 decision.level.as_str(),
976 decision.reason
977 ),
978 );
979 if let Err(e) = marker_write {
980 state.event(
981 "bump",
982 format!(
983 "could not record the pending release bump marker for v{next}: {e:#}; a later \
984 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
985 the forge either"
986 ),
987 );
988 }
989 state.release_bump = Some(run::ReleaseBump {
990 pr_url: Some(pr_url_opened.clone()),
991 version: Some(next.clone()),
992 automerge_enabled: automerge_warning.is_none() && merged_detail.is_none(),
993 merged_directly: merged_detail.is_some(),
994 ..run::ReleaseBump::default()
995 });
996 if let Some(detail) = merged_detail {
997 clear_marker(&marker);
1000 state.event("bump", format!("merged v{next} directly: {detail}"));
1001 }
1002 if let Some(warning) = automerge_warning {
1003 state.event(
1004 "bump",
1005 format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
1006 );
1007 report_problem(state, Some(&pr_url_opened), Some(&next), &warning).await;
1008 }
1009 Ok(())
1010}
1011
1012pub const NOTICE_NODE: &str = "release-bump";
1017
1018fn automerge_hint(reason: &str) -> &'static str {
1025 let r = reason.to_lowercase();
1026 if is_clean_status_refusal(reason) {
1027 "merge the release pull request by hand; CI is already green"
1028 } else if r.contains("enablepullrequestautomerge") || r.contains("protected branch rules") {
1029 "merge the release pull request by hand, and enable branch protection with required \
1030 status checks on the base branch so automerge can work next time"
1031 } else {
1032 "merge the release pull request by hand"
1033 }
1034}
1035
1036fn automerge_failure_comment(reason: &str) -> String {
1038 format!(
1039 "magi could not enable automerge on this pull request: {reason}\n\n\
1040 Action required: {}. Until then the release does not happen.",
1041 automerge_hint(reason)
1042 )
1043}
1044
1045fn surface_problem(
1053 state: &mut RunState,
1054 store: &Notices,
1055 pr_url: Option<&str>,
1056 version: Option<&str>,
1057 reason: &str,
1058) -> Result<(Notice, Option<String>)> {
1059 let action = if pr_url.is_some() {
1060 automerge_hint(reason).to_owned()
1061 } else {
1062 "the release bump did not run; open the release pull request by hand".to_owned()
1063 };
1064 let record = state.release_bump.get_or_insert_with(Default::default);
1065 record.pr_url = pr_url.map(str::to_owned).or(record.pr_url.take());
1066 record.version = version.map(str::to_owned).or(record.version.take());
1067 record.automerge_enabled = false;
1068 record.problem = Some(reason.to_owned());
1069 record.action_required = Some(action.clone());
1070
1071 let mut notice = Notice::error(
1072 &format!("release-bump:{}", state.id),
1073 format!(
1074 "Run {} merged, but its release step failed: {action}.",
1075 state.id
1076 ),
1077 );
1078 notice = match pr_url {
1079 Some(url) => notice.link(Link::Url {
1080 url: url.to_owned(),
1081 }),
1082 None => notice.link(Link::Run {
1083 id: state.id.clone(),
1084 }),
1085 };
1086 let notice = store
1087 .raise(notice)
1088 .context("raise the release-bump notification")?;
1089 state.event("bump", format!("needs attention: {action}"));
1090 let comment = pr_url.map(|_| automerge_failure_comment(reason));
1091 Ok((notice, comment))
1092}
1093
1094pub async fn report_problem(
1099 state: &mut RunState,
1100 pr_url: Option<&str>,
1101 version: Option<&str>,
1102 reason: &str,
1103) {
1104 match surface_problem(state, &Notices::open(), pr_url, version, reason) {
1105 Ok((notice, comment)) => {
1106 if let (Some(url), Some(body)) = (pr_url, comment)
1107 && let Err(e) = gh_pr_comment(
1108 &state.repo,
1109 url,
1110 &crate::scrub::scrub(&body, &crate::scrub::Identity::current()),
1111 )
1112 .await
1113 {
1114 state.event("bump", format!("could not comment on {url}: {e:#}"));
1115 }
1116 let summary = match pr_url {
1119 Some(url) => format!("Release PR needs a human: {url}"),
1120 None => "Release bump did not run".to_owned(),
1121 };
1122 let q = ask::Question::new(
1123 state.id.clone(),
1124 NOTICE_NODE.to_owned(),
1125 "bump".to_owned(),
1126 summary,
1127 notice.message.clone(),
1128 Vec::new(),
1129 );
1130 if let Err(e) = ask::notify(&state.config.notify, &q).await {
1131 tracing::warn!(
1132 "could not notify about the release bump of {}: {e:#}",
1133 state.id
1134 );
1135 }
1136 }
1137 Err(e) => state.event("bump", format!("could not raise a notice: {e:#}")),
1138 }
1139}
1140
1141async fn gh_pr_comment(cwd: &Path, pr_url: &str, body: &str) -> Result<()> {
1142 let out = tokio::process::Command::new("gh")
1143 .args(["pr", "comment", pr_url, "--body", body])
1144 .current_dir(cwd)
1145 .quiet()
1146 .stdin(std::process::Stdio::null())
1147 .output()
1148 .await
1149 .context("spawn gh pr comment")?;
1150 if out.status.success() {
1151 Ok(())
1152 } else {
1153 bail!(
1154 "gh pr comment: {}",
1155 String::from_utf8_lossy(&out.stderr).trim()
1156 )
1157 }
1158}
1159
1160async fn escalate_pending(
1169 state: &mut RunState,
1170 repo: &Path,
1171 remote: &str,
1172 pending: &PendingBump,
1173 decision: &BumpDecision,
1174 base_version: &str,
1175 marker: &Path,
1176) -> Result<()> {
1177 let next = Version::parse(base_version)?
1178 .bump(decision.level)
1179 .to_string();
1180 let worktree = state.dir().join("bump");
1181 git::worktree_remove(repo, &worktree).await.ok();
1182 let checked_out = git::git_raw(
1183 repo,
1184 &[
1185 "worktree",
1186 "add",
1187 "--force",
1188 &worktree.to_string_lossy(),
1189 &pending.branch,
1190 ],
1191 )
1192 .await?;
1193 if !checked_out.ok() {
1194 bail!(
1195 "checking out the pending release branch {} failed: {}",
1196 pending.branch,
1197 checked_out.stderr
1198 );
1199 }
1200
1201 let pushed: Result<()> = async {
1206 let cargo_toml_path = worktree.join("Cargo.toml");
1207 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1208 .await
1209 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1210 let rewritten = rewrite_cargo_version(&toml, &next)?;
1211 tokio::fs::write(&cargo_toml_path, rewritten)
1212 .await
1213 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1214 sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1215 let committed = git::commit_all(
1216 &worktree,
1217 &format!(
1218 "chore: release v{next} (supersedes v{})",
1219 pending.target_version
1220 ),
1221 )
1222 .await
1223 .context("commit the escalated version bump")?;
1224 if !committed {
1225 bail!("escalating the version bump left nothing to commit");
1226 }
1227 let pushed = git::push(&worktree, remote, &pending.branch).await?;
1228 if !pushed.ok() {
1229 bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1230 }
1231 Ok(())
1232 }
1233 .await;
1234 if let Err(e) = pushed {
1235 git::worktree_remove(repo, &worktree).await.ok();
1236 return Err(e);
1237 }
1238
1239 let title_warning = match gh_pr_edit_title(
1243 &worktree,
1244 &pending.pr_url,
1245 &crate::scrub::scrub(
1246 &format!("chore: release v{next} ({} bump)", decision.level.as_str()),
1247 &crate::scrub::Identity::current(),
1248 ),
1249 )
1250 .await
1251 {
1252 Ok(()) => None,
1253 Err(e) => Some(e.to_string()),
1254 };
1255 git::worktree_remove(repo, &worktree).await.ok();
1256
1257 let marker_write = write_marker(
1258 marker,
1259 &PendingBump {
1260 target_version: next.clone(),
1261 level: decision.level,
1262 branch: pending.branch.clone(),
1263 pr_url: pending.pr_url.clone(),
1264 },
1265 );
1266 state.event(
1267 "bump",
1268 format!(
1269 "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1270 pending.target_version,
1271 decision.level.as_str(),
1272 decision.reason,
1273 pending.pr_url
1274 ),
1275 );
1276 if let Err(e) = marker_write {
1277 state.event(
1278 "bump",
1279 format!(
1280 "could not update the pending release bump marker to v{next}: {e:#}; a later \
1281 merge may misjudge whether it is already covered"
1282 ),
1283 );
1284 }
1285 if let Some(warning) = title_warning {
1286 state.event(
1287 "bump",
1288 format!(
1289 "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1290 subject may still read the superseded version",
1291 pending.pr_url
1292 ),
1293 );
1294 }
1295 Ok(())
1296}
1297
1298async fn open_bump_pr(
1304 state: &RunState,
1305 worktree: &Path,
1306 branch: &str,
1307 next_version: &str,
1308 decision: &BumpDecision,
1309 source_pr_url: &str,
1310) -> Result<(String, AutomergeOutcome)> {
1311 let cargo_toml_path = worktree.join("Cargo.toml");
1312 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1313 .await
1314 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1315 let rewritten = rewrite_cargo_version(&toml, next_version)?;
1316 tokio::fs::write(&cargo_toml_path, rewritten)
1317 .await
1318 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1319
1320 sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
1321
1322 let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
1323 .await
1324 .context("commit the version bump")?;
1325 if !committed {
1326 bail!("the version bump left nothing to commit");
1327 }
1328
1329 let remote = state.config.merge.remote.clone();
1330 let pushed = git::push(worktree, &remote, branch).await?;
1331 if !pushed.ok() {
1332 bail!("pushing {branch} failed: {}", pushed.stderr);
1333 }
1334
1335 let (title, body) = release_pr(
1336 decision.level.as_str(),
1337 &decision.reason,
1338 next_version,
1339 &state.id,
1340 source_pr_url,
1341 );
1342 let who = crate::scrub::Identity::current();
1343 let (title, body) = (
1344 crate::scrub::scrub(&title, &who),
1345 crate::scrub::scrub(&body, &who),
1346 );
1347 let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
1348 let outcome = match gh_enable_automerge(worktree, &url).await {
1349 Ok(()) => AutomergeOutcome::Enabled,
1350 Err(e) => {
1351 let reason = e.to_string();
1352 if is_clean_status_refusal(&reason) {
1353 gh_merge_directly(worktree, &url, &title, reason).await
1354 } else {
1355 AutomergeOutcome::Failed { reason }
1356 }
1357 }
1358 };
1359 Ok((url, outcome))
1360}
1361
1362#[derive(Debug, Clone, PartialEq, Eq)]
1364enum AutomergeOutcome {
1365 Enabled,
1367 MergedDirectly { detail: String },
1369 Failed { reason: String },
1371}
1372
1373fn is_clean_status_refusal(reason: &str) -> bool {
1379 let r = reason.to_lowercase();
1380 r.contains("is in clean status") && r.contains("enablepullrequestautomerge")
1381}
1382
1383fn bump_merge_argv(pr_url: &str, subject: &str) -> Vec<String> {
1386 [
1387 "pr",
1388 "merge",
1389 pr_url,
1390 "--squash",
1391 "--delete-branch",
1392 "--subject",
1393 subject,
1394 ]
1395 .map(str::to_owned)
1396 .to_vec()
1397}
1398
1399fn resolve_direct_merge(
1403 refusal: &str,
1404 argv: &[String],
1405 merge_ok: bool,
1406 stderr: &str,
1407 after: Option<land::PrLifecycle>,
1408) -> AutomergeOutcome {
1409 if merge_ok {
1410 return AutomergeOutcome::MergedDirectly {
1411 detail: format!("automerge was refused ({refusal}); gh {}", argv.join(" ")),
1412 };
1413 }
1414 match land::merged_after_all(argv, stderr, after) {
1415 Some(m) => AutomergeOutcome::MergedDirectly { detail: m.detail },
1416 None => AutomergeOutcome::Failed {
1417 reason: format!("{refusal}; merging directly failed too: {}", stderr.trim()),
1418 },
1419 }
1420}
1421
1422async fn gh_merge_directly(
1426 cwd: &Path,
1427 pr_url: &str,
1428 subject: &str,
1429 refusal: String,
1430) -> AutomergeOutcome {
1431 let argv = bump_merge_argv(pr_url, subject);
1432 let out = match tokio::process::Command::new("gh")
1433 .args(&argv)
1434 .current_dir(cwd)
1435 .quiet()
1436 .stdin(std::process::Stdio::null())
1437 .output()
1438 .await
1439 {
1440 Ok(o) => o,
1441 Err(e) => {
1442 return AutomergeOutcome::Failed {
1443 reason: format!("{refusal}; could not spawn gh to merge directly: {e}"),
1444 };
1445 }
1446 };
1447 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
1448 let after = if out.status.success() {
1451 None
1452 } else {
1453 land::lifecycle(cwd, pr_url).await.ok()
1454 };
1455 resolve_direct_merge(&refusal, &argv, out.status.success(), &stderr, after)
1456}
1457
1458async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
1466 let mut cmd = tokio::process::Command::new("cargo");
1467 cmd.arg("build").current_dir(worktree).quiet();
1468 if let Some(dir) = cache_dir {
1469 cmd.env("CARGO_TARGET_DIR", dir);
1470 }
1471 let out = cmd
1472 .stdin(std::process::Stdio::null())
1473 .output()
1474 .await
1475 .context("spawn cargo build")?;
1476 if !out.status.success() {
1477 bail!(
1478 "cargo build failed while syncing Cargo.lock: {}",
1479 String::from_utf8_lossy(&out.stderr).trim()
1480 );
1481 }
1482 Ok(())
1483}
1484
1485fn release_pr(
1490 level: &str,
1491 reason: &str,
1492 next_version: &str,
1493 run_id: &str,
1494 source_pr_url: &str,
1495) -> (String, String) {
1496 let title = format!("chore: release v{next_version} ({level} bump)");
1497 let body = format!(
1498 "## Background\n\n\
1499 A change that was just merged is a `{level}` change, so the crate needs a new \
1500 release: {reason}\n\n\
1501 Triggered by magi run `{run_id}`, which landed {source}.\n\n\
1502 ## Change\n\n\
1503 Raises the package version to `v{next_version}` in `Cargo.toml`, with \
1504 `Cargo.lock` following it. Nothing else changes.\n\n\
1505 ## Risk\n\n\
1506 Version-bump-only, so there is nothing here for a reviewer to find. Merging \
1507 it starts the release pipeline (auto-tag, then the release workflow).",
1508 source = source_pr_url,
1509 );
1510 (title, body)
1511}
1512
1513async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
1515 let out = tokio::process::Command::new("gh")
1516 .args(["pr", "view", pr_url, "--json", "title"])
1517 .current_dir(repo)
1518 .quiet()
1519 .stdin(std::process::Stdio::null())
1520 .output()
1521 .await
1522 .context("spawn gh pr view")?;
1523 if !out.status.success() {
1524 bail!(
1525 "gh pr view {pr_url}: {}",
1526 String::from_utf8_lossy(&out.stderr).trim()
1527 );
1528 }
1529 #[derive(Deserialize)]
1530 struct Title {
1531 title: String,
1532 }
1533 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
1534 .context("parse `gh pr view --json title` output")?;
1535 Ok(parsed.title)
1536}
1537
1538async fn gh_pr_create(
1539 cwd: &Path,
1540 base: &str,
1541 head: &str,
1542 title: &str,
1543 body: &str,
1544) -> Result<String> {
1545 let out = tokio::process::Command::new("gh")
1546 .args([
1547 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
1548 ])
1549 .current_dir(cwd)
1550 .quiet()
1551 .stdin(std::process::Stdio::null())
1552 .output()
1553 .await
1554 .context("spawn gh pr create")?;
1555 if out.status.success() {
1556 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
1557 } else {
1558 bail!(
1559 "gh pr create: {}",
1560 String::from_utf8_lossy(&out.stderr).trim()
1561 )
1562 }
1563}
1564
1565async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
1569 let out = tokio::process::Command::new("gh")
1570 .args([
1571 "pr",
1572 "merge",
1573 pr_url,
1574 "--auto",
1575 "--squash",
1576 "--delete-branch",
1577 ])
1578 .current_dir(cwd)
1579 .quiet()
1580 .stdin(std::process::Stdio::null())
1581 .output()
1582 .await
1583 .context("spawn gh pr merge --auto")?;
1584 if out.status.success() {
1585 Ok(())
1586 } else {
1587 bail!(
1588 "gh pr merge --auto: {}",
1589 String::from_utf8_lossy(&out.stderr).trim()
1590 )
1591 }
1592}
1593
1594async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
1598 let out = tokio::process::Command::new("gh")
1599 .args(["pr", "edit", pr_url, "--title", title])
1600 .current_dir(cwd)
1601 .quiet()
1602 .stdin(std::process::Stdio::null())
1603 .output()
1604 .await
1605 .context("spawn gh pr edit")?;
1606 if out.status.success() {
1607 Ok(())
1608 } else {
1609 bail!(
1610 "gh pr edit --title: {}",
1611 String::from_utf8_lossy(&out.stderr).trim()
1612 )
1613 }
1614}
1615
1616#[cfg(test)]
1617mod tests {
1618 use super::*;
1619 use crate::notices::Severity;
1620
1621 #[test]
1622 fn github_facing_bump_text_is_english() {
1623 let (title, body) =
1624 release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
1625 assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
1626 assert_eq!(title, "chore: release v0.37.0 (minor bump)");
1627 assert!(
1628 body.contains("## Background") && body.contains("## Change"),
1629 "{body}"
1630 );
1631 let p = decision_prompt("s", "i", "d", &[], "0.36.5");
1632 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
1633 }
1634 use crate::config::Config;
1635 use crate::land::PrLifecycle;
1636
1637 #[tokio::test]
1643 async fn a_disabled_config_does_nothing() {
1644 let config = Config {
1645 merge: crate::config::Merge {
1646 release_bump: false,
1647 ..crate::config::Merge::default()
1648 },
1649 ..Config::default()
1650 };
1651 let mut state = RunState::new(
1652 PathBuf::from("/no/such/repo"),
1653 "main".to_owned(),
1654 "0000000000000000000000000000000000000000".to_owned(),
1655 "irrelevant".to_owned(),
1656 config,
1657 );
1658 after_merge(&mut state, "https://example.invalid/pull/1")
1659 .await
1660 .expect("a disabled config must return Ok without touching anything");
1661 assert!(
1662 state.events.is_empty(),
1663 "nothing should happen at all, not even a logged event"
1664 );
1665 }
1666
1667 async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
1669 let dir = tempfile::tempdir().unwrap();
1670 let origin = dir.path().join("origin.git");
1671 let repo = dir.path().join("repo");
1672 let o = origin.to_string_lossy().into_owned();
1673 git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
1674 .await
1675 .unwrap();
1676 tokio::fs::create_dir_all(&repo).await.unwrap();
1677 git::git(&repo, &["init", "-b", "main"]).await.unwrap();
1678 git::git(&repo, &["config", "user.name", "test"])
1679 .await
1680 .unwrap();
1681 git::git(&repo, &["config", "user.email", "test@example.com"])
1682 .await
1683 .unwrap();
1684 for (name, body) in files {
1685 tokio::fs::write(repo.join(name), body).await.unwrap();
1686 }
1687 git::git(&repo, &["add", "-A"]).await.unwrap();
1688 git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
1689 git::git(&repo, &["remote", "add", "origin", &o])
1690 .await
1691 .unwrap();
1692 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
1693 (dir, repo)
1694 }
1695
1696 #[tokio::test]
1697 async fn base_has_cargo_toml_tells_rust_from_non_rust() {
1698 let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
1699 assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
1700 let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
1701 assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
1702 assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
1704 }
1705
1706 #[tokio::test]
1707 async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
1708 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
1709 let home = tempfile::tempdir().unwrap();
1710 let mut state = RunState::new(
1711 repo.clone(),
1712 "main".to_owned(),
1713 "0000000000000000000000000000000000000000".to_owned(),
1714 "task".to_owned(),
1715 Config::default(),
1716 );
1717 state.candidates.push(crate::run::Candidate {
1718 index: 0,
1719 label: 'A',
1720 agent: "x".to_owned(),
1721 branch: "main".to_owned(),
1722 worktree: repo.clone(),
1723 summary: String::new(),
1724 stat: String::new(),
1725 files: 1,
1726 commits: 1,
1727 empty: false,
1728 failed: None,
1729 verified_noop: None,
1730 duration_ms: 0,
1731 folded: false,
1732 });
1733 state.tally = Some(
1734 serde_json::from_value(serde_json::json!({
1735 "first_choice": {}, "borda": {}, "winner": "A",
1736 "unanimous_initial": true, "deliberated": false,
1737 "changed_votes": 0, "unanimous_final": true,
1738 }))
1739 .unwrap(),
1740 );
1741 after_merge_at(
1742 &mut state,
1743 "https://example.invalid/pull/1",
1744 Some(home.path()),
1745 )
1746 .await
1747 .expect("a non-Rust repository is not an error");
1748 let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
1749 assert_eq!(bumps.len(), 1, "{:?}", state.events);
1750 assert_eq!(
1751 bumps[0].message,
1752 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
1753 );
1754 assert!(
1755 std::fs::read_dir(home.path()).unwrap().next().is_none(),
1756 "no marker and no lock may be created"
1757 );
1758 }
1759
1760 const NO_RULES: &str = "gh pr merge --auto: GraphQL: Pull request Branch does not have \
1761 required protected branch rules (enablePullRequestAutoMerge)";
1762
1763 fn merged_state() -> RunState {
1764 run::set_home(std::env::temp_dir().join("magi-report-test-home"));
1767 let mut s = RunState::new(
1768 PathBuf::from("/no/such/repo"),
1769 "main".to_owned(),
1770 "0000000000000000000000000000000000000000".to_owned(),
1771 "task".to_owned(),
1772 Config::default(),
1773 );
1774 s.status = RunStatus::Merged;
1775 s
1776 }
1777
1778 #[test]
1779 fn the_known_automerge_refusal_names_branch_protection() {
1780 assert!(automerge_hint(NO_RULES).contains("branch protection with required"));
1781 let other = automerge_hint("gh: network unreachable");
1782 assert!(!other.contains("branch protection"), "{other}");
1783 let body = automerge_failure_comment(NO_RULES);
1784 assert!(body.contains("enablePullRequestAutoMerge"), "{body}");
1785 assert!(body.contains("Action required"), "{body}");
1786 }
1787
1788 const CLEAN: &str = "gh pr merge --auto: GraphQL: Pull request Pull request is in clean \
1789 status (enablePullRequestAutoMerge)";
1790
1791 #[test]
1792 fn clean_status_refusal_is_matched_narrowly() {
1793 assert!(is_clean_status_refusal(CLEAN));
1794 assert!(!is_clean_status_refusal(NO_RULES));
1795 assert!(!is_clean_status_refusal("gh: network unreachable"));
1796 assert!(!is_clean_status_refusal("Pull request is in clean status"));
1797 assert!(automerge_hint(CLEAN).contains("already green"));
1798 }
1799
1800 #[test]
1801 fn the_direct_merge_argv_matches_the_land_flags() {
1802 let a = bump_merge_argv("https://github.com/o/r/pull/9", "chore: release v1.0.0");
1803 let l = land::merge_argv(9, "chore: release v1.0.0");
1804 assert_eq!(a[..2], l[..2]);
1805 assert_eq!(a[3..], l[3..]);
1806 assert_eq!(a[2], "https://github.com/o/r/pull/9");
1807 }
1808
1809 #[test]
1810 fn a_direct_merge_is_judged_by_the_forge_not_the_exit_code() {
1811 let argv = bump_merge_argv("u", "t");
1812 let merged = |o: &AutomergeOutcome| matches!(o, AutomergeOutcome::MergedDirectly { .. });
1813 assert!(merged(&resolve_direct_merge(CLEAN, &argv, true, "", None)));
1814 let detached = "not on any branch";
1815 assert!(merged(&resolve_direct_merge(
1816 CLEAN,
1817 &argv,
1818 false,
1819 detached,
1820 Some(PrLifecycle::Merged)
1821 )));
1822 for after in [Some(PrLifecycle::Open), None] {
1823 let o = resolve_direct_merge(CLEAN, &argv, false, "boom", after);
1824 match o {
1825 AutomergeOutcome::Failed { reason } => {
1826 assert!(
1827 reason.contains("clean status") && reason.contains("boom"),
1828 "{reason}"
1829 )
1830 }
1831 other => panic!("expected Failed, got {other:?}"),
1832 }
1833 }
1834 }
1835
1836 #[test]
1837 fn a_directly_merged_bump_is_not_reported_as_pending_or_failed() {
1838 let mut state = merged_state();
1839 state.release_bump = Some(run::ReleaseBump {
1840 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
1841 version: Some("1.0.0".to_owned()),
1842 merged_directly: true,
1843 ..run::ReleaseBump::default()
1844 });
1845 assert!(!state.needs_attention());
1846 let text = crate::report::run(&state);
1847 assert!(text.contains("merged directly"), "{text}");
1848 assert!(!text.contains("FAILED"), "{text}");
1849 }
1850
1851 #[test]
1852 fn an_automerge_failure_is_recorded_shown_and_filed_and_survives_settling() {
1853 let dir = tempfile::tempdir().unwrap();
1854 let store = Notices::at(dir.path().join("notifications"));
1855 let questions = ask::Questions::at(dir.path().join("questions"));
1856 let mut state = merged_state();
1857 let url = "https://github.com/o/r/pull/35";
1858
1859 let (n, comment) =
1860 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
1861
1862 let comment = comment.expect("a PR was opened, so it gets a comment");
1864 assert!(comment.contains("branch protection"), "{comment}");
1865
1866 assert_eq!(state.status, RunStatus::Merged);
1868 assert!(state.needs_attention());
1869 let text = crate::report::run(&state);
1870 assert!(text.contains("release bump"), "{text}");
1871 assert!(text.contains("FAILED"), "{text}");
1872 assert!(text.contains(url), "{text}");
1873 assert!(text.contains("action required"), "{text}");
1874 assert!(crate::report::line(&state).contains("release needs a human"));
1875
1876 assert_eq!(n.severity, Severity::Error);
1878 assert_eq!(
1879 n.link,
1880 Some(Link::Url {
1881 url: url.to_owned()
1882 })
1883 );
1884 assert_eq!(store.list().len(), 1);
1885 assert!(questions.open_for(&state.id).is_empty());
1886
1887 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
1889 let listed = store.list();
1890 assert_eq!(listed.len(), 1);
1891 assert_eq!(listed[0].count, 2);
1892 }
1893
1894 #[test]
1895 fn a_bump_that_never_ran_is_surfaced_without_a_pr_comment() {
1896 let dir = tempfile::tempdir().unwrap();
1897 let store = Notices::at(dir.path().join("notifications"));
1898 let mut state = merged_state();
1899 let (n, comment) = surface_problem(&mut state, &store, None, None, "no agent").unwrap();
1900 assert!(comment.is_none());
1901 assert!(matches!(n.link, Some(Link::Run { .. })));
1902 assert!(state.needs_attention());
1903 }
1904
1905 #[test]
1906 fn version_parses_and_bumps_each_digit() {
1907 let v = Version::parse("0.4.0").unwrap();
1908 assert_eq!(
1909 v,
1910 Version {
1911 major: 0,
1912 minor: 4,
1913 patch: 0
1914 }
1915 );
1916
1917 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
1918 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
1919 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
1920 }
1921
1922 #[test]
1923 fn version_tolerates_a_prerelease_suffix_on_patch() {
1924 let v = Version::parse("1.2.3-rc1").unwrap();
1925 assert_eq!(
1926 v,
1927 Version {
1928 major: 1,
1929 minor: 2,
1930 patch: 3
1931 }
1932 );
1933 }
1934
1935 #[test]
1936 fn version_rejects_garbage() {
1937 assert!(Version::parse("not-a-version").is_err());
1938 assert!(Version::parse("1.2").is_err());
1939 }
1940
1941 #[test]
1942 fn decision_parses_each_level() {
1943 for (json, level) in [
1944 (
1945 r#"{"level":"major","reason":"drops a config key"}"#,
1946 BumpLevel::Major,
1947 ),
1948 (
1949 r#"{"level":"minor","reason":"adds a new flag"}"#,
1950 BumpLevel::Minor,
1951 ),
1952 (
1953 r#"{"level":"patch","reason":"fixes a race"}"#,
1954 BumpLevel::Patch,
1955 ),
1956 ] {
1957 let decision = parse_decision(json).unwrap();
1958 assert_eq!(decision.level, level);
1959 assert!(!decision.reason.is_empty());
1960 }
1961 }
1962
1963 #[test]
1964 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
1965 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
1966 let decision = parse_decision(text).unwrap();
1967 assert_eq!(decision.level, BumpLevel::Minor);
1968 assert_eq!(decision.reason, "new HTTP route");
1969 }
1970
1971 #[test]
1972 fn a_broken_reply_is_an_error_not_a_default() {
1973 assert!(parse_decision("I decline to answer.").is_err());
1974 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
1975 assert!(
1976 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
1977 "an empty reason must not pass either"
1978 );
1979 assert!(
1980 parse_decision(r#"{"level":"patch"}"#).is_err(),
1981 "a reply with no reason at all must not pass"
1982 );
1983 }
1984
1985 #[test]
1986 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
1987 let prompt = decision_prompt(
1988 "feat: add a phone endpoint",
1989 "add POST /api/widgets",
1990 "1 file changed, 10 insertions(+)",
1991 &["src/web.rs".to_owned()],
1992 "0.8.0",
1993 );
1994 assert!(prompt.contains("0.8.0"), "the current version is stated");
1995 assert!(
1996 prompt.contains("below `1.0.0`")
1997 && prompt.contains("`minor` is the digit that carries a breaking change"),
1998 "the 0.x rule must be explicit: {prompt}"
1999 );
2000 assert!(
2001 prompt.contains("choose the larger"),
2002 "the tie-break toward the bigger digit must be explicit: {prompt}"
2003 );
2004 }
2005
2006 #[test]
2007 fn release_only_diffs_are_recognised() {
2008 assert!(is_release_only(&["Cargo.toml".to_owned()]));
2009 assert!(is_release_only(&[
2010 "Cargo.toml".to_owned(),
2011 "Cargo.lock".to_owned()
2012 ]));
2013 assert!(!is_release_only(&[]));
2014 assert!(!is_release_only(&[
2015 "Cargo.toml".to_owned(),
2016 "src/main.rs".to_owned()
2017 ]));
2018 }
2019
2020 #[test]
2021 fn cargo_version_rewrite_touches_only_the_package_table() {
2022 let toml = "\
2023[package]\n\
2024# a comment mentioning version on purpose\n\
2025name = \"magi-cli\"\n\
2026version = \"0.8.0\"\n\
2027edition = \"2024\"\n\
2028\n\
2029[dependencies]\n\
2030foo = { version = \"1.2.3\" }\n";
2031 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
2032 assert!(out.contains("version = \"0.9.0\""));
2033 assert!(
2034 out.contains("foo = { version = \"1.2.3\" }"),
2035 "a dependency's own version pin must survive: {out}"
2036 );
2037 assert!(
2038 out.contains("# a comment mentioning version on purpose"),
2039 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
2040 );
2041 assert_eq!(
2042 out.lines().count(),
2043 toml.lines().count(),
2044 "the rewrite replaces one line, it does not add or remove any"
2045 );
2046 }
2047
2048 #[test]
2049 fn cargo_version_rewrite_fails_without_a_package_table() {
2050 let toml = "[dependencies]\nfoo = \"1\"\n";
2051 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
2052 }
2053
2054 #[test]
2060 fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
2061 let toml = "\
2062[workspace]\n\
2063members = [\"crates/a\", \"crates/b\"]\n\
2064\n\
2065[workspace.package]\n\
2066version = \"0.45.18\"\n\
2067edition = \"2024\"\n\
2068\n\
2069[workspace.dependencies]\n\
2070foo = { version = \"1.2.3\" }\n";
2071 let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
2072 assert!(out.contains("version = \"0.45.19\""));
2073 assert!(
2074 out.contains("foo = { version = \"1.2.3\" }"),
2075 "a workspace dependency's own version pin must survive: {out}"
2076 );
2077 assert_eq!(
2078 out.lines().count(),
2079 toml.lines().count(),
2080 "the rewrite replaces one line, it does not add or remove any"
2081 );
2082 }
2083
2084 #[test]
2085 fn current_version_prefers_the_package_table_when_both_exist() {
2086 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
2087 assert_eq!(current_version(toml).unwrap(), "0.8.0");
2088 }
2089
2090 #[test]
2093 fn current_version_falls_back_to_workspace_package_without_a_package_table() {
2094 let toml = "\
2095[workspace]\n\
2096members = [\"crates/a\", \"crates/b\"]\n\
2097\n\
2098[workspace.package]\n\
2099version = \"0.45.18\"\n";
2100 assert_eq!(current_version(toml).unwrap(), "0.45.18");
2101 }
2102
2103 #[test]
2104 fn coalesce_proceeds_with_nothing_pending() {
2105 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
2106 }
2107
2108 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
2111 PendingBump {
2112 target_version: target_version.to_owned(),
2113 level,
2114 branch: format!("chore/release-v{target_version}"),
2115 pr_url: "https://example.invalid/pull/9".to_owned(),
2116 }
2117 }
2118
2119 #[test]
2120 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
2121 let pending = test_pending("0.9.0", BumpLevel::Minor);
2122 assert_eq!(
2123 coalesce(Some(&pending), "0.8.0").unwrap(),
2124 Coalesce::Skip {
2125 target_version: "0.9.0".to_owned()
2126 }
2127 );
2128 }
2129
2130 #[test]
2131 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
2132 let pending = test_pending("0.9.0", BumpLevel::Minor);
2133 assert_eq!(
2135 coalesce(Some(&pending), "0.9.0").unwrap(),
2136 Coalesce::Proceed
2137 );
2138 assert_eq!(
2140 coalesce(Some(&pending), "1.0.0").unwrap(),
2141 Coalesce::Proceed
2142 );
2143 }
2144
2145 #[test]
2146 fn pending_action_escalates_only_for_a_more_severe_decision() {
2147 assert_eq!(
2148 pending_action(BumpLevel::Patch, BumpLevel::Patch),
2149 PendingAction::AlreadyCovered
2150 );
2151 assert_eq!(
2152 pending_action(BumpLevel::Patch, BumpLevel::Minor),
2153 PendingAction::Escalate
2154 );
2155 assert_eq!(
2156 pending_action(BumpLevel::Patch, BumpLevel::Major),
2157 PendingAction::Escalate
2158 );
2159 assert_eq!(
2160 pending_action(BumpLevel::Minor, BumpLevel::Patch),
2161 PendingAction::AlreadyCovered
2162 );
2163 assert_eq!(
2164 pending_action(BumpLevel::Major, BumpLevel::Minor),
2165 PendingAction::AlreadyCovered
2166 );
2167 assert_eq!(
2168 pending_action(BumpLevel::Major, BumpLevel::Major),
2169 PendingAction::AlreadyCovered
2170 );
2171 }
2172
2173 #[test]
2174 fn pr_state_parsing_reads_open_and_not_open() {
2175 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
2176 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
2177 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
2178 }
2179
2180 #[test]
2181 fn a_lock_is_exclusive_until_dropped() {
2182 let dir = tempfile::tempdir().unwrap();
2183 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2184 let first = MarkerLock::acquire(&marker)
2185 .unwrap()
2186 .expect("first attempt takes the lock");
2187 assert!(
2188 MarkerLock::acquire(&marker).unwrap().is_none(),
2189 "a second attempt must be refused while the first holds it"
2190 );
2191 drop(first);
2192 assert!(
2193 MarkerLock::acquire(&marker).unwrap().is_some(),
2194 "dropping the guard releases the lock for the next attempt"
2195 );
2196 }
2197
2198 #[test]
2199 fn a_stale_lock_is_reclaimed() {
2200 let dir = tempfile::tempdir().unwrap();
2201 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2202 let lock_path = marker.with_extension("lock");
2203 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
2204 std::fs::write(&lock_path, b"").unwrap();
2205 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
2206 std::fs::OpenOptions::new()
2207 .write(true)
2208 .open(&lock_path)
2209 .unwrap()
2210 .set_modified(old)
2211 .unwrap();
2212 assert!(
2213 MarkerLock::acquire(&marker).unwrap().is_some(),
2214 "a lock older than the stale window must be reclaimed rather than block forever"
2215 );
2216 }
2217
2218 #[tokio::test]
2219 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
2220 let dir = tempfile::tempdir().unwrap();
2221 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2222 let held = MarkerLock::acquire(&marker)
2223 .unwrap()
2224 .expect("seed the contention");
2225 let releaser = tokio::spawn(async move {
2226 tokio::time::sleep(Duration::from_millis(20)).await;
2227 drop(held);
2228 });
2229 let waited =
2230 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
2231 .await
2232 .unwrap();
2233 assert!(
2234 waited.is_some(),
2235 "a merge landing behind another's still-running decision must not be dropped - it \
2236 must wait for that decision to finish and then judge against what it left behind"
2237 );
2238 releaser.await.unwrap();
2239 }
2240
2241 #[tokio::test]
2242 async fn a_lock_held_past_the_ceiling_gives_up() {
2243 let dir = tempfile::tempdir().unwrap();
2244 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2245 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
2246 let waited =
2247 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
2248 .await
2249 .unwrap();
2250 assert!(
2251 waited.is_none(),
2252 "a lock genuinely held past the ceiling must eventually give up rather than wait \
2253 forever"
2254 );
2255 }
2256
2257 #[test]
2258 fn level_between_reads_off_the_differing_digit() {
2259 assert_eq!(
2260 level_between(
2261 Version::parse("0.8.0").unwrap(),
2262 Version::parse("1.0.0").unwrap()
2263 ),
2264 Some(BumpLevel::Major)
2265 );
2266 assert_eq!(
2267 level_between(
2268 Version::parse("0.8.0").unwrap(),
2269 Version::parse("0.9.0").unwrap()
2270 ),
2271 Some(BumpLevel::Minor)
2272 );
2273 assert_eq!(
2274 level_between(
2275 Version::parse("0.8.0").unwrap(),
2276 Version::parse("0.8.1").unwrap()
2277 ),
2278 Some(BumpLevel::Patch)
2279 );
2280 assert_eq!(
2281 level_between(
2282 Version::parse("0.8.0").unwrap(),
2283 Version::parse("0.8.0").unwrap()
2284 ),
2285 None
2286 );
2287 }
2288
2289 #[test]
2290 fn open_release_pr_is_found_among_unrelated_pull_requests() {
2291 let json = r#"[
2292 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
2293 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
2294 ]"#;
2295 let found = parse_open_release_pr(json).unwrap();
2296 assert_eq!(
2297 found,
2298 Some((
2299 "chore/release-v0.9.0".to_owned(),
2300 "https://example.invalid/pull/2".to_owned()
2301 ))
2302 );
2303 }
2304
2305 #[test]
2306 fn no_open_release_pr_reads_as_none_not_an_error() {
2307 let json =
2308 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
2309 assert_eq!(parse_open_release_pr(json).unwrap(), None);
2310 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
2311 }
2312
2313 #[test]
2314 fn marker_round_trips_through_disk() {
2315 let dir = tempfile::tempdir().unwrap();
2316 let path = marker_path(dir.path(), Path::new("/repos/magi"));
2317 assert!(read_marker(&path).is_none());
2318
2319 let marker = test_pending("0.9.0", BumpLevel::Patch);
2320 write_marker(&path, &marker).unwrap();
2321 let read_back = read_marker(&path).unwrap();
2322 assert_eq!(read_back.target_version, "0.9.0");
2323 assert_eq!(read_back.level, BumpLevel::Patch);
2324 assert_eq!(read_back.pr_url, marker.pr_url);
2325
2326 clear_marker(&path);
2327 assert!(read_marker(&path).is_none());
2328 }
2329
2330 #[test]
2331 fn different_repos_get_different_marker_files() {
2332 let dir = tempfile::tempdir().unwrap();
2333 let a = marker_path(dir.path(), Path::new("/repos/a"));
2334 let b = marker_path(dir.path(), Path::new("/repos/b"));
2335 assert_ne!(a, b);
2336 }
2337
2338 #[test]
2342 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
2343 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
2344 assert!(
2345 is_release_only(&files),
2346 "the bump pull request's own diff must read as release-only"
2347 );
2348 }
2349
2350 #[test]
2351 fn should_release_bump_reads_only_a_merged_status() {
2352 assert!(should_release_bump(RunStatus::Merged));
2353 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
2354 assert!(!should_release_bump(other));
2355 }
2356 }
2357
2358 #[test]
2362 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
2363 let pr = land::PrState {
2364 url: "https://github.com/o/r/pull/1".to_owned(),
2365 number: 1,
2366 state: PrLifecycle::Merged,
2367 checks: land::Checks::Green,
2368 failing: Vec::new(),
2369 review_comments: Vec::new(),
2370 blocking: land::Blocking::No,
2371 };
2372 assert_eq!(
2373 land::decide(&pr, 0, 4, Duration::ZERO),
2374 land::Step::Done { merged: true }
2375 );
2376 assert!(should_release_bump(RunStatus::Merged));
2377 }
2378
2379 #[test]
2384 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
2385 let pr = land::PrState {
2386 url: "https://github.com/o/r/pull/2".to_owned(),
2387 number: 2,
2388 state: PrLifecycle::Open,
2389 checks: land::Checks::Green,
2390 failing: Vec::new(),
2391 review_comments: Vec::new(),
2392 blocking: land::Blocking::No,
2393 };
2394 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
2395 assert!(should_release_bump(RunStatus::Merged));
2398 }
2399
2400 #[test]
2403 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
2404 let argv = land::merge_argv(3, "feat: something");
2405 let outcome = land::merged_after_all(
2406 &argv,
2407 "could not determine current branch: not on any branch",
2408 Some(PrLifecycle::Merged),
2409 );
2410 assert!(outcome.is_some(), "the forge's confirmation must win");
2411 assert!(should_release_bump(RunStatus::Merged));
2412
2413 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
2416 assert!(land::merged_after_all(&argv, "network error", None).is_none());
2417 }
2418
2419 #[test]
2421 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
2422 let pr = land::PrState {
2423 url: "https://github.com/o/r/pull/4".to_owned(),
2424 number: 4,
2425 state: PrLifecycle::Closed,
2426 checks: land::Checks::Green,
2427 failing: Vec::new(),
2428 review_comments: Vec::new(),
2429 blocking: land::Blocking::No,
2430 };
2431 assert_eq!(
2432 land::decide(&pr, 0, 4, Duration::ZERO),
2433 land::Step::Done { merged: false }
2434 );
2435 assert!(!should_release_bump(RunStatus::Blocked));
2436 }
2437}