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