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
247fn strip_trailing_comment(s: &str) -> &str {
253 let mut in_string = false;
254 let mut quote = '"';
255 for (i, c) in s.char_indices() {
256 if in_string {
257 if c == quote {
258 in_string = false;
259 }
260 } else if c == '"' || c == '\'' {
261 in_string = true;
262 quote = c;
263 } else if c == '#' {
264 return s[..i].trim_end();
265 }
266 }
267 s
268}
269
270fn unquote_key(key: &str) -> &str {
276 for quote in ['"', '\''] {
277 if let Some(inner) = key.strip_prefix(quote).and_then(|k| k.strip_suffix(quote)) {
278 return inner;
279 }
280 }
281 key
282}
283
284fn extract_workspace_dependencies_fragment(toml: &str) -> String {
296 let mut fragment = String::new();
297 let mut capturing = false;
298 for line in toml.split_inclusive('\n') {
299 let header = strip_trailing_comment(line.trim());
300 if header.starts_with('[') {
301 if header == "[workspace.dependencies]" {
302 capturing = true;
303 fragment.push_str("[dependencies]\n");
304 } else if let Some(name) = header
305 .strip_prefix("[workspace.dependencies.")
306 .and_then(|rest| rest.strip_suffix(']'))
307 {
308 capturing = true;
309 let _ = writeln!(fragment, "[dependencies.{name}]");
310 } else {
311 capturing = false;
312 }
313 continue;
314 }
315 if capturing {
316 fragment.push_str(line);
317 }
318 }
319 fragment
320}
321
322fn internal_pin_names(toml: &str) -> Result<Vec<String>> {
340 let fragment = extract_workspace_dependencies_fragment(toml);
341 if fragment.trim().is_empty() {
342 return Ok(Vec::new());
343 }
344 let value: toml::Value =
345 toml::from_str(&fragment).context("failed to parse `[workspace.dependencies]` as TOML")?;
346 let mut names: Vec<String> = value
347 .get("dependencies")
348 .and_then(|d| d.as_table())
349 .into_iter()
350 .flatten()
351 .filter(|(_, dep)| {
352 dep.as_table()
353 .is_some_and(|t| t.contains_key("path") && t.contains_key("version"))
354 })
355 .map(|(name, _)| name.clone())
356 .collect();
357 names.sort();
358 Ok(names)
359}
360
361fn rewrite_quoted_field(line: &str, key: &str, new_value: &str) -> Option<String> {
371 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-';
372 let mut search_from = 0;
373 while let Some(rel) = line[search_from..].find(key) {
374 let key_start = search_from + rel;
375 let before_ok = key_start == 0 || !is_ident(line[..key_start].chars().next_back().unwrap());
376 let eq_pos = key_start
377 + key.len()
378 + (line[key_start + key.len()..].len()
379 - line[key_start + key.len()..].trim_start().len());
380 if before_ok && line[eq_pos..].starts_with('=') {
381 let after_eq = &line[eq_pos + 1..];
382 let ws_len = after_eq.len() - after_eq.trim_start().len();
383 let quote_pos = eq_pos + 1 + ws_len;
384 if let Some(quote_char) = line[quote_pos..]
385 .chars()
386 .next()
387 .filter(|c| *c == '"' || *c == '\'')
388 {
389 let value_begin = quote_pos + quote_char.len_utf8();
390 if let Some(end_rel) = line[value_begin..].find(quote_char) {
391 let value_end = value_begin + end_rel;
392 let mut out = String::with_capacity(line.len());
393 out.push_str(&line[..value_begin]);
394 out.push_str(new_value);
395 out.push_str(&line[value_end..]);
396 return Some(out);
397 }
398 }
399 }
400 search_from = key_start + key.len();
401 }
402 None
403}
404
405fn rewrite_workspace_dependency_pins(
420 toml: &str,
421 names: &[String],
422 new_version: &str,
423) -> Result<String> {
424 if names.is_empty() {
425 return Ok(toml.to_owned());
426 }
427 let mut out = String::with_capacity(toml.len() + names.len() * 8);
428 let mut in_flat_table = false;
429 let mut in_named_table: Option<String> = None;
430 let mut rewritten: std::collections::HashSet<String> = std::collections::HashSet::new();
431
432 for line in toml.split_inclusive('\n') {
433 let trimmed = line.trim();
434 if trimmed.starts_with('[') {
435 let header = strip_trailing_comment(trimmed);
436 in_flat_table = header == "[workspace.dependencies]";
437 in_named_table = header
438 .strip_prefix("[workspace.dependencies.")
439 .and_then(|rest| rest.strip_suffix(']'))
440 .map(str::to_owned);
441 out.push_str(line);
442 continue;
443 }
444
445 let key = trimmed.split('=').next().map(str::trim).map(unquote_key);
446
447 if in_flat_table {
448 if let Some(name) = key.and_then(|k| names.iter().find(|n| n.as_str() == k)) {
450 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
451 rewritten.insert(name.clone());
452 out.push_str(&rewritten_line);
453 continue;
454 }
455 } else if let Some(name) = key.and_then(|k| {
456 k.strip_suffix(".version")
457 .and_then(|prefix| names.iter().find(|n| n.as_str() == prefix))
458 }) {
459 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
464 rewritten.insert(name.clone());
465 out.push_str(&rewritten_line);
466 continue;
467 }
468 }
469 } else if let Some(table_name) = &in_named_table {
470 if key == Some("version") && names.iter().any(|n| n == table_name) {
471 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
472 rewritten.insert(table_name.clone());
473 out.push_str(&rewritten_line);
474 continue;
475 }
476 }
477 }
478
479 out.push_str(line);
480 }
481
482 for name in names {
483 if !rewritten.contains(name) {
484 bail!(
485 "could not rewrite the `[workspace.dependencies]` version pin \
486 for `{name}` - its `version` field was not found in a shape \
487 this rewrite understands"
488 );
489 }
490 }
491 Ok(out)
492}
493
494fn verify_pins_rewritten(toml: &str, names: &[String], new_version: &str) -> Result<()> {
502 let fragment = extract_workspace_dependencies_fragment(toml);
503 let value: toml::Value = toml::from_str(&fragment)
504 .context("rewritten `[workspace.dependencies]` failed to parse")?;
505 let deps = value.get("dependencies").and_then(|d| d.as_table());
506 for name in names {
507 let actual = deps
508 .and_then(|d| d.get(name))
509 .and_then(|dep| dep.as_table())
510 .and_then(|t| t.get("version"))
511 .and_then(|v| v.as_str());
512 if actual != Some(new_version) {
513 bail!(
514 "the `[workspace.dependencies]` version pin for `{name}` did \
515 not end up at `{new_version}` after the rewrite"
516 );
517 }
518 }
519 Ok(())
520}
521
522pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
544 let rewritten = rewrite_table_version(toml, "[package]", new_version)
545 .or_else(|_| rewrite_table_version(toml, "[workspace.package]", new_version))
546 .context("no `version` field found under `[package]` or `[workspace.package]`")?;
547
548 let pin_names = internal_pin_names(&rewritten)?;
549 if pin_names.is_empty() {
550 return Ok(rewritten);
551 }
552
553 let rewritten = rewrite_workspace_dependency_pins(&rewritten, &pin_names, new_version)?;
554 verify_pins_rewritten(&rewritten, &pin_names, new_version)?;
555 Ok(rewritten)
556}
557
558fn version_in_table(toml: &str, table: &str) -> Option<String> {
560 let mut in_table = false;
561 for line in toml.lines() {
562 let trimmed = line.trim();
563 if trimmed.starts_with('[') {
564 in_table = trimmed == table;
565 continue;
566 }
567 if !in_table {
568 continue;
569 }
570 let mut parts = trimmed.splitn(2, '=');
571 let key = parts.next().map(str::trim);
572 let Some(value) = parts.next() else {
573 continue;
574 };
575 if key == Some("version") {
576 return Some(value.trim().trim_matches('"').to_owned());
577 }
578 }
579 None
580}
581
582fn current_version(toml: &str) -> Result<String> {
587 version_in_table(toml, "[package]")
588 .or_else(|| version_in_table(toml, "[workspace.package]"))
589 .context("no `version` field found under `[package]` or `[workspace.package]`")
590}
591
592pub fn decision_prompt(
602 subject: &str,
603 instruction: &str,
604 diffstat: &str,
605 files: &[String],
606 current_version: &str,
607) -> String {
608 let mut s = format!(
609 "A pull request just merged into the base branch. Decide which digit \
610 of this project's `major.minor.patch` version this change earns, so \
611 a release bump can be opened for exactly it.\n\n\
612 Current version: {current_version}\n\n\
613 # Merge subject\n\n{subject}\n\n\
614 # The task that produced it\n\n{instruction}\n\n\
615 # Files changed ({} total)\n\n",
616 files.len()
617 );
618 const MAX_FILES: usize = 50;
619 for f in files.iter().take(MAX_FILES) {
620 let _ = writeln!(s, "- {f}");
621 }
622 if files.len() > MAX_FILES {
623 let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
624 }
625 let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
626
627 s.push_str(
628 "\n# How to decide\n\n\
629 This project is below version `1.0.0`. At that stage **`minor` is \
630 the digit that carries a breaking change** - do not spend `major` \
631 below `1.0.0`.\n\n\
632 A change is breaking, and earns `minor`, when it changes any of: \
633 the public API reachable from `src/lib.rs`, a CLI subcommand or \
634 flag, an HTTP API route or response shape, a configuration key, or \
635 the on-disk shape of persisted state.\n\n\
636 A user-visible new capability that breaks none of the above also \
637 earns `minor`.\n\n\
638 A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
639 **When it is not obvious which digit applies, choose the larger \
640 one.** An oversized bump costs nothing; a breaking change shipped as \
641 `patch` breaks every downstream update that pins a range.\n\n\
642 # Output\n\n\
643 Reply with exactly one fenced JSON object and nothing that matters \
644 outside it:\n\n\
645 ```json\n\
646 {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
647 ```\n",
648 );
649 let _ = write!(
651 s,
652 "\n{}\n\nThe `reason` goes into a GitHub pull request body, so write it \
653 in English.\n",
654 crate::prompt::GITHUB_ENGLISH_HEADING
655 );
656 s
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct PendingBump {
676 pub target_version: String,
678 pub level: BumpLevel,
681 pub branch: String,
684 pub pr_url: String,
687}
688
689pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
693 let key = repo.to_string_lossy();
694 home.join("bump")
695 .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
696}
697
698pub fn read_marker(path: &Path) -> Option<PendingBump> {
702 let body = std::fs::read_to_string(path).ok()?;
703 serde_json::from_str(&body).ok()
704}
705
706pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
710 if let Some(parent) = path.parent() {
711 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
712 }
713 let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
714 let tmp = path.with_extension("json.tmp");
715 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
716 std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
717 Ok(())
718}
719
720pub fn clear_marker(path: &Path) {
723 let _ = std::fs::remove_file(path);
724}
725
726#[derive(Debug, Clone, PartialEq, Eq)]
730pub enum Coalesce {
731 Proceed,
734 Skip {
736 target_version: String,
738 },
739}
740
741pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
743 let Some(pending) = pending else {
744 return Ok(Coalesce::Proceed);
745 };
746 let current = Version::parse(current_version)?;
747 let target = Version::parse(&pending.target_version)?;
748 if current >= target {
749 return Ok(Coalesce::Proceed);
750 }
751 Ok(Coalesce::Skip {
752 target_version: pending.target_version.clone(),
753 })
754}
755
756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
758pub enum PendingAction {
759 AlreadyCovered,
762 Escalate,
765}
766
767pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
776 if decision_level.severity() > pending_level.severity() {
777 PendingAction::Escalate
778 } else {
779 PendingAction::AlreadyCovered
780 }
781}
782
783fn parse_pr_state(json: &str) -> Result<bool> {
785 #[derive(Deserialize)]
786 struct State {
787 state: String,
788 }
789 let parsed: State =
790 serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
791 Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
792}
793
794async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
807 let out = tokio::process::Command::new("gh")
808 .args(["pr", "view", pr_url, "--json", "state"])
809 .current_dir(repo)
810 .quiet()
811 .stdin(std::process::Stdio::null())
812 .output()
813 .await
814 .context("spawn gh pr view")?;
815 if !out.status.success() {
816 bail!(
817 "gh pr view {pr_url}: {}",
818 String::from_utf8_lossy(&out.stderr).trim()
819 );
820 }
821 parse_pr_state(&String::from_utf8_lossy(&out.stdout))
822}
823
824const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
832
833struct MarkerLock {
845 path: PathBuf,
846}
847
848impl MarkerLock {
849 fn acquire(marker: &Path) -> Result<Option<Self>> {
853 let path = marker.with_extension("lock");
854 if let Some(parent) = path.parent() {
855 std::fs::create_dir_all(parent)
856 .with_context(|| format!("create {}", parent.display()))?;
857 }
858 if Self::try_create(&path)? {
859 return Ok(Some(Self { path }));
860 }
861 if Self::is_stale(&path) {
862 let _ = std::fs::remove_file(&path);
863 if Self::try_create(&path)? {
864 return Ok(Some(Self { path }));
865 }
866 }
867 Ok(None)
868 }
869
870 fn try_create(path: &Path) -> Result<bool> {
871 match std::fs::OpenOptions::new()
872 .write(true)
873 .create_new(true)
874 .open(path)
875 {
876 Ok(_) => Ok(true),
877 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
878 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
879 }
880 }
881
882 fn is_stale(path: &Path) -> bool {
883 std::fs::metadata(path)
884 .and_then(|m| m.modified())
885 .ok()
886 .and_then(|m| m.elapsed().ok())
887 .is_some_and(|age| age >= LOCK_STALE_AFTER)
888 }
889}
890
891impl Drop for MarkerLock {
892 fn drop(&mut self) {
893 let _ = std::fs::remove_file(&self.path);
894 }
895}
896
897const LOCK_POLL: Duration = Duration::from_secs(5);
899
900const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
913
914async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
917 wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
918}
919
920async fn wait_for_marker_lock_with(
924 marker: &Path,
925 poll: Duration,
926 ceiling: Duration,
927) -> Result<Option<MarkerLock>> {
928 let mut waited = Duration::ZERO;
929 loop {
930 if let Some(lock) = MarkerLock::acquire(marker)? {
931 return Ok(Some(lock));
932 }
933 if waited >= ceiling {
934 return Ok(None);
935 }
936 tokio::time::sleep(poll).await;
937 waited += poll;
938 }
939}
940
941fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
949 if to.major != from.major {
950 Some(BumpLevel::Major)
951 } else if to.minor != from.minor {
952 Some(BumpLevel::Minor)
953 } else if to.patch != from.patch {
954 Some(BumpLevel::Patch)
955 } else {
956 None
957 }
958}
959
960fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
963 #[derive(Deserialize)]
964 struct Pr {
965 url: String,
966 #[serde(rename = "headRefName")]
967 head_ref_name: String,
968 }
969 let list: Vec<Pr> =
970 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
971 Ok(list
972 .into_iter()
973 .find(|p| p.head_ref_name.starts_with("chore/release-v"))
974 .map(|p| (p.head_ref_name, p.url)))
975}
976
977async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
992 let out = tokio::process::Command::new("gh")
993 .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
994 .current_dir(repo)
995 .quiet()
996 .stdin(std::process::Stdio::null())
997 .output()
998 .await
999 .context("spawn gh pr list")?;
1000 if !out.status.success() {
1001 bail!(
1002 "gh pr list: {}",
1003 String::from_utf8_lossy(&out.stderr).trim()
1004 );
1005 }
1006 parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
1007}
1008
1009pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
1018 after_merge_at(state, pr_url, None).await
1019}
1020
1021async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
1027 let out = git::git(
1028 repo,
1029 &[
1030 "ls-tree",
1031 "--name-only",
1032 &format!("{remote}/{base}"),
1033 "--",
1034 "Cargo.toml",
1035 ],
1036 )
1037 .await
1038 .context("look for Cargo.toml on the base branch")?;
1039 Ok(!out.trim().is_empty())
1040}
1041
1042async fn after_merge_at(state: &mut RunState, pr_url: &str, home: Option<&Path>) -> Result<()> {
1045 if !state.config.merge.release_bump {
1046 return Ok(());
1047 }
1048 let Some(winner) = state.winner().cloned() else {
1049 return Ok(());
1050 };
1051 let repo = state.repo.clone();
1052 let base = state.base_branch.clone();
1053 let remote = state.config.merge.remote.clone();
1054
1055 let files = git::changed_files(&winner.worktree, &base, &winner.branch)
1056 .await
1057 .unwrap_or_default();
1058 if is_release_only(&files) {
1059 state.event(
1060 "bump",
1061 "the merged change touches only the release manifest; not treating it as a trigger",
1062 );
1063 return Ok(());
1064 }
1065
1066 git::fetch(&repo, &remote, &base).await.ok();
1069 if !base_has_cargo_toml(&repo, &remote, &base).await? {
1070 state.event(
1071 "bump",
1072 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping",
1073 );
1074 return Ok(());
1075 }
1076
1077 let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
1078 let Some(_lock) = wait_for_marker_lock(&marker).await? else {
1085 state.event(
1086 "bump",
1087 "another release bump decision held the lock past the wait ceiling; skipping this round",
1088 );
1089 return Ok(());
1090 };
1091
1092 git::fetch(&repo, &remote, &base).await.ok();
1093 let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
1094 .await
1095 .context("read Cargo.toml from the base branch")?;
1096 let base_version = current_version(&cargo_toml)?;
1097
1098 let mut pending = read_marker(&marker);
1099 if let Some(p) = &pending {
1100 match coalesce(Some(p), &base_version)? {
1101 Coalesce::Proceed => {
1102 clear_marker(&marker);
1105 pending = None;
1106 }
1107 Coalesce::Skip { target_version } => {
1108 if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
1109 state.event(
1110 "bump",
1111 format!(
1112 "the pending release bump to v{target_version} ({}) is no longer \
1113 open; treating it as abandoned",
1114 p.pr_url
1115 ),
1116 );
1117 clear_marker(&marker);
1118 pending = None;
1119 }
1120 }
1125 }
1126 }
1127
1128 if pending.is_none() {
1129 if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
1134 && let Some(target) = branch
1135 .strip_prefix("chore/release-v")
1136 .and_then(|v| Version::parse(v).ok())
1137 {
1138 let base_parsed = Version::parse(&base_version)?;
1139 if target > base_parsed
1140 && let Some(level) = level_between(base_parsed, target)
1141 {
1142 let adopted = PendingBump {
1143 target_version: target.to_string(),
1144 level,
1145 branch,
1146 pr_url: url,
1147 };
1148 let _ = write_marker(&marker, &adopted);
1151 pending = Some(adopted);
1152 }
1153 }
1154 }
1155
1156 let title = pr_title(&repo, pr_url).await.unwrap_or_default();
1157 let subject = land::merge_subject(&title, &state.instruction);
1158 let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
1159 .await
1160 .unwrap_or_default();
1161 let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
1162
1163 let spec: AgentSpec = agent::pick(
1169 &state.config.agents,
1170 state.config.roles.chatter.as_deref(),
1171 &agent::installed,
1172 )
1173 .context("choose an agent for the release-bump decision")?;
1174 let mut seat = SeatState::new("bump", &spec.id, state.seed);
1175 let artifacts = agent::artifacts_dir(&state.dir());
1176 let out = agent::invoke(
1177 &spec,
1178 &mut seat,
1179 &Invocation {
1180 cwd: &repo,
1181 prompt: &prompt,
1182 timeout: DECISION_TIMEOUT,
1183 allow_write: false,
1186 sessions: false,
1187 artifacts: &artifacts,
1188 stem: "bump-decision",
1189 run: &state.id,
1190 node: "bump",
1191 cache_dir: state.config.cache_dir().as_deref(),
1192 attachments: &[],
1193 },
1194 )
1195 .await
1196 .context("ask an agent how big the merged change was")?;
1197 if !out.usable() {
1198 bail!(
1199 "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
1200 out.exit_code,
1201 out.timed_out
1202 );
1203 }
1204 let decision = parse_decision(&out.text).context("parse the release-bump decision")?;
1205
1206 if let Some(p) = pending {
1207 return match pending_action(p.level, decision.level) {
1208 PendingAction::AlreadyCovered => {
1209 state.event(
1210 "bump",
1211 format!(
1212 "a release bump to v{} ({}) already covers at least a {} change; not \
1213 opening another",
1214 p.target_version,
1215 p.pr_url,
1216 decision.level.as_str()
1217 ),
1218 );
1219 Ok(())
1220 }
1221 PendingAction::Escalate => {
1222 escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
1223 }
1224 };
1225 }
1226
1227 let next = Version::parse(&base_version)?
1228 .bump(decision.level)
1229 .to_string();
1230 let branch = format!("chore/release-v{next}");
1231 let worktree = state.dir().join("bump");
1232 git::worktree_remove(&repo, &worktree).await.ok();
1233 git::worktree_add_branch(&repo, &worktree, &branch, &format!("{remote}/{base}"))
1234 .await
1235 .context("create the release-bump worktree")?;
1236 let opened = open_bump_pr(state, &worktree, &branch, &next, &decision, pr_url).await;
1237 git::worktree_remove(&repo, &worktree).await.ok();
1241 let (pr_url_opened, outcome) = opened?;
1242 let (automerge_warning, merged_detail) = match outcome {
1243 AutomergeOutcome::Enabled => (None, None),
1244 AutomergeOutcome::MergedDirectly { detail } => (None, Some(detail)),
1245 AutomergeOutcome::Failed { reason } => (Some(reason), None),
1246 };
1247
1248 let marker_write = write_marker(
1257 &marker,
1258 &PendingBump {
1259 target_version: next.clone(),
1260 level: decision.level,
1261 branch,
1262 pr_url: pr_url_opened.clone(),
1263 },
1264 );
1265 state.event(
1266 "bump",
1267 format!(
1268 "opened a {} release bump to v{next} ({}): {pr_url_opened}",
1269 decision.level.as_str(),
1270 decision.reason
1271 ),
1272 );
1273 if let Err(e) = marker_write {
1274 state.event(
1275 "bump",
1276 format!(
1277 "could not record the pending release bump marker for v{next}: {e:#}; a later \
1278 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
1279 the forge either"
1280 ),
1281 );
1282 }
1283 state.release_bump = Some(run::ReleaseBump {
1284 pr_url: Some(pr_url_opened.clone()),
1285 version: Some(next.clone()),
1286 automerge_enabled: automerge_warning.is_none() && merged_detail.is_none(),
1287 merged_directly: merged_detail.is_some(),
1288 ..run::ReleaseBump::default()
1289 });
1290 if let Some(detail) = merged_detail {
1291 clear_marker(&marker);
1294 state.event("bump", format!("merged v{next} directly: {detail}"));
1295 }
1296 if let Some(warning) = automerge_warning {
1297 state.event(
1298 "bump",
1299 format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
1300 );
1301 report_problem(state, Some(&pr_url_opened), Some(&next), &warning).await;
1302 }
1303 Ok(())
1304}
1305
1306pub const NOTICE_NODE: &str = "release-bump";
1311
1312fn automerge_hint(reason: &str) -> &'static str {
1319 let r = reason.to_lowercase();
1320 if is_clean_status_refusal(reason) {
1321 "merge the release pull request by hand; CI is already green"
1322 } else if r.contains("enablepullrequestautomerge") || r.contains("protected branch rules") {
1323 "merge the release pull request by hand, and enable branch protection with required \
1324 status checks on the base branch so automerge can work next time"
1325 } else {
1326 "merge the release pull request by hand"
1327 }
1328}
1329
1330fn automerge_failure_comment(reason: &str) -> String {
1332 format!(
1333 "magi could not enable automerge on this pull request: {reason}\n\n\
1334 Action required: {}. Until then the release does not happen.",
1335 automerge_hint(reason)
1336 )
1337}
1338
1339fn surface_problem(
1347 state: &mut RunState,
1348 store: &Notices,
1349 pr_url: Option<&str>,
1350 version: Option<&str>,
1351 reason: &str,
1352) -> Result<(Notice, Option<String>)> {
1353 let action = if pr_url.is_some() {
1354 automerge_hint(reason).to_owned()
1355 } else {
1356 "the release bump did not run; open the release pull request by hand".to_owned()
1357 };
1358 let record = state.release_bump.get_or_insert_with(Default::default);
1359 record.pr_url = pr_url.map(str::to_owned).or(record.pr_url.take());
1360 record.version = version.map(str::to_owned).or(record.version.take());
1361 record.automerge_enabled = false;
1362 record.problem = Some(reason.to_owned());
1363 record.action_required = Some(action.clone());
1364
1365 let mut notice = Notice::error(
1366 &format!("release-bump:{}", state.id),
1367 format!(
1368 "Run {} merged, but its release step failed: {action}.",
1369 state.id
1370 ),
1371 );
1372 notice = match pr_url {
1373 Some(url) => notice.link(Link::Url {
1374 url: url.to_owned(),
1375 }),
1376 None => notice.link(Link::Run {
1377 id: state.id.clone(),
1378 }),
1379 };
1380 let notice = store
1381 .raise(notice)
1382 .context("raise the release-bump notification")?;
1383 state.event("bump", format!("needs attention: {action}"));
1384 let comment = pr_url.map(|_| automerge_failure_comment(reason));
1385 Ok((notice, comment))
1386}
1387
1388pub async fn report_problem(
1393 state: &mut RunState,
1394 pr_url: Option<&str>,
1395 version: Option<&str>,
1396 reason: &str,
1397) {
1398 match surface_problem(state, &Notices::open(), pr_url, version, reason) {
1399 Ok((notice, comment)) => {
1400 if let (Some(url), Some(body)) = (pr_url, comment)
1401 && let Err(e) = gh_pr_comment(
1402 &state.repo,
1403 url,
1404 &crate::scrub::scrub(&body, &crate::scrub::Identity::current()),
1405 )
1406 .await
1407 {
1408 state.event("bump", format!("could not comment on {url}: {e:#}"));
1409 }
1410 let summary = match pr_url {
1413 Some(url) => format!("Release PR needs a human: {url}"),
1414 None => "Release bump did not run".to_owned(),
1415 };
1416 let q = ask::Question::new(
1417 state.id.clone(),
1418 NOTICE_NODE.to_owned(),
1419 "bump".to_owned(),
1420 summary,
1421 notice.message.clone(),
1422 Vec::new(),
1423 );
1424 if let Err(e) = ask::notify(&state.config.notify, &q).await {
1425 tracing::warn!(
1426 "could not notify about the release bump of {}: {e:#}",
1427 state.id
1428 );
1429 }
1430 }
1431 Err(e) => state.event("bump", format!("could not raise a notice: {e:#}")),
1432 }
1433}
1434
1435async fn gh_pr_comment(cwd: &Path, pr_url: &str, body: &str) -> Result<()> {
1436 let out = tokio::process::Command::new("gh")
1437 .args(["pr", "comment", pr_url, "--body", body])
1438 .current_dir(cwd)
1439 .quiet()
1440 .stdin(std::process::Stdio::null())
1441 .output()
1442 .await
1443 .context("spawn gh pr comment")?;
1444 if out.status.success() {
1445 Ok(())
1446 } else {
1447 bail!(
1448 "gh pr comment: {}",
1449 String::from_utf8_lossy(&out.stderr).trim()
1450 )
1451 }
1452}
1453
1454async fn escalate_pending(
1463 state: &mut RunState,
1464 repo: &Path,
1465 remote: &str,
1466 pending: &PendingBump,
1467 decision: &BumpDecision,
1468 base_version: &str,
1469 marker: &Path,
1470) -> Result<()> {
1471 let next = Version::parse(base_version)?
1472 .bump(decision.level)
1473 .to_string();
1474 let worktree = state.dir().join("bump");
1475 git::worktree_remove(repo, &worktree).await.ok();
1476 let checked_out = git::git_raw(
1477 repo,
1478 &[
1479 "worktree",
1480 "add",
1481 "--force",
1482 &worktree.to_string_lossy(),
1483 &pending.branch,
1484 ],
1485 )
1486 .await?;
1487 if !checked_out.ok() {
1488 bail!(
1489 "checking out the pending release branch {} failed: {}",
1490 pending.branch,
1491 checked_out.stderr
1492 );
1493 }
1494
1495 let pushed: Result<()> = async {
1500 let cargo_toml_path = worktree.join("Cargo.toml");
1501 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1502 .await
1503 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1504 let rewritten = rewrite_cargo_version(&toml, &next)?;
1505 tokio::fs::write(&cargo_toml_path, rewritten)
1506 .await
1507 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1508 sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1509 let committed = git::commit_all(
1510 &worktree,
1511 &format!(
1512 "chore: release v{next} (supersedes v{})",
1513 pending.target_version
1514 ),
1515 )
1516 .await
1517 .context("commit the escalated version bump")?;
1518 if !committed {
1519 bail!("escalating the version bump left nothing to commit");
1520 }
1521 let pushed = git::push(&worktree, remote, &pending.branch).await?;
1522 if !pushed.ok() {
1523 bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1524 }
1525 Ok(())
1526 }
1527 .await;
1528 if let Err(e) = pushed {
1529 git::worktree_remove(repo, &worktree).await.ok();
1530 return Err(e);
1531 }
1532
1533 let title_warning = match gh_pr_edit_title(
1537 &worktree,
1538 &pending.pr_url,
1539 &crate::scrub::scrub(
1540 &format!("chore: release v{next} ({} bump)", decision.level.as_str()),
1541 &crate::scrub::Identity::current(),
1542 ),
1543 )
1544 .await
1545 {
1546 Ok(()) => None,
1547 Err(e) => Some(e.to_string()),
1548 };
1549 git::worktree_remove(repo, &worktree).await.ok();
1550
1551 let marker_write = write_marker(
1552 marker,
1553 &PendingBump {
1554 target_version: next.clone(),
1555 level: decision.level,
1556 branch: pending.branch.clone(),
1557 pr_url: pending.pr_url.clone(),
1558 },
1559 );
1560 state.event(
1561 "bump",
1562 format!(
1563 "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1564 pending.target_version,
1565 decision.level.as_str(),
1566 decision.reason,
1567 pending.pr_url
1568 ),
1569 );
1570 if let Err(e) = marker_write {
1571 state.event(
1572 "bump",
1573 format!(
1574 "could not update the pending release bump marker to v{next}: {e:#}; a later \
1575 merge may misjudge whether it is already covered"
1576 ),
1577 );
1578 }
1579 if let Some(warning) = title_warning {
1580 state.event(
1581 "bump",
1582 format!(
1583 "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1584 subject may still read the superseded version",
1585 pending.pr_url
1586 ),
1587 );
1588 }
1589 Ok(())
1590}
1591
1592async fn open_bump_pr(
1598 state: &RunState,
1599 worktree: &Path,
1600 branch: &str,
1601 next_version: &str,
1602 decision: &BumpDecision,
1603 source_pr_url: &str,
1604) -> Result<(String, AutomergeOutcome)> {
1605 let cargo_toml_path = worktree.join("Cargo.toml");
1606 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1607 .await
1608 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1609 let rewritten = rewrite_cargo_version(&toml, next_version)?;
1610 tokio::fs::write(&cargo_toml_path, rewritten)
1611 .await
1612 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1613
1614 sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
1615
1616 let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
1617 .await
1618 .context("commit the version bump")?;
1619 if !committed {
1620 bail!("the version bump left nothing to commit");
1621 }
1622
1623 let remote = state.config.merge.remote.clone();
1624 let pushed = git::push(worktree, &remote, branch).await?;
1625 if !pushed.ok() {
1626 bail!("pushing {branch} failed: {}", pushed.stderr);
1627 }
1628
1629 let (title, body) = release_pr(
1630 decision.level.as_str(),
1631 &decision.reason,
1632 next_version,
1633 &state.id,
1634 source_pr_url,
1635 );
1636 let who = crate::scrub::Identity::current();
1637 let (title, body) = (
1638 crate::scrub::scrub(&title, &who),
1639 crate::scrub::scrub(&body, &who),
1640 );
1641 let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
1642 let outcome = match gh_enable_automerge(worktree, &url).await {
1643 Ok(()) => AutomergeOutcome::Enabled,
1644 Err(e) => {
1645 let reason = e.to_string();
1646 if is_clean_status_refusal(&reason) {
1647 gh_merge_directly(worktree, &url, &title, reason).await
1648 } else {
1649 AutomergeOutcome::Failed { reason }
1650 }
1651 }
1652 };
1653 Ok((url, outcome))
1654}
1655
1656#[derive(Debug, Clone, PartialEq, Eq)]
1658enum AutomergeOutcome {
1659 Enabled,
1661 MergedDirectly { detail: String },
1663 Failed { reason: String },
1665}
1666
1667fn is_clean_status_refusal(reason: &str) -> bool {
1673 let r = reason.to_lowercase();
1674 r.contains("is in clean status") && r.contains("enablepullrequestautomerge")
1675}
1676
1677fn bump_merge_argv(pr_url: &str, subject: &str) -> Vec<String> {
1680 [
1681 "pr",
1682 "merge",
1683 pr_url,
1684 "--squash",
1685 "--delete-branch",
1686 "--subject",
1687 subject,
1688 ]
1689 .map(str::to_owned)
1690 .to_vec()
1691}
1692
1693fn resolve_direct_merge(
1697 refusal: &str,
1698 argv: &[String],
1699 merge_ok: bool,
1700 stderr: &str,
1701 after: Option<land::PrLifecycle>,
1702) -> AutomergeOutcome {
1703 if merge_ok {
1704 return AutomergeOutcome::MergedDirectly {
1705 detail: format!("automerge was refused ({refusal}); gh {}", argv.join(" ")),
1706 };
1707 }
1708 match land::merged_after_all(argv, stderr, after) {
1709 Some(m) => AutomergeOutcome::MergedDirectly { detail: m.detail },
1710 None => AutomergeOutcome::Failed {
1711 reason: format!("{refusal}; merging directly failed too: {}", stderr.trim()),
1712 },
1713 }
1714}
1715
1716async fn gh_merge_directly(
1720 cwd: &Path,
1721 pr_url: &str,
1722 subject: &str,
1723 refusal: String,
1724) -> AutomergeOutcome {
1725 let argv = bump_merge_argv(pr_url, subject);
1726 let out = match tokio::process::Command::new("gh")
1727 .args(&argv)
1728 .current_dir(cwd)
1729 .quiet()
1730 .stdin(std::process::Stdio::null())
1731 .output()
1732 .await
1733 {
1734 Ok(o) => o,
1735 Err(e) => {
1736 return AutomergeOutcome::Failed {
1737 reason: format!("{refusal}; could not spawn gh to merge directly: {e}"),
1738 };
1739 }
1740 };
1741 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
1742 let after = if out.status.success() {
1745 None
1746 } else {
1747 land::lifecycle(cwd, pr_url).await.ok()
1748 };
1749 resolve_direct_merge(&refusal, &argv, out.status.success(), &stderr, after)
1750}
1751
1752async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
1760 let mut cmd = tokio::process::Command::new("cargo");
1761 cmd.arg("build").current_dir(worktree).quiet();
1762 if let Some(dir) = cache_dir {
1763 cmd.env("CARGO_TARGET_DIR", dir);
1764 }
1765 let out = cmd
1766 .stdin(std::process::Stdio::null())
1767 .output()
1768 .await
1769 .context("spawn cargo build")?;
1770 if !out.status.success() {
1771 bail!(
1772 "cargo build failed while syncing Cargo.lock: {}",
1773 String::from_utf8_lossy(&out.stderr).trim()
1774 );
1775 }
1776 Ok(())
1777}
1778
1779fn release_pr(
1784 level: &str,
1785 reason: &str,
1786 next_version: &str,
1787 run_id: &str,
1788 source_pr_url: &str,
1789) -> (String, String) {
1790 let title = format!("chore: release v{next_version} ({level} bump)");
1791 let body = format!(
1792 "## Background\n\n\
1793 A change that was just merged is a `{level}` change, so the crate needs a new \
1794 release: {reason}\n\n\
1795 Triggered by magi run `{run_id}`, which landed {source}.\n\n\
1796 ## Change\n\n\
1797 Raises the package version to `v{next_version}` in `Cargo.toml`, with \
1798 `Cargo.lock` following it. Nothing else changes.\n\n\
1799 ## Risk\n\n\
1800 Version-bump-only, so there is nothing here for a reviewer to find. Merging \
1801 it starts the release pipeline (auto-tag, then the release workflow).",
1802 source = source_pr_url,
1803 );
1804 (title, body)
1805}
1806
1807async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
1809 let out = tokio::process::Command::new("gh")
1810 .args(["pr", "view", pr_url, "--json", "title"])
1811 .current_dir(repo)
1812 .quiet()
1813 .stdin(std::process::Stdio::null())
1814 .output()
1815 .await
1816 .context("spawn gh pr view")?;
1817 if !out.status.success() {
1818 bail!(
1819 "gh pr view {pr_url}: {}",
1820 String::from_utf8_lossy(&out.stderr).trim()
1821 );
1822 }
1823 #[derive(Deserialize)]
1824 struct Title {
1825 title: String,
1826 }
1827 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
1828 .context("parse `gh pr view --json title` output")?;
1829 Ok(parsed.title)
1830}
1831
1832async fn gh_pr_create(
1833 cwd: &Path,
1834 base: &str,
1835 head: &str,
1836 title: &str,
1837 body: &str,
1838) -> Result<String> {
1839 let out = tokio::process::Command::new("gh")
1840 .args([
1841 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
1842 ])
1843 .current_dir(cwd)
1844 .quiet()
1845 .stdin(std::process::Stdio::null())
1846 .output()
1847 .await
1848 .context("spawn gh pr create")?;
1849 if out.status.success() {
1850 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
1851 } else {
1852 bail!(
1853 "gh pr create: {}",
1854 String::from_utf8_lossy(&out.stderr).trim()
1855 )
1856 }
1857}
1858
1859async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
1863 let out = tokio::process::Command::new("gh")
1864 .args([
1865 "pr",
1866 "merge",
1867 pr_url,
1868 "--auto",
1869 "--squash",
1870 "--delete-branch",
1871 ])
1872 .current_dir(cwd)
1873 .quiet()
1874 .stdin(std::process::Stdio::null())
1875 .output()
1876 .await
1877 .context("spawn gh pr merge --auto")?;
1878 if out.status.success() {
1879 Ok(())
1880 } else {
1881 bail!(
1882 "gh pr merge --auto: {}",
1883 String::from_utf8_lossy(&out.stderr).trim()
1884 )
1885 }
1886}
1887
1888async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
1892 let out = tokio::process::Command::new("gh")
1893 .args(["pr", "edit", pr_url, "--title", title])
1894 .current_dir(cwd)
1895 .quiet()
1896 .stdin(std::process::Stdio::null())
1897 .output()
1898 .await
1899 .context("spawn gh pr edit")?;
1900 if out.status.success() {
1901 Ok(())
1902 } else {
1903 bail!(
1904 "gh pr edit --title: {}",
1905 String::from_utf8_lossy(&out.stderr).trim()
1906 )
1907 }
1908}
1909
1910#[cfg(test)]
1911mod tests {
1912 use super::*;
1913 use crate::notices::Severity;
1914
1915 #[test]
1916 fn github_facing_bump_text_is_english() {
1917 let (title, body) =
1918 release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
1919 assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
1920 assert_eq!(title, "chore: release v0.37.0 (minor bump)");
1921 assert!(
1922 body.contains("## Background") && body.contains("## Change"),
1923 "{body}"
1924 );
1925 let p = decision_prompt("s", "i", "d", &[], "0.36.5");
1926 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
1927 }
1928 use crate::config::Config;
1929 use crate::land::PrLifecycle;
1930
1931 #[tokio::test]
1937 async fn a_disabled_config_does_nothing() {
1938 let config = Config {
1939 merge: crate::config::Merge {
1940 release_bump: false,
1941 ..crate::config::Merge::default()
1942 },
1943 ..Config::default()
1944 };
1945 let mut state = RunState::new(
1946 PathBuf::from("/no/such/repo"),
1947 "main".to_owned(),
1948 "0000000000000000000000000000000000000000".to_owned(),
1949 "irrelevant".to_owned(),
1950 config,
1951 );
1952 after_merge(&mut state, "https://example.invalid/pull/1")
1953 .await
1954 .expect("a disabled config must return Ok without touching anything");
1955 assert!(
1956 state.events.is_empty(),
1957 "nothing should happen at all, not even a logged event"
1958 );
1959 }
1960
1961 async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
1963 let dir = tempfile::tempdir().unwrap();
1964 let origin = dir.path().join("origin.git");
1965 let repo = dir.path().join("repo");
1966 let o = origin.to_string_lossy().into_owned();
1967 git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
1968 .await
1969 .unwrap();
1970 tokio::fs::create_dir_all(&repo).await.unwrap();
1971 git::git(&repo, &["init", "-b", "main"]).await.unwrap();
1972 git::git(&repo, &["config", "user.name", "test"])
1973 .await
1974 .unwrap();
1975 git::git(&repo, &["config", "user.email", "test@example.com"])
1976 .await
1977 .unwrap();
1978 for (name, body) in files {
1979 tokio::fs::write(repo.join(name), body).await.unwrap();
1980 }
1981 git::git(&repo, &["add", "-A"]).await.unwrap();
1982 git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
1983 git::git(&repo, &["remote", "add", "origin", &o])
1984 .await
1985 .unwrap();
1986 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
1987 (dir, repo)
1988 }
1989
1990 #[tokio::test]
1991 async fn base_has_cargo_toml_tells_rust_from_non_rust() {
1992 let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
1993 assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
1994 let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
1995 assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
1996 assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
1998 }
1999
2000 #[tokio::test]
2001 async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
2002 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2003 let home = tempfile::tempdir().unwrap();
2004 let mut state = RunState::new(
2005 repo.clone(),
2006 "main".to_owned(),
2007 "0000000000000000000000000000000000000000".to_owned(),
2008 "task".to_owned(),
2009 Config::default(),
2010 );
2011 state.candidates.push(crate::run::Candidate {
2012 index: 0,
2013 label: 'A',
2014 agent: "x".to_owned(),
2015 branch: "main".to_owned(),
2016 worktree: repo.clone(),
2017 summary: String::new(),
2018 stat: String::new(),
2019 files: 1,
2020 commits: 1,
2021 empty: false,
2022 failed: None,
2023 verified_noop: None,
2024 duration_ms: 0,
2025 folded: false,
2026 });
2027 state.tally = Some(
2028 serde_json::from_value(serde_json::json!({
2029 "first_choice": {}, "borda": {}, "winner": "A",
2030 "unanimous_initial": true, "deliberated": false,
2031 "changed_votes": 0, "unanimous_final": true,
2032 }))
2033 .unwrap(),
2034 );
2035 after_merge_at(
2036 &mut state,
2037 "https://example.invalid/pull/1",
2038 Some(home.path()),
2039 )
2040 .await
2041 .expect("a non-Rust repository is not an error");
2042 let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
2043 assert_eq!(bumps.len(), 1, "{:?}", state.events);
2044 assert_eq!(
2045 bumps[0].message,
2046 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
2047 );
2048 assert!(
2049 std::fs::read_dir(home.path()).unwrap().next().is_none(),
2050 "no marker and no lock may be created"
2051 );
2052 }
2053
2054 const NO_RULES: &str = "gh pr merge --auto: GraphQL: Pull request Branch does not have \
2055 required protected branch rules (enablePullRequestAutoMerge)";
2056
2057 fn merged_state() -> RunState {
2058 run::set_home(std::env::temp_dir().join("magi-report-test-home"));
2061 let mut s = RunState::new(
2062 PathBuf::from("/no/such/repo"),
2063 "main".to_owned(),
2064 "0000000000000000000000000000000000000000".to_owned(),
2065 "task".to_owned(),
2066 Config::default(),
2067 );
2068 s.status = RunStatus::Merged;
2069 s
2070 }
2071
2072 #[test]
2073 fn the_known_automerge_refusal_names_branch_protection() {
2074 assert!(automerge_hint(NO_RULES).contains("branch protection with required"));
2075 let other = automerge_hint("gh: network unreachable");
2076 assert!(!other.contains("branch protection"), "{other}");
2077 let body = automerge_failure_comment(NO_RULES);
2078 assert!(body.contains("enablePullRequestAutoMerge"), "{body}");
2079 assert!(body.contains("Action required"), "{body}");
2080 }
2081
2082 const CLEAN: &str = "gh pr merge --auto: GraphQL: Pull request Pull request is in clean \
2083 status (enablePullRequestAutoMerge)";
2084
2085 #[test]
2086 fn clean_status_refusal_is_matched_narrowly() {
2087 assert!(is_clean_status_refusal(CLEAN));
2088 assert!(!is_clean_status_refusal(NO_RULES));
2089 assert!(!is_clean_status_refusal("gh: network unreachable"));
2090 assert!(!is_clean_status_refusal("Pull request is in clean status"));
2091 assert!(automerge_hint(CLEAN).contains("already green"));
2092 }
2093
2094 #[test]
2095 fn the_direct_merge_argv_matches_the_land_flags() {
2096 let a = bump_merge_argv("https://github.com/o/r/pull/9", "chore: release v1.0.0");
2097 let l = land::merge_argv(9, "chore: release v1.0.0");
2098 assert_eq!(a[..2], l[..2]);
2099 assert_eq!(a[3..], l[3..]);
2100 assert_eq!(a[2], "https://github.com/o/r/pull/9");
2101 }
2102
2103 #[test]
2104 fn a_direct_merge_is_judged_by_the_forge_not_the_exit_code() {
2105 let argv = bump_merge_argv("u", "t");
2106 let merged = |o: &AutomergeOutcome| matches!(o, AutomergeOutcome::MergedDirectly { .. });
2107 assert!(merged(&resolve_direct_merge(CLEAN, &argv, true, "", None)));
2108 let detached = "not on any branch";
2109 assert!(merged(&resolve_direct_merge(
2110 CLEAN,
2111 &argv,
2112 false,
2113 detached,
2114 Some(PrLifecycle::Merged)
2115 )));
2116 for after in [Some(PrLifecycle::Open), None] {
2117 let o = resolve_direct_merge(CLEAN, &argv, false, "boom", after);
2118 match o {
2119 AutomergeOutcome::Failed { reason } => {
2120 assert!(
2121 reason.contains("clean status") && reason.contains("boom"),
2122 "{reason}"
2123 )
2124 }
2125 other => panic!("expected Failed, got {other:?}"),
2126 }
2127 }
2128 }
2129
2130 #[test]
2131 fn a_directly_merged_bump_is_not_reported_as_pending_or_failed() {
2132 let mut state = merged_state();
2133 state.release_bump = Some(run::ReleaseBump {
2134 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
2135 version: Some("1.0.0".to_owned()),
2136 merged_directly: true,
2137 ..run::ReleaseBump::default()
2138 });
2139 assert!(!state.needs_attention());
2140 let text = crate::report::run(&state);
2141 assert!(text.contains("merged directly"), "{text}");
2142 assert!(!text.contains("FAILED"), "{text}");
2143 }
2144
2145 #[test]
2146 fn an_automerge_failure_is_recorded_shown_and_filed_and_survives_settling() {
2147 let dir = tempfile::tempdir().unwrap();
2148 let store = Notices::at(dir.path().join("notifications"));
2149 let questions = ask::Questions::at(dir.path().join("questions"));
2150 let mut state = merged_state();
2151 let url = "https://github.com/o/r/pull/35";
2152
2153 let (n, comment) =
2154 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
2155
2156 let comment = comment.expect("a PR was opened, so it gets a comment");
2158 assert!(comment.contains("branch protection"), "{comment}");
2159
2160 assert_eq!(state.status, RunStatus::Merged);
2162 assert!(state.needs_attention());
2163 let text = crate::report::run(&state);
2164 assert!(text.contains("release bump"), "{text}");
2165 assert!(text.contains("FAILED"), "{text}");
2166 assert!(text.contains(url), "{text}");
2167 assert!(text.contains("action required"), "{text}");
2168 assert!(crate::report::line(&state).contains("release needs a human"));
2169
2170 assert_eq!(n.severity, Severity::Error);
2172 assert_eq!(
2173 n.link,
2174 Some(Link::Url {
2175 url: url.to_owned()
2176 })
2177 );
2178 assert_eq!(store.list().len(), 1);
2179 assert!(questions.open_for(&state.id).is_empty());
2180
2181 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
2183 let listed = store.list();
2184 assert_eq!(listed.len(), 1);
2185 assert_eq!(listed[0].count, 2);
2186 }
2187
2188 #[test]
2189 fn a_bump_that_never_ran_is_surfaced_without_a_pr_comment() {
2190 let dir = tempfile::tempdir().unwrap();
2191 let store = Notices::at(dir.path().join("notifications"));
2192 let mut state = merged_state();
2193 let (n, comment) = surface_problem(&mut state, &store, None, None, "no agent").unwrap();
2194 assert!(comment.is_none());
2195 assert!(matches!(n.link, Some(Link::Run { .. })));
2196 assert!(state.needs_attention());
2197 }
2198
2199 #[test]
2200 fn version_parses_and_bumps_each_digit() {
2201 let v = Version::parse("0.4.0").unwrap();
2202 assert_eq!(
2203 v,
2204 Version {
2205 major: 0,
2206 minor: 4,
2207 patch: 0
2208 }
2209 );
2210
2211 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
2212 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
2213 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
2214 }
2215
2216 #[test]
2217 fn version_tolerates_a_prerelease_suffix_on_patch() {
2218 let v = Version::parse("1.2.3-rc1").unwrap();
2219 assert_eq!(
2220 v,
2221 Version {
2222 major: 1,
2223 minor: 2,
2224 patch: 3
2225 }
2226 );
2227 }
2228
2229 #[test]
2230 fn version_rejects_garbage() {
2231 assert!(Version::parse("not-a-version").is_err());
2232 assert!(Version::parse("1.2").is_err());
2233 }
2234
2235 #[test]
2236 fn decision_parses_each_level() {
2237 for (json, level) in [
2238 (
2239 r#"{"level":"major","reason":"drops a config key"}"#,
2240 BumpLevel::Major,
2241 ),
2242 (
2243 r#"{"level":"minor","reason":"adds a new flag"}"#,
2244 BumpLevel::Minor,
2245 ),
2246 (
2247 r#"{"level":"patch","reason":"fixes a race"}"#,
2248 BumpLevel::Patch,
2249 ),
2250 ] {
2251 let decision = parse_decision(json).unwrap();
2252 assert_eq!(decision.level, level);
2253 assert!(!decision.reason.is_empty());
2254 }
2255 }
2256
2257 #[test]
2258 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
2259 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
2260 let decision = parse_decision(text).unwrap();
2261 assert_eq!(decision.level, BumpLevel::Minor);
2262 assert_eq!(decision.reason, "new HTTP route");
2263 }
2264
2265 #[test]
2266 fn a_broken_reply_is_an_error_not_a_default() {
2267 assert!(parse_decision("I decline to answer.").is_err());
2268 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
2269 assert!(
2270 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
2271 "an empty reason must not pass either"
2272 );
2273 assert!(
2274 parse_decision(r#"{"level":"patch"}"#).is_err(),
2275 "a reply with no reason at all must not pass"
2276 );
2277 }
2278
2279 #[test]
2280 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
2281 let prompt = decision_prompt(
2282 "feat: add a phone endpoint",
2283 "add POST /api/widgets",
2284 "1 file changed, 10 insertions(+)",
2285 &["src/web.rs".to_owned()],
2286 "0.8.0",
2287 );
2288 assert!(prompt.contains("0.8.0"), "the current version is stated");
2289 assert!(
2290 prompt.contains("below `1.0.0`")
2291 && prompt.contains("`minor` is the digit that carries a breaking change"),
2292 "the 0.x rule must be explicit: {prompt}"
2293 );
2294 assert!(
2295 prompt.contains("choose the larger"),
2296 "the tie-break toward the bigger digit must be explicit: {prompt}"
2297 );
2298 }
2299
2300 #[test]
2301 fn release_only_diffs_are_recognised() {
2302 assert!(is_release_only(&["Cargo.toml".to_owned()]));
2303 assert!(is_release_only(&[
2304 "Cargo.toml".to_owned(),
2305 "Cargo.lock".to_owned()
2306 ]));
2307 assert!(!is_release_only(&[]));
2308 assert!(!is_release_only(&[
2309 "Cargo.toml".to_owned(),
2310 "src/main.rs".to_owned()
2311 ]));
2312 }
2313
2314 #[test]
2315 fn cargo_version_rewrite_touches_only_the_package_table() {
2316 let toml = "\
2317[package]\n\
2318# a comment mentioning version on purpose\n\
2319name = \"magi-cli\"\n\
2320version = \"0.8.0\"\n\
2321edition = \"2024\"\n\
2322\n\
2323[dependencies]\n\
2324foo = { version = \"1.2.3\" }\n";
2325 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
2326 assert!(out.contains("version = \"0.9.0\""));
2327 assert!(
2328 out.contains("foo = { version = \"1.2.3\" }"),
2329 "a dependency's own version pin must survive: {out}"
2330 );
2331 assert!(
2332 out.contains("# a comment mentioning version on purpose"),
2333 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
2334 );
2335 assert_eq!(
2336 out.lines().count(),
2337 toml.lines().count(),
2338 "the rewrite replaces one line, it does not add or remove any"
2339 );
2340 }
2341
2342 #[test]
2343 fn cargo_version_rewrite_fails_without_a_package_table() {
2344 let toml = "[dependencies]\nfoo = \"1\"\n";
2345 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
2346 }
2347
2348 #[test]
2354 fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
2355 let toml = "\
2356[workspace]\n\
2357members = [\"crates/a\", \"crates/b\"]\n\
2358\n\
2359[workspace.package]\n\
2360version = \"0.45.18\"\n\
2361edition = \"2024\"\n\
2362\n\
2363[workspace.dependencies]\n\
2364foo = { version = \"1.2.3\" }\n";
2365 let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
2366 assert!(out.contains("version = \"0.45.19\""));
2367 assert!(
2368 out.contains("foo = { version = \"1.2.3\" }"),
2369 "a workspace dependency's own version pin must survive: {out}"
2370 );
2371 assert_eq!(
2372 out.lines().count(),
2373 toml.lines().count(),
2374 "the rewrite replaces one line, it does not add or remove any"
2375 );
2376 }
2377
2378 #[test]
2382 fn cargo_version_rewrite_bumps_an_internal_workspace_dependency_pin() {
2383 let toml = "\
2384[workspace]\n\
2385members = [\"crates/kanade-shared\"]\n\
2386\n\
2387[workspace.package]\n\
2388version = \"0.48.2\"\n\
2389\n\
2390[workspace.dependencies]\n\
2391kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n";
2392 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
2393 assert!(out.contains("[workspace.package]\nversion = \"0.48.3\"\n"));
2394 assert!(
2395 out.contains(
2396 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
2397 ),
2398 "the internal pin must move with the workspace version: {out}"
2399 );
2400 }
2401
2402 #[test]
2404 fn cargo_version_rewrite_bumps_an_internal_pin_with_version_before_path() {
2405 let toml = "\
2406[workspace.package]\n\
2407version = \"1.0.0\"\n\
2408\n\
2409[workspace.dependencies]\n\
2410inner = { version = \"1.0.0\", path = \"crates/inner\" }\n";
2411 let out = rewrite_cargo_version(toml, "1.0.1").unwrap();
2412 assert!(out.contains("inner = { version = \"1.0.1\", path = \"crates/inner\" }"));
2413 }
2414
2415 #[test]
2418 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_table_form() {
2419 let toml = "\
2420[workspace.package]\n\
2421version = \"2.3.0\"\n\
2422\n\
2423[workspace.dependencies.inner]\n\
2424path = \"crates/inner\"\n\
2425version = \"2.3.0\"\n";
2426 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
2427 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
2428 assert!(out.contains(
2429 "[workspace.dependencies.inner]\npath = \"crates/inner\"\nversion = \"2.4.0\"\n"
2430 ));
2431 }
2432
2433 #[test]
2438 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_key_form() {
2439 let toml = "\
2440[workspace.package]\n\
2441version = \"2.3.0\"\n\
2442\n\
2443[workspace.dependencies]\n\
2444inner.path = \"crates/inner\"\n\
2445inner.version = \"2.3.0\"\n";
2446 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
2447 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
2448 assert!(out.contains("inner.path = \"crates/inner\"\ninner.version = \"2.4.0\"\n"));
2449 }
2450
2451 #[test]
2454 fn cargo_version_rewrite_leaves_a_path_only_workspace_dependency_untouched() {
2455 let toml = "\
2456[workspace.package]\n\
2457version = \"0.1.0\"\n\
2458\n\
2459[workspace.dependencies]\n\
2460internal-only = { path = \"crates/internal-only\" }\n";
2461 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
2462 assert!(out.contains("internal-only = { path = \"crates/internal-only\" }"));
2463 }
2464
2465 #[test]
2469 fn cargo_version_rewrite_leaves_an_external_dependency_untouched() {
2470 let toml = "\
2471[workspace.package]\n\
2472version = \"0.1.0\"\n\
2473\n\
2474[workspace.dependencies]\n\
2475serde = { version = \"1\", features = [\"derive\"] }\n\
2476inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
2477 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
2478 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
2479 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
2480 }
2481
2482 #[test]
2486 fn cargo_version_rewrite_without_workspace_dependencies_table_still_bumps_package() {
2487 let toml = "[workspace.package]\nversion = \"0.9.0\"\nedition = \"2024\"\n";
2488 let out = rewrite_cargo_version(toml, "0.10.0").unwrap();
2489 assert_eq!(
2490 out,
2491 "[workspace.package]\nversion = \"0.10.0\"\nedition = \"2024\"\n"
2492 );
2493 }
2494
2495 #[test]
2498 fn cargo_version_rewrite_handles_a_kanade_shaped_workspace_dependencies_table() {
2499 let toml = "\
2500[workspace.package]\n\
2501version = \"0.48.2\"\n\
2502\n\
2503[workspace.dependencies]\n\
2504anyhow = { version = \"1\" }\n\
2505serde = { version = \"1\", features = [\"derive\"] }\n\
2506kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n\
2507kanade-core = { path = \"crates/kanade-core\", version = \"0.48.2\" }\n\
2508kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }\n";
2509 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
2510 assert!(out.contains("anyhow = { version = \"1\" }"));
2511 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
2512 assert!(
2513 out.contains(
2514 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
2515 )
2516 );
2517 assert!(
2518 out.contains("kanade-core = { path = \"crates/kanade-core\", version = \"0.48.3\" }")
2519 );
2520 assert!(out.contains("kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }"));
2521 }
2522
2523 #[test]
2527 fn cargo_version_rewrite_bails_on_an_unrepresentable_inline_table() {
2528 let toml = "\
2529[workspace.package]\n\
2530version = \"0.1.0\"\n\
2531\n\
2532[workspace.dependencies]\n\
2533inner = { path = \"crates/inner\",\n\
2534 version = \"0.1.0\" }\n";
2535 assert!(rewrite_cargo_version(toml, "0.2.0").is_err());
2536 }
2537
2538 #[test]
2541 fn cargo_version_rewrite_recognises_a_commented_workspace_dependencies_header() {
2542 let toml = "\
2543[workspace.package]\n\
2544version = \"0.1.0\"\n\
2545\n\
2546[workspace.dependencies] # internal pins\n\
2547inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
2548 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
2549 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
2550 }
2551
2552 #[test]
2555 fn cargo_version_rewrite_bumps_a_quoted_dependency_key() {
2556 let toml = "\
2557[workspace.package]\n\
2558version = \"0.1.0\"\n\
2559\n\
2560[workspace.dependencies]\n\
2561\"inner\" = { path = \"crates/inner\", version = \"0.1.0\" }\n";
2562 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
2563 assert!(out.contains("\"inner\" = { path = \"crates/inner\", version = \"0.2.0\" }"));
2564 }
2565
2566 #[test]
2570 fn cargo_version_rewrite_bumps_a_literal_string_version_pin() {
2571 let toml = "\
2572[workspace.package]\n\
2573version = \"0.1.0\"\n\
2574\n\
2575[workspace.dependencies]\n\
2576inner = { path = 'crates/inner', version = '0.1.0' }\n";
2577 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
2578 assert!(out.contains("inner = { path = 'crates/inner', version = '0.2.0' }"));
2579 }
2580
2581 #[test]
2587 fn cargo_version_rewrite_does_not_require_the_whole_file_to_parse() {
2588 let toml = "\
2589[workspace.package]\n\
2590version = \"0.1.0\"\n\
2591\n\
2592[workspace.dependencies]\n\
2593inner = { path = \"crates/inner\", version = \"0.1.0\" }\n\
2594\n\
2595[workspace.package]\n\
2596edition = \"2024\"\n";
2597 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
2598 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
2599 assert!(
2600 toml::from_str::<toml::Value>(toml).is_err(),
2601 "the fixture itself must be invalid as a whole file, or this test proves nothing"
2602 );
2603 }
2604
2605 #[test]
2606 fn current_version_prefers_the_package_table_when_both_exist() {
2607 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
2608 assert_eq!(current_version(toml).unwrap(), "0.8.0");
2609 }
2610
2611 #[test]
2614 fn current_version_falls_back_to_workspace_package_without_a_package_table() {
2615 let toml = "\
2616[workspace]\n\
2617members = [\"crates/a\", \"crates/b\"]\n\
2618\n\
2619[workspace.package]\n\
2620version = \"0.45.18\"\n";
2621 assert_eq!(current_version(toml).unwrap(), "0.45.18");
2622 }
2623
2624 #[test]
2625 fn coalesce_proceeds_with_nothing_pending() {
2626 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
2627 }
2628
2629 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
2632 PendingBump {
2633 target_version: target_version.to_owned(),
2634 level,
2635 branch: format!("chore/release-v{target_version}"),
2636 pr_url: "https://example.invalid/pull/9".to_owned(),
2637 }
2638 }
2639
2640 #[test]
2641 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
2642 let pending = test_pending("0.9.0", BumpLevel::Minor);
2643 assert_eq!(
2644 coalesce(Some(&pending), "0.8.0").unwrap(),
2645 Coalesce::Skip {
2646 target_version: "0.9.0".to_owned()
2647 }
2648 );
2649 }
2650
2651 #[test]
2652 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
2653 let pending = test_pending("0.9.0", BumpLevel::Minor);
2654 assert_eq!(
2656 coalesce(Some(&pending), "0.9.0").unwrap(),
2657 Coalesce::Proceed
2658 );
2659 assert_eq!(
2661 coalesce(Some(&pending), "1.0.0").unwrap(),
2662 Coalesce::Proceed
2663 );
2664 }
2665
2666 #[test]
2667 fn pending_action_escalates_only_for_a_more_severe_decision() {
2668 assert_eq!(
2669 pending_action(BumpLevel::Patch, BumpLevel::Patch),
2670 PendingAction::AlreadyCovered
2671 );
2672 assert_eq!(
2673 pending_action(BumpLevel::Patch, BumpLevel::Minor),
2674 PendingAction::Escalate
2675 );
2676 assert_eq!(
2677 pending_action(BumpLevel::Patch, BumpLevel::Major),
2678 PendingAction::Escalate
2679 );
2680 assert_eq!(
2681 pending_action(BumpLevel::Minor, BumpLevel::Patch),
2682 PendingAction::AlreadyCovered
2683 );
2684 assert_eq!(
2685 pending_action(BumpLevel::Major, BumpLevel::Minor),
2686 PendingAction::AlreadyCovered
2687 );
2688 assert_eq!(
2689 pending_action(BumpLevel::Major, BumpLevel::Major),
2690 PendingAction::AlreadyCovered
2691 );
2692 }
2693
2694 #[test]
2695 fn pr_state_parsing_reads_open_and_not_open() {
2696 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
2697 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
2698 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
2699 }
2700
2701 #[test]
2702 fn a_lock_is_exclusive_until_dropped() {
2703 let dir = tempfile::tempdir().unwrap();
2704 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2705 let first = MarkerLock::acquire(&marker)
2706 .unwrap()
2707 .expect("first attempt takes the lock");
2708 assert!(
2709 MarkerLock::acquire(&marker).unwrap().is_none(),
2710 "a second attempt must be refused while the first holds it"
2711 );
2712 drop(first);
2713 assert!(
2714 MarkerLock::acquire(&marker).unwrap().is_some(),
2715 "dropping the guard releases the lock for the next attempt"
2716 );
2717 }
2718
2719 #[test]
2720 fn a_stale_lock_is_reclaimed() {
2721 let dir = tempfile::tempdir().unwrap();
2722 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2723 let lock_path = marker.with_extension("lock");
2724 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
2725 std::fs::write(&lock_path, b"").unwrap();
2726 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
2727 std::fs::OpenOptions::new()
2728 .write(true)
2729 .open(&lock_path)
2730 .unwrap()
2731 .set_modified(old)
2732 .unwrap();
2733 assert!(
2734 MarkerLock::acquire(&marker).unwrap().is_some(),
2735 "a lock older than the stale window must be reclaimed rather than block forever"
2736 );
2737 }
2738
2739 #[tokio::test]
2740 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
2741 let dir = tempfile::tempdir().unwrap();
2742 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2743 let held = MarkerLock::acquire(&marker)
2744 .unwrap()
2745 .expect("seed the contention");
2746 let releaser = tokio::spawn(async move {
2747 tokio::time::sleep(Duration::from_millis(20)).await;
2748 drop(held);
2749 });
2750 let waited =
2751 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
2752 .await
2753 .unwrap();
2754 assert!(
2755 waited.is_some(),
2756 "a merge landing behind another's still-running decision must not be dropped - it \
2757 must wait for that decision to finish and then judge against what it left behind"
2758 );
2759 releaser.await.unwrap();
2760 }
2761
2762 #[tokio::test]
2763 async fn a_lock_held_past_the_ceiling_gives_up() {
2764 let dir = tempfile::tempdir().unwrap();
2765 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
2766 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
2767 let waited =
2768 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
2769 .await
2770 .unwrap();
2771 assert!(
2772 waited.is_none(),
2773 "a lock genuinely held past the ceiling must eventually give up rather than wait \
2774 forever"
2775 );
2776 }
2777
2778 #[test]
2779 fn level_between_reads_off_the_differing_digit() {
2780 assert_eq!(
2781 level_between(
2782 Version::parse("0.8.0").unwrap(),
2783 Version::parse("1.0.0").unwrap()
2784 ),
2785 Some(BumpLevel::Major)
2786 );
2787 assert_eq!(
2788 level_between(
2789 Version::parse("0.8.0").unwrap(),
2790 Version::parse("0.9.0").unwrap()
2791 ),
2792 Some(BumpLevel::Minor)
2793 );
2794 assert_eq!(
2795 level_between(
2796 Version::parse("0.8.0").unwrap(),
2797 Version::parse("0.8.1").unwrap()
2798 ),
2799 Some(BumpLevel::Patch)
2800 );
2801 assert_eq!(
2802 level_between(
2803 Version::parse("0.8.0").unwrap(),
2804 Version::parse("0.8.0").unwrap()
2805 ),
2806 None
2807 );
2808 }
2809
2810 #[test]
2811 fn open_release_pr_is_found_among_unrelated_pull_requests() {
2812 let json = r#"[
2813 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
2814 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
2815 ]"#;
2816 let found = parse_open_release_pr(json).unwrap();
2817 assert_eq!(
2818 found,
2819 Some((
2820 "chore/release-v0.9.0".to_owned(),
2821 "https://example.invalid/pull/2".to_owned()
2822 ))
2823 );
2824 }
2825
2826 #[test]
2827 fn no_open_release_pr_reads_as_none_not_an_error() {
2828 let json =
2829 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
2830 assert_eq!(parse_open_release_pr(json).unwrap(), None);
2831 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
2832 }
2833
2834 #[test]
2835 fn marker_round_trips_through_disk() {
2836 let dir = tempfile::tempdir().unwrap();
2837 let path = marker_path(dir.path(), Path::new("/repos/magi"));
2838 assert!(read_marker(&path).is_none());
2839
2840 let marker = test_pending("0.9.0", BumpLevel::Patch);
2841 write_marker(&path, &marker).unwrap();
2842 let read_back = read_marker(&path).unwrap();
2843 assert_eq!(read_back.target_version, "0.9.0");
2844 assert_eq!(read_back.level, BumpLevel::Patch);
2845 assert_eq!(read_back.pr_url, marker.pr_url);
2846
2847 clear_marker(&path);
2848 assert!(read_marker(&path).is_none());
2849 }
2850
2851 #[test]
2852 fn different_repos_get_different_marker_files() {
2853 let dir = tempfile::tempdir().unwrap();
2854 let a = marker_path(dir.path(), Path::new("/repos/a"));
2855 let b = marker_path(dir.path(), Path::new("/repos/b"));
2856 assert_ne!(a, b);
2857 }
2858
2859 #[test]
2863 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
2864 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
2865 assert!(
2866 is_release_only(&files),
2867 "the bump pull request's own diff must read as release-only"
2868 );
2869 }
2870
2871 #[test]
2872 fn should_release_bump_reads_only_a_merged_status() {
2873 assert!(should_release_bump(RunStatus::Merged));
2874 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
2875 assert!(!should_release_bump(other));
2876 }
2877 }
2878
2879 #[test]
2883 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
2884 let pr = land::PrState {
2885 url: "https://github.com/o/r/pull/1".to_owned(),
2886 number: 1,
2887 state: PrLifecycle::Merged,
2888 checks: land::Checks::Green,
2889 failing: Vec::new(),
2890 review_comments: Vec::new(),
2891 blocking: land::Blocking::No,
2892 };
2893 assert_eq!(
2894 land::decide(&pr, 0, 4, Duration::ZERO),
2895 land::Step::Done { merged: true }
2896 );
2897 assert!(should_release_bump(RunStatus::Merged));
2898 }
2899
2900 #[test]
2905 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
2906 let pr = land::PrState {
2907 url: "https://github.com/o/r/pull/2".to_owned(),
2908 number: 2,
2909 state: PrLifecycle::Open,
2910 checks: land::Checks::Green,
2911 failing: Vec::new(),
2912 review_comments: Vec::new(),
2913 blocking: land::Blocking::No,
2914 };
2915 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
2916 assert!(should_release_bump(RunStatus::Merged));
2919 }
2920
2921 #[test]
2924 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
2925 let argv = land::merge_argv(3, "feat: something");
2926 let outcome = land::merged_after_all(
2927 &argv,
2928 "could not determine current branch: not on any branch",
2929 Some(PrLifecycle::Merged),
2930 );
2931 assert!(outcome.is_some(), "the forge's confirmation must win");
2932 assert!(should_release_bump(RunStatus::Merged));
2933
2934 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
2937 assert!(land::merged_after_all(&argv, "network error", None).is_none());
2938 }
2939
2940 #[test]
2942 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
2943 let pr = land::PrState {
2944 url: "https://github.com/o/r/pull/4".to_owned(),
2945 number: 4,
2946 state: PrLifecycle::Closed,
2947 checks: land::Checks::Green,
2948 failing: Vec::new(),
2949 review_comments: Vec::new(),
2950 blocking: land::Blocking::No,
2951 };
2952 assert_eq!(
2953 land::decide(&pr, 0, 4, Duration::ZERO),
2954 land::Step::Done { merged: false }
2955 );
2956 assert!(!should_release_bump(RunStatus::Blocked));
2957 }
2958}