1use std::fmt::Write as _;
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26use anyhow::{Context as _, Result, anyhow, 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<()> {
1029 after_merge_at(state, pr_url, None, None).await
1030}
1031
1032#[derive(Debug, Default)]
1034struct Progress {
1035 version: Option<String>,
1037 pending_pr: Option<String>,
1039}
1040
1041fn notice_for_failure(progress: &Progress, err: &anyhow::Error) -> (Option<String>, String) {
1047 let chain = format!("{err:#}");
1048 let first = chain.lines().next().unwrap_or_default().trim();
1049 (progress.version.clone(), first.to_owned())
1050}
1051
1052async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
1058 let out = git::git(
1059 repo,
1060 &[
1061 "ls-tree",
1062 "--name-only",
1063 &format!("{remote}/{base}"),
1064 "--",
1065 "Cargo.toml",
1066 ],
1067 )
1068 .await
1069 .context("look for Cargo.toml on the base branch")?;
1070 Ok(!out.trim().is_empty())
1071}
1072
1073async fn after_merge_at(
1077 state: &mut RunState,
1078 pr_url: &str,
1079 home: Option<&Path>,
1080 store: Option<&Notices>,
1081) -> Result<()> {
1082 let mut progress = Progress::default();
1083 let result = after_merge_inner(state, pr_url, home, &mut progress).await;
1084 if let Err(e) = &result {
1085 let (version, reason) = notice_for_failure(&progress, e);
1086 let pending = progress.pending_pr.as_deref();
1087 let store = store.cloned().unwrap_or_else(Notices::open);
1088 report_problem_in(
1089 state,
1090 &store,
1091 pending,
1092 version.as_deref(),
1093 &reason,
1094 pending.is_some(),
1095 )
1096 .await;
1097 }
1098 result
1099}
1100
1101async fn after_merge_inner(
1102 state: &mut RunState,
1103 pr_url: &str,
1104 home: Option<&Path>,
1105 progress: &mut Progress,
1106) -> Result<()> {
1107 if !state.config.merge.release_bump {
1108 return Ok(());
1109 }
1110 let Some(winner) = state.winner().cloned() else {
1111 return Ok(());
1112 };
1113 let repo = state.repo.clone();
1114 let base = state.base_branch.clone();
1115 let remote = state.config.merge.remote.clone();
1116
1117 let files = git::changed_files(&winner.worktree, &base, &winner.branch)
1118 .await
1119 .unwrap_or_default();
1120 if is_release_only(&files) {
1121 state.event(
1122 "bump",
1123 "the merged change touches only the release manifest; not treating it as a trigger",
1124 );
1125 return Ok(());
1126 }
1127
1128 git::fetch(&repo, &remote, &base).await.ok();
1131 if !base_has_cargo_toml(&repo, &remote, &base).await? {
1132 state.event(
1133 "bump",
1134 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping",
1135 );
1136 return Ok(());
1137 }
1138
1139 let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
1140 let Some(_lock) = wait_for_marker_lock(&marker).await? else {
1147 state.event(
1148 "bump",
1149 "another release bump decision held the lock past the wait ceiling; skipping this round",
1150 );
1151 return Ok(());
1152 };
1153
1154 git::fetch(&repo, &remote, &base).await.ok();
1155 let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
1156 .await
1157 .context("read Cargo.toml from the base branch")?;
1158 let base_version = current_version(&cargo_toml)?;
1159
1160 let mut pending = read_marker(&marker);
1161 if let Some(p) = &pending {
1162 match coalesce(Some(p), &base_version)? {
1163 Coalesce::Proceed => {
1164 clear_marker(&marker);
1167 pending = None;
1168 }
1169 Coalesce::Skip { target_version } => {
1170 if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
1171 state.event(
1172 "bump",
1173 format!(
1174 "the pending release bump to v{target_version} ({}) is no longer \
1175 open; treating it as abandoned",
1176 p.pr_url
1177 ),
1178 );
1179 clear_marker(&marker);
1180 pending = None;
1181 }
1182 }
1187 }
1188 }
1189
1190 if pending.is_none() {
1191 if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
1196 && let Some(target) = branch
1197 .strip_prefix("chore/release-v")
1198 .and_then(|v| Version::parse(v).ok())
1199 {
1200 let base_parsed = Version::parse(&base_version)?;
1201 if target > base_parsed
1202 && let Some(level) = level_between(base_parsed, target)
1203 {
1204 let adopted = PendingBump {
1205 target_version: target.to_string(),
1206 level,
1207 branch,
1208 pr_url: url,
1209 };
1210 let _ = write_marker(&marker, &adopted);
1213 pending = Some(adopted);
1214 }
1215 }
1216 }
1217
1218 progress.pending_pr = pending.as_ref().map(|p| p.pr_url.clone());
1219 let title = pr_title(&repo, pr_url).await.unwrap_or_default();
1220 let subject = land::merge_subject(&title, &state.instruction);
1221 let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
1222 .await
1223 .unwrap_or_default();
1224 let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
1225
1226 let spec: AgentSpec = agent::pick(
1232 &state.config.agents,
1233 state.config.roles.chatter.as_deref(),
1234 &agent::installed,
1235 )
1236 .context("choose an agent for the release-bump decision")?;
1237 let mut seat = SeatState::new("bump", &spec.id, state.seed);
1238 let artifacts = agent::artifacts_dir(&state.dir());
1239 let out = agent::invoke(
1240 &spec,
1241 &mut seat,
1242 &Invocation {
1243 cwd: &repo,
1244 prompt: &prompt,
1245 timeout: DECISION_TIMEOUT,
1246 allow_write: false,
1249 sessions: false,
1250 artifacts: &artifacts,
1251 stem: "bump-decision",
1252 run: &state.id,
1253 node: "bump",
1254 cache_dir: state.config.cache_dir().as_deref(),
1255 attachments: &[],
1256 },
1257 )
1258 .await
1259 .context("ask an agent how big the merged change was")?;
1260 if !out.usable() {
1261 bail!(
1262 "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
1263 out.exit_code,
1264 out.timed_out
1265 );
1266 }
1267 let decision = parse_decision(&out.text).context("parse the release-bump decision")?;
1268
1269 if let Some(p) = pending {
1270 return match pending_action(p.level, decision.level) {
1271 PendingAction::AlreadyCovered => {
1272 state.event(
1273 "bump",
1274 format!(
1275 "a release bump to v{} ({}) already covers at least a {} change; not \
1276 opening another",
1277 p.target_version,
1278 p.pr_url,
1279 decision.level.as_str()
1280 ),
1281 );
1282 Ok(())
1283 }
1284 PendingAction::Escalate => {
1285 progress.version = Some(
1286 Version::parse(&base_version)?
1287 .bump(decision.level)
1288 .to_string(),
1289 );
1290 escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
1291 }
1292 };
1293 }
1294
1295 let next = Version::parse(&base_version)?
1296 .bump(decision.level)
1297 .to_string();
1298 progress.version = Some(next.clone());
1299 let branch = format!("chore/release-v{next}");
1300 let worktree = state.dir().join("bump");
1301 let (shared, branch_ref, next_ref, decision_ref) = (&*state, &branch, &next, &decision);
1302 let (pr_url_opened, outcome) =
1303 release_attempt(
1304 &repo,
1305 &remote,
1306 &base,
1307 &worktree,
1308 &branch,
1309 |head| {
1310 let repo = repo.clone();
1311 async move { gh_open_pr_for_head(&repo, &head).await }
1312 },
1313 |wt| async move {
1314 open_bump_pr(shared, &wt, branch_ref, next_ref, decision_ref, pr_url).await
1315 },
1316 )
1317 .await?;
1318 let (automerge_warning, merged_detail) = match outcome {
1319 AutomergeOutcome::Enabled => (None, None),
1320 AutomergeOutcome::MergedDirectly { detail } => (None, Some(detail)),
1321 AutomergeOutcome::Failed { reason } => (Some(reason), None),
1322 };
1323
1324 let marker_write = write_marker(
1333 &marker,
1334 &PendingBump {
1335 target_version: next.clone(),
1336 level: decision.level,
1337 branch,
1338 pr_url: pr_url_opened.clone(),
1339 },
1340 );
1341 state.event(
1342 "bump",
1343 format!(
1344 "opened a {} release bump to v{next} ({}): {pr_url_opened}",
1345 decision.level.as_str(),
1346 decision.reason
1347 ),
1348 );
1349 if let Err(e) = marker_write {
1350 state.event(
1351 "bump",
1352 format!(
1353 "could not record the pending release bump marker for v{next}: {e:#}; a later \
1354 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
1355 the forge either"
1356 ),
1357 );
1358 }
1359 state.release_bump = Some(run::ReleaseBump {
1360 pr_url: Some(pr_url_opened.clone()),
1361 version: Some(next.clone()),
1362 automerge_enabled: automerge_warning.is_none() && merged_detail.is_none(),
1363 merged_directly: merged_detail.is_some(),
1364 ..run::ReleaseBump::default()
1365 });
1366 if let Some(detail) = merged_detail {
1367 clear_marker(&marker);
1370 state.event("bump", format!("merged v{next} directly: {detail}"));
1371 }
1372 if let Some(warning) = automerge_warning {
1373 state.event(
1374 "bump",
1375 format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
1376 );
1377 report_problem(state, Some(&pr_url_opened), Some(&next), &warning).await;
1378 }
1379 Ok(())
1380}
1381
1382pub const NOTICE_NODE: &str = "release-bump";
1387
1388fn automerge_hint(reason: &str) -> &'static str {
1395 let r = reason.to_lowercase();
1396 if is_clean_status_refusal(reason) {
1397 "merge the release pull request by hand; CI is already green"
1398 } else if r.contains("enablepullrequestautomerge") || r.contains("protected branch rules") {
1399 "merge the release pull request by hand, and enable branch protection with required \
1400 status checks on the base branch so automerge can work next time"
1401 } else {
1402 "merge the release pull request by hand"
1403 }
1404}
1405
1406fn automerge_failure_comment(reason: &str) -> String {
1408 format!(
1409 "magi could not enable automerge on this pull request: {reason}\n\n\
1410 Action required: {}. Until then the release does not happen.",
1411 automerge_hint(reason)
1412 )
1413}
1414
1415#[cfg(test)]
1423fn surface_problem(
1424 state: &mut RunState,
1425 store: &Notices,
1426 pr_url: Option<&str>,
1427 version: Option<&str>,
1428 reason: &str,
1429) -> Result<(Notice, Option<String>)> {
1430 surface_problem_in(state, store, pr_url, version, reason, false)
1431}
1432
1433fn surface_problem_in(
1437 state: &mut RunState,
1438 store: &Notices,
1439 pr_url: Option<&str>,
1440 version: Option<&str>,
1441 reason: &str,
1442 pending: bool,
1443) -> Result<(Notice, Option<String>)> {
1444 let action = if let (true, Some(url)) = (pending, pr_url) {
1445 let ja = crate::lang::is_japanese(&state.config.graph.language);
1446 let first = reason.lines().next().filter(|l| !l.is_empty());
1447 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1448 if ja {
1449 let target = version.map(|v| format!(" v{v}")).unwrap_or_default();
1450 format!(
1451 "既存のリリース PR {url} を{target}へ更新できませんでした{cause}。PR を手で更新してください"
1452 )
1453 } else {
1454 let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1455 format!(
1456 "the pending release pull request {url} could not be updated{target}{cause}; update it by hand"
1457 )
1458 }
1459 } else if pr_url.is_some() {
1460 automerge_hint(reason).to_owned()
1461 } else {
1462 let ja = crate::lang::is_japanese(&state.config.graph.language);
1463 let first = reason.lines().next().filter(|l| !l.is_empty());
1464 if ja {
1465 let target = version.map(|v| format!(" (v{v})")).unwrap_or_default();
1466 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1467 format!(
1468 "リリースバンプ{target}は実行されませんでした{cause}。リリース PR を手で開いてください"
1469 )
1470 } else {
1471 let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1472 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1473 format!(
1474 "the release bump{target} did not run{cause}; open the release pull request by hand"
1475 )
1476 }
1477 };
1478 let record = state.release_bump.get_or_insert_with(Default::default);
1479 record.pr_url = pr_url.map(str::to_owned).or(record.pr_url.take());
1480 record.version = version.map(str::to_owned).or(record.version.take());
1481 record.automerge_enabled = false;
1482 record.problem = Some(reason.to_owned());
1483 record.action_required = Some(action.clone());
1484
1485 let mut notice = Notice::error(
1486 &format!("release-bump:{}", state.id),
1487 format!(
1488 "Run {} merged, but its release step failed: {action}.",
1489 state.id
1490 ),
1491 );
1492 notice = match pr_url {
1493 Some(url) => notice.link(Link::Url {
1494 url: url.to_owned(),
1495 }),
1496 None => notice.link(Link::Run {
1497 id: state.id.clone(),
1498 }),
1499 };
1500 let notice = store
1501 .raise(notice)
1502 .context("raise the release-bump notification")?;
1503 state.event("bump", format!("needs attention: {action}"));
1504 let comment = pr_url
1505 .filter(|_| !pending)
1506 .map(|_| automerge_failure_comment(reason));
1507 Ok((notice, comment))
1508}
1509
1510pub async fn report_problem(
1515 state: &mut RunState,
1516 pr_url: Option<&str>,
1517 version: Option<&str>,
1518 reason: &str,
1519) {
1520 report_problem_in(state, &Notices::open(), pr_url, version, reason, false).await;
1521}
1522
1523async fn report_problem_in(
1525 state: &mut RunState,
1526 store: &Notices,
1527 pr_url: Option<&str>,
1528 version: Option<&str>,
1529 reason: &str,
1530 pending: bool,
1531) {
1532 match surface_problem_in(state, store, pr_url, version, reason, pending) {
1533 Ok((notice, comment)) => {
1534 if let (Some(url), Some(body)) = (pr_url, comment)
1535 && let Err(e) = gh_pr_comment(
1536 &state.repo,
1537 url,
1538 &crate::scrub::scrub(&body, &crate::scrub::Identity::current()),
1539 )
1540 .await
1541 {
1542 state.event("bump", format!("could not comment on {url}: {e:#}"));
1543 }
1544 let summary = match pr_url {
1547 Some(url) if pending => format!("Release PR could not be updated: {url}"),
1548 Some(url) => format!("Release PR needs a human: {url}"),
1549 None if crate::lang::is_japanese(&state.config.graph.language) => {
1550 "リリースバンプが実行されませんでした".to_owned()
1551 }
1552 None => "Release bump did not run".to_owned(),
1553 };
1554 let q = ask::Question::new(
1555 state.id.clone(),
1556 NOTICE_NODE.to_owned(),
1557 "bump".to_owned(),
1558 summary,
1559 notice.message.clone(),
1560 Vec::new(),
1561 );
1562 if let Err(e) = ask::notify(&state.config.notify, &q).await {
1563 tracing::warn!(
1564 "could not notify about the release bump of {}: {e:#}",
1565 state.id
1566 );
1567 }
1568 }
1569 Err(e) => state.event("bump", format!("could not raise a notice: {e:#}")),
1570 }
1571}
1572
1573async fn gh_pr_comment(cwd: &Path, pr_url: &str, body: &str) -> Result<()> {
1574 let out = tokio::process::Command::new("gh")
1575 .args(["pr", "comment", pr_url, "--body", body])
1576 .current_dir(cwd)
1577 .quiet()
1578 .stdin(std::process::Stdio::null())
1579 .output()
1580 .await
1581 .context("spawn gh pr comment")?;
1582 if out.status.success() {
1583 Ok(())
1584 } else {
1585 bail!(
1586 "gh pr comment: {}",
1587 String::from_utf8_lossy(&out.stderr).trim()
1588 )
1589 }
1590}
1591
1592async fn escalate_pending(
1601 state: &mut RunState,
1602 repo: &Path,
1603 remote: &str,
1604 pending: &PendingBump,
1605 decision: &BumpDecision,
1606 base_version: &str,
1607 marker: &Path,
1608) -> Result<()> {
1609 let next = Version::parse(base_version)?
1610 .bump(decision.level)
1611 .to_string();
1612 let worktree = state.dir().join("bump");
1613 git::worktree_remove(repo, &worktree).await.ok();
1614 let checked_out = git::git_raw(
1615 repo,
1616 &[
1617 "worktree",
1618 "add",
1619 "--force",
1620 &worktree.to_string_lossy(),
1621 &pending.branch,
1622 ],
1623 )
1624 .await?;
1625 if !checked_out.ok() {
1626 bail!(
1627 "checking out the pending release branch {} failed: {}",
1628 pending.branch,
1629 checked_out.stderr
1630 );
1631 }
1632
1633 let pushed: Result<()> = async {
1638 let cargo_toml_path = worktree.join("Cargo.toml");
1639 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1640 .await
1641 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1642 let rewritten = rewrite_cargo_version(&toml, &next)?;
1643 tokio::fs::write(&cargo_toml_path, rewritten)
1644 .await
1645 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1646 sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1647 let committed = git::commit_all(
1648 &worktree,
1649 &format!(
1650 "chore: release v{next} (supersedes v{})",
1651 pending.target_version
1652 ),
1653 )
1654 .await
1655 .context("commit the escalated version bump")?;
1656 if !committed {
1657 bail!("escalating the version bump left nothing to commit");
1658 }
1659 let pushed = git::push(&worktree, remote, &pending.branch).await?;
1660 if !pushed.ok() {
1661 bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1662 }
1663 Ok(())
1664 }
1665 .await;
1666 if let Err(e) = pushed {
1667 git::worktree_remove(repo, &worktree).await.ok();
1668 return Err(e);
1669 }
1670
1671 let title_warning = match gh_pr_edit_title(
1675 &worktree,
1676 &pending.pr_url,
1677 &crate::scrub::scrub(
1678 &format!("chore: release v{next} ({} bump)", decision.level.as_str()),
1679 &crate::scrub::Identity::current(),
1680 ),
1681 )
1682 .await
1683 {
1684 Ok(()) => None,
1685 Err(e) => Some(e.to_string()),
1686 };
1687 git::worktree_remove(repo, &worktree).await.ok();
1688
1689 let marker_write = write_marker(
1690 marker,
1691 &PendingBump {
1692 target_version: next.clone(),
1693 level: decision.level,
1694 branch: pending.branch.clone(),
1695 pr_url: pending.pr_url.clone(),
1696 },
1697 );
1698 state.event(
1699 "bump",
1700 format!(
1701 "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1702 pending.target_version,
1703 decision.level.as_str(),
1704 decision.reason,
1705 pending.pr_url
1706 ),
1707 );
1708 if let Err(e) = marker_write {
1709 state.event(
1710 "bump",
1711 format!(
1712 "could not update the pending release bump marker to v{next}: {e:#}; a later \
1713 merge may misjudge whether it is already covered"
1714 ),
1715 );
1716 }
1717 if let Some(warning) = title_warning {
1718 state.event(
1719 "bump",
1720 format!(
1721 "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1722 subject may still read the superseded version",
1723 pending.pr_url
1724 ),
1725 );
1726 }
1727 Ok(())
1728}
1729
1730async fn gh_open_pr_for_head(repo: &Path, branch: &str) -> Result<bool> {
1733 let out = tokio::process::Command::new("gh")
1734 .args([
1735 "pr", "list", "--head", branch, "--state", "open", "--json", "url",
1736 ])
1737 .current_dir(repo)
1738 .quiet()
1739 .stdin(std::process::Stdio::null())
1740 .output()
1741 .await
1742 .context("spawn gh pr list")?;
1743 if !out.status.success() {
1744 bail!(
1745 "gh pr list --head {branch}: {}",
1746 String::from_utf8_lossy(&out.stderr).trim()
1747 );
1748 }
1749 let prs: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout)
1750 .with_context(|| format!("parse the pull requests headed by {branch}"))?;
1751 Ok(!prs.is_empty())
1752}
1753
1754async fn release_attempt<T, F, Fut, P, PFut>(
1763 repo: &Path,
1764 remote: &str,
1765 base: &str,
1766 worktree: &Path,
1767 branch: &str,
1768 open_pr: P,
1769 fill: F,
1770) -> Result<T>
1771where
1772 F: FnOnce(PathBuf) -> Fut,
1773 Fut: std::future::Future<Output = Result<T>>,
1774 P: Fn(String) -> PFut,
1775 PFut: std::future::Future<Output = Result<bool>>,
1776{
1777 let start = format!("{remote}/{base}");
1778 git::worktree_remove(repo, worktree).await.ok();
1779 let mut retried = false;
1780 if git::branch_exists(repo, branch).await? {
1781 reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await?;
1782 retried = true;
1783 }
1784 loop {
1785 if let Err(e) = git::git(repo, &["branch", branch, &start]).await {
1790 let e = e.context("create the release branch");
1791 if !git::branch_exists(repo, branch).await.unwrap_or(false) {
1792 return Err(e);
1793 }
1794 if retried {
1796 return Err(anyhow!(
1797 "{e:#}; left {branch} in place: it is not one this attempt created"
1798 ));
1799 }
1800 retried = true;
1801 if let Err(r) = reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await {
1802 return Err(anyhow!("{r:#} (after: {e:#})"));
1803 }
1804 continue;
1805 }
1806 if let Some(parent) = worktree.parent() {
1807 tokio::fs::create_dir_all(parent).await.ok();
1808 }
1809 let path = worktree.to_string_lossy();
1810 if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
1811 let e = e.context("create the release-bump worktree");
1812 return Err(discard_attempt(repo, remote, worktree, branch, e).await);
1813 }
1814 break;
1815 }
1816 match fill(worktree.to_path_buf()).await {
1817 Ok(v) => {
1818 git::worktree_remove(repo, worktree).await.ok();
1821 Ok(v)
1822 }
1823 Err(e) => Err(discard_attempt(repo, remote, worktree, branch, e).await),
1824 }
1825}
1826
1827async fn discard_attempt(
1829 repo: &Path,
1830 remote: &str,
1831 worktree: &Path,
1832 branch: &str,
1833 cause: anyhow::Error,
1834) -> anyhow::Error {
1835 git::worktree_remove(repo, worktree).await.ok();
1836 if !git::branch_exists(repo, branch).await.unwrap_or(false) {
1837 return cause;
1838 }
1839 let note = match git::remote_has_branch(repo, remote, branch).await {
1842 Ok(false) => match git::branch_delete(repo, branch).await {
1843 Ok(true) => return cause,
1844 _ => format!("could not delete the local branch {branch}"),
1845 },
1846 Ok(true) => format!("left {branch} in place: it was pushed to {remote}"),
1847 Err(e) => format!(
1848 "left {branch} in place: could not tell whether it was pushed to {remote} ({e:#})"
1849 ),
1850 };
1851 anyhow!("{cause:#}; {note}")
1852}
1853
1854async fn reclaim_stale_branch<P, PFut>(
1859 repo: &Path,
1860 remote: &str,
1861 start: &str,
1862 branch: &str,
1863 open_pr: &P,
1864) -> Result<()>
1865where
1866 P: Fn(String) -> PFut,
1867 PFut: std::future::Future<Output = Result<bool>>,
1868{
1869 match git::remote_has_branch(repo, remote, branch).await {
1870 Ok(false) => {}
1871 Ok(true) => bail!("the branch {branch} already exists and is on {remote}; left alone"),
1872 Err(e) => bail!(
1873 "the branch {branch} already exists and could not check whether {remote} has it ({e:#}); left alone"
1874 ),
1875 }
1876 match open_pr(branch.to_string()).await {
1877 Ok(false) => {}
1878 Ok(true) => {
1879 bail!("the branch {branch} already exists and has an open pull request; left alone")
1880 }
1881 Err(e) => bail!(
1882 "the branch {branch} already exists and could not check for an open pull request \
1883 ({e:#}); left alone"
1884 ),
1885 }
1886 match git::commits_ahead(repo, start, branch).await {
1887 Ok(0) => {}
1888 Ok(n) => bail!(
1889 "the branch {branch} already exists with {n} commit(s) not in {start}; left alone"
1890 ),
1891 Err(e) => bail!(
1892 "the branch {branch} already exists and could not compare it with {start} ({e:#}); \
1893 left alone"
1894 ),
1895 }
1896 if let Some(held) = git::worktree_holding(repo, branch).await? {
1897 let same = |a: &Path, b: &Path| match (a.canonicalize(), b.canonicalize()) {
1898 (Ok(a), Ok(b)) => a == b,
1899 _ => a == b,
1900 };
1901 if same(&held, repo) {
1902 bail!(
1903 "the branch {branch} already exists and is checked out in the main checkout; left alone"
1904 );
1905 }
1906 git::worktree_remove(repo, &held).await.ok();
1907 }
1908 if !git::branch_delete(repo, branch).await? {
1909 bail!("the branch {branch} already exists and could not be deleted; left alone");
1910 }
1911 Ok(())
1912}
1913
1914async fn open_bump_pr(
1920 state: &RunState,
1921 worktree: &Path,
1922 branch: &str,
1923 next_version: &str,
1924 decision: &BumpDecision,
1925 source_pr_url: &str,
1926) -> Result<(String, AutomergeOutcome)> {
1927 let cargo_toml_path = worktree.join("Cargo.toml");
1928 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1929 .await
1930 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1931 let rewritten = rewrite_cargo_version(&toml, next_version)?;
1932 tokio::fs::write(&cargo_toml_path, rewritten)
1933 .await
1934 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1935
1936 sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
1937
1938 let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
1939 .await
1940 .context("commit the version bump")?;
1941 if !committed {
1942 bail!("the version bump left nothing to commit");
1943 }
1944
1945 let remote = state.config.merge.remote.clone();
1946 let pushed = git::push(worktree, &remote, branch).await?;
1947 if !pushed.ok() {
1948 bail!("pushing {branch} failed: {}", pushed.stderr);
1949 }
1950
1951 let (title, body) = release_pr(
1952 decision.level.as_str(),
1953 &decision.reason,
1954 next_version,
1955 &state.id,
1956 source_pr_url,
1957 );
1958 let who = crate::scrub::Identity::current();
1959 let (title, body) = (
1960 crate::scrub::scrub(&title, &who),
1961 crate::scrub::scrub(&body, &who),
1962 );
1963 let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
1964 let outcome = match gh_enable_automerge(worktree, &url).await {
1965 Ok(()) => AutomergeOutcome::Enabled,
1966 Err(e) => {
1967 let reason = e.to_string();
1968 if is_clean_status_refusal(&reason) {
1969 gh_merge_directly(worktree, &url, &title, reason).await
1970 } else {
1971 AutomergeOutcome::Failed { reason }
1972 }
1973 }
1974 };
1975 Ok((url, outcome))
1976}
1977
1978#[derive(Debug, Clone, PartialEq, Eq)]
1980enum AutomergeOutcome {
1981 Enabled,
1983 MergedDirectly { detail: String },
1985 Failed { reason: String },
1987}
1988
1989fn is_clean_status_refusal(reason: &str) -> bool {
1995 let r = reason.to_lowercase();
1996 r.contains("is in clean status") && r.contains("enablepullrequestautomerge")
1997}
1998
1999fn bump_merge_argv(pr_url: &str, subject: &str) -> Vec<String> {
2002 [
2003 "pr",
2004 "merge",
2005 pr_url,
2006 "--squash",
2007 "--delete-branch",
2008 "--subject",
2009 subject,
2010 ]
2011 .map(str::to_owned)
2012 .to_vec()
2013}
2014
2015fn resolve_direct_merge(
2019 refusal: &str,
2020 argv: &[String],
2021 merge_ok: bool,
2022 stderr: &str,
2023 after: Option<land::PrLifecycle>,
2024) -> AutomergeOutcome {
2025 if merge_ok {
2026 return AutomergeOutcome::MergedDirectly {
2027 detail: format!("automerge was refused ({refusal}); gh {}", argv.join(" ")),
2028 };
2029 }
2030 match land::merged_after_all(argv, stderr, after) {
2031 Some(m) => AutomergeOutcome::MergedDirectly { detail: m.detail },
2032 None => AutomergeOutcome::Failed {
2033 reason: format!("{refusal}; merging directly failed too: {}", stderr.trim()),
2034 },
2035 }
2036}
2037
2038async fn gh_merge_directly(
2042 cwd: &Path,
2043 pr_url: &str,
2044 subject: &str,
2045 refusal: String,
2046) -> AutomergeOutcome {
2047 let argv = bump_merge_argv(pr_url, subject);
2048 let out = match tokio::process::Command::new("gh")
2049 .args(&argv)
2050 .current_dir(cwd)
2051 .quiet()
2052 .stdin(std::process::Stdio::null())
2053 .output()
2054 .await
2055 {
2056 Ok(o) => o,
2057 Err(e) => {
2058 return AutomergeOutcome::Failed {
2059 reason: format!("{refusal}; could not spawn gh to merge directly: {e}"),
2060 };
2061 }
2062 };
2063 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
2064 let after = if out.status.success() {
2067 None
2068 } else {
2069 land::lifecycle(cwd, pr_url).await.ok()
2070 };
2071 resolve_direct_merge(&refusal, &argv, out.status.success(), &stderr, after)
2072}
2073
2074async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
2082 let mut cmd = tokio::process::Command::new("cargo");
2083 cmd.arg("build").current_dir(worktree).quiet();
2084 if let Some(dir) = cache_dir {
2085 cmd.env("CARGO_TARGET_DIR", dir);
2086 }
2087 let out = cmd
2088 .stdin(std::process::Stdio::null())
2089 .output()
2090 .await
2091 .context("spawn cargo build")?;
2092 if !out.status.success() {
2093 bail!(
2094 "cargo build failed while syncing Cargo.lock: {}",
2095 String::from_utf8_lossy(&out.stderr).trim()
2096 );
2097 }
2098 Ok(())
2099}
2100
2101fn release_pr(
2106 level: &str,
2107 reason: &str,
2108 next_version: &str,
2109 run_id: &str,
2110 source_pr_url: &str,
2111) -> (String, String) {
2112 let title = format!("chore: release v{next_version} ({level} bump)");
2113 let body = format!(
2114 "## Background\n\n\
2115 A change that was just merged is a `{level}` change, so the crate needs a new \
2116 release: {reason}\n\n\
2117 Triggered by magi run `{run_id}`, which landed {source}.\n\n\
2118 ## Change\n\n\
2119 Raises the package version to `v{next_version}` in `Cargo.toml`, with \
2120 `Cargo.lock` following it. Nothing else changes.\n\n\
2121 ## Risk\n\n\
2122 Version-bump-only, so there is nothing here for a reviewer to find. Merging \
2123 it starts the release pipeline (auto-tag, then the release workflow).",
2124 source = source_pr_url,
2125 );
2126 (title, body)
2127}
2128
2129async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
2131 let out = tokio::process::Command::new("gh")
2132 .args(["pr", "view", pr_url, "--json", "title"])
2133 .current_dir(repo)
2134 .quiet()
2135 .stdin(std::process::Stdio::null())
2136 .output()
2137 .await
2138 .context("spawn gh pr view")?;
2139 if !out.status.success() {
2140 bail!(
2141 "gh pr view {pr_url}: {}",
2142 String::from_utf8_lossy(&out.stderr).trim()
2143 );
2144 }
2145 #[derive(Deserialize)]
2146 struct Title {
2147 title: String,
2148 }
2149 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
2150 .context("parse `gh pr view --json title` output")?;
2151 Ok(parsed.title)
2152}
2153
2154async fn gh_pr_create(
2155 cwd: &Path,
2156 base: &str,
2157 head: &str,
2158 title: &str,
2159 body: &str,
2160) -> Result<String> {
2161 let out = tokio::process::Command::new("gh")
2162 .args([
2163 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
2164 ])
2165 .current_dir(cwd)
2166 .quiet()
2167 .stdin(std::process::Stdio::null())
2168 .output()
2169 .await
2170 .context("spawn gh pr create")?;
2171 if out.status.success() {
2172 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2173 } else {
2174 bail!(
2175 "gh pr create: {}",
2176 String::from_utf8_lossy(&out.stderr).trim()
2177 )
2178 }
2179}
2180
2181async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
2185 let out = tokio::process::Command::new("gh")
2186 .args([
2187 "pr",
2188 "merge",
2189 pr_url,
2190 "--auto",
2191 "--squash",
2192 "--delete-branch",
2193 ])
2194 .current_dir(cwd)
2195 .quiet()
2196 .stdin(std::process::Stdio::null())
2197 .output()
2198 .await
2199 .context("spawn gh pr merge --auto")?;
2200 if out.status.success() {
2201 Ok(())
2202 } else {
2203 bail!(
2204 "gh pr merge --auto: {}",
2205 String::from_utf8_lossy(&out.stderr).trim()
2206 )
2207 }
2208}
2209
2210async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
2214 let out = tokio::process::Command::new("gh")
2215 .args(["pr", "edit", pr_url, "--title", title])
2216 .current_dir(cwd)
2217 .quiet()
2218 .stdin(std::process::Stdio::null())
2219 .output()
2220 .await
2221 .context("spawn gh pr edit")?;
2222 if out.status.success() {
2223 Ok(())
2224 } else {
2225 bail!(
2226 "gh pr edit --title: {}",
2227 String::from_utf8_lossy(&out.stderr).trim()
2228 )
2229 }
2230}
2231
2232#[cfg(test)]
2233mod tests {
2234 use super::*;
2235 use crate::notices::Severity;
2236
2237 #[test]
2238 fn github_facing_bump_text_is_english() {
2239 let (title, body) =
2240 release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
2241 assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
2242 assert_eq!(title, "chore: release v0.37.0 (minor bump)");
2243 assert!(
2244 body.contains("## Background") && body.contains("## Change"),
2245 "{body}"
2246 );
2247 let p = decision_prompt("s", "i", "d", &[], "0.36.5");
2248 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
2249 }
2250 use crate::config::Config;
2251 use crate::land::PrLifecycle;
2252
2253 #[tokio::test]
2259 async fn a_disabled_config_does_nothing() {
2260 let config = Config {
2261 merge: crate::config::Merge {
2262 release_bump: false,
2263 ..crate::config::Merge::default()
2264 },
2265 ..Config::default()
2266 };
2267 let mut state = RunState::new(
2268 PathBuf::from("/no/such/repo"),
2269 "main".to_owned(),
2270 "0000000000000000000000000000000000000000".to_owned(),
2271 "irrelevant".to_owned(),
2272 config,
2273 );
2274 after_merge(&mut state, "https://example.invalid/pull/1")
2275 .await
2276 .expect("a disabled config must return Ok without touching anything");
2277 assert!(
2278 state.events.is_empty(),
2279 "nothing should happen at all, not even a logged event"
2280 );
2281 }
2282
2283 async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
2285 let dir = tempfile::tempdir().unwrap();
2286 let origin = dir.path().join("origin.git");
2287 let repo = dir.path().join("repo");
2288 let o = origin.to_string_lossy().into_owned();
2289 git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
2290 .await
2291 .unwrap();
2292 tokio::fs::create_dir_all(&repo).await.unwrap();
2293 git::git(&repo, &["init", "-b", "main"]).await.unwrap();
2294 git::git(&repo, &["config", "user.name", "test"])
2295 .await
2296 .unwrap();
2297 git::git(&repo, &["config", "user.email", "test@example.com"])
2298 .await
2299 .unwrap();
2300 for (name, body) in files {
2301 tokio::fs::write(repo.join(name), body).await.unwrap();
2302 }
2303 git::git(&repo, &["add", "-A"]).await.unwrap();
2304 git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
2305 git::git(&repo, &["remote", "add", "origin", &o])
2306 .await
2307 .unwrap();
2308 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
2309 (dir, repo)
2310 }
2311
2312 async fn no_pr(_: String) -> Result<bool> {
2313 Ok(false)
2314 }
2315
2316 async fn with_branch(ahead: bool) -> (tempfile::TempDir, PathBuf) {
2319 let (d, repo) = origin_with(&[("f", "x\n")]).await;
2320 git::git(&repo, &["branch", "b"]).await.unwrap();
2321 if ahead {
2322 git::git(&repo, &["checkout", "-q", "b"]).await.unwrap();
2323 git::git(&repo, &["commit", "--allow-empty", "-m", "wip"])
2324 .await
2325 .unwrap();
2326 git::git(&repo, &["checkout", "-q", "main"]).await.unwrap();
2327 }
2328 (d, repo)
2329 }
2330
2331 async fn attempt(repo: &Path, open_pr: bool, fail_after_push: Option<bool>) -> Result<()> {
2332 let wt = repo.parent().unwrap().join("bump");
2333 release_attempt(
2334 repo,
2335 "origin",
2336 "main",
2337 &wt,
2338 "b",
2339 |_| async move { Ok(open_pr) },
2340 |w| async move {
2341 if fail_after_push == Some(true) {
2342 git::push(&w, "origin", "b").await?;
2343 }
2344 if fail_after_push.is_some() {
2345 bail!("cargo build failed");
2346 }
2347 Ok(())
2348 },
2349 )
2350 .await
2351 }
2352
2353 #[tokio::test]
2354 async fn a_stale_ancestor_branch_is_deleted_and_the_attempt_proceeds() {
2355 let (_d, repo) = with_branch(false).await;
2356 attempt(&repo, false, None).await.unwrap();
2357 assert!(git::branch_exists(&repo, "b").await.unwrap());
2359 }
2360
2361 #[tokio::test]
2362 async fn a_stale_branch_holding_a_leftover_worktree_is_reclaimed() {
2363 let (_d, repo) = with_branch(false).await;
2364 let old = repo.parent().unwrap().join("old");
2365 git::git(&repo, &["worktree", "add", &old.to_string_lossy(), "b"])
2366 .await
2367 .unwrap();
2368 attempt(&repo, false, None).await.unwrap();
2369 assert!(!old.exists());
2370 }
2371
2372 #[tokio::test]
2373 async fn a_stale_branch_with_an_unmerged_commit_is_kept_with_a_reason() {
2374 let (_d, repo) = with_branch(true).await;
2375 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2376 assert!(e.contains("`b`") || e.contains("branch b"), "{e}");
2377 assert!(e.contains("1 commit(s) not in origin/main"), "{e}");
2378 assert!(git::branch_exists(&repo, "b").await.unwrap());
2379 }
2380
2381 #[tokio::test]
2382 async fn a_stale_branch_on_the_remote_is_kept() {
2383 let (_d, repo) = with_branch(false).await;
2384 git::git(&repo, &["push", "origin", "b"]).await.unwrap();
2385 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2386 assert!(e.contains("branch b") && e.contains("is on origin"), "{e}");
2387 assert!(git::branch_exists(&repo, "b").await.unwrap());
2388 }
2389
2390 #[tokio::test]
2391 async fn a_stale_branch_with_an_open_pull_request_is_kept() {
2392 let (_d, repo) = with_branch(false).await;
2393 let e = attempt(&repo, true, None).await.unwrap_err().to_string();
2394 assert!(e.contains("open pull request"), "{e}");
2395 assert!(git::branch_exists(&repo, "b").await.unwrap());
2396 }
2397
2398 #[tokio::test]
2399 async fn an_unanswerable_pull_request_check_keeps_the_branch() {
2400 let (_d, repo) = with_branch(false).await;
2401 let wt = repo.parent().unwrap().join("bump");
2402 let e = release_attempt(
2403 &repo,
2404 "origin",
2405 "main",
2406 &wt,
2407 "b",
2408 |_| async { bail!("offline") },
2409 |_| async { Ok(()) },
2410 )
2411 .await
2412 .unwrap_err()
2413 .to_string();
2414 assert!(
2415 e.contains("could not check for an open pull request"),
2416 "{e}"
2417 );
2418 assert!(git::branch_exists(&repo, "b").await.unwrap());
2419 }
2420
2421 #[tokio::test]
2422 async fn a_failed_attempt_removes_its_own_worktree_and_branch() {
2423 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2424 let wt = repo.parent().unwrap().join("bump");
2425 let e = attempt(&repo, false, Some(false)).await.unwrap_err();
2426 assert!(format!("{e:#}").contains("cargo build failed"));
2427 assert!(!wt.exists());
2428 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2429 }
2430
2431 #[tokio::test]
2432 async fn a_pushed_branch_survives_a_failed_attempt_and_the_reason_says_so() {
2433 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2434 let e = attempt(&repo, false, Some(true)).await.unwrap_err();
2435 let e = format!("{e:#}");
2436 assert!(
2437 e.contains("cargo build failed") && e.contains("pushed to origin"),
2438 "{e}"
2439 );
2440 assert!(git::branch_exists(&repo, "b").await.unwrap());
2441 }
2442
2443 #[tokio::test]
2444 async fn a_branch_left_by_a_half_done_worktree_add_is_removed() {
2445 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2448 let blocker = repo.parent().unwrap().join("blocker");
2449 tokio::fs::write(&blocker, "file").await.unwrap();
2450 let wt = blocker.join("bump");
2451 let r = release_attempt(
2452 &repo,
2453 "origin",
2454 "main",
2455 &wt,
2456 "b",
2457 |_| async { bail!("offline") },
2458 |_| async { Ok(()) },
2459 )
2460 .await;
2461 assert!(r.is_err());
2462 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2463 }
2464
2465 #[tokio::test]
2466 async fn the_retry_is_taken_at_most_once() {
2467 let (_d, repo) = with_branch(false).await;
2470 let blocker = repo.parent().unwrap().join("blocker");
2471 tokio::fs::write(&blocker, "file").await.unwrap();
2472 let wt = blocker.join("bump");
2473 let r = release_attempt(&repo, "origin", "main", &wt, "b", no_pr, |_| async {
2474 Ok(())
2475 })
2476 .await;
2477 assert!(r.is_err());
2478 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2479 }
2480
2481 #[tokio::test]
2482 async fn base_has_cargo_toml_tells_rust_from_non_rust() {
2483 let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
2484 assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
2485 let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
2486 assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
2487 assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
2489 }
2490
2491 #[tokio::test]
2492 async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
2493 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2494 let home = tempfile::tempdir().unwrap();
2495 let mut state = RunState::new(
2496 repo.clone(),
2497 "main".to_owned(),
2498 "0000000000000000000000000000000000000000".to_owned(),
2499 "task".to_owned(),
2500 Config::default(),
2501 );
2502 state.candidates.push(crate::run::Candidate {
2503 index: 0,
2504 label: 'A',
2505 agent: "x".to_owned(),
2506 branch: "main".to_owned(),
2507 worktree: repo.clone(),
2508 summary: String::new(),
2509 stat: String::new(),
2510 files: 1,
2511 commits: 1,
2512 empty: false,
2513 failed: None,
2514 verified_noop: None,
2515 duration_ms: 0,
2516 folded: false,
2517 });
2518 state.tally = Some(
2519 serde_json::from_value(serde_json::json!({
2520 "first_choice": {}, "borda": {}, "winner": "A",
2521 "unanimous_initial": true, "deliberated": false,
2522 "changed_votes": 0, "unanimous_final": true,
2523 }))
2524 .unwrap(),
2525 );
2526 after_merge_at(
2527 &mut state,
2528 "https://example.invalid/pull/1",
2529 Some(home.path()),
2530 None,
2531 )
2532 .await
2533 .expect("a non-Rust repository is not an error");
2534 let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
2535 assert_eq!(bumps.len(), 1, "{:?}", state.events);
2536 assert_eq!(
2537 bumps[0].message,
2538 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
2539 );
2540 assert!(
2541 std::fs::read_dir(home.path()).unwrap().next().is_none(),
2542 "no marker and no lock may be created"
2543 );
2544 }
2545
2546 fn winner_state(repo: &Path, base: &str) -> RunState {
2547 let mut state = RunState::new(
2548 repo.to_path_buf(),
2549 base.to_owned(),
2550 "0000000000000000000000000000000000000000".to_owned(),
2551 "task".to_owned(),
2552 Config::default(),
2553 );
2554 state.candidates.push(crate::run::Candidate {
2555 index: 0,
2556 label: 'A',
2557 agent: "x".to_owned(),
2558 branch: "main".to_owned(),
2559 worktree: repo.to_path_buf(),
2560 summary: String::new(),
2561 stat: String::new(),
2562 files: 1,
2563 commits: 1,
2564 empty: false,
2565 failed: None,
2566 verified_noop: None,
2567 duration_ms: 0,
2568 folded: false,
2569 });
2570 state.tally = Some(
2571 serde_json::from_value(serde_json::json!({
2572 "first_choice": {}, "borda": {}, "winner": "A",
2573 "unanimous_initial": true, "deliberated": false,
2574 "changed_votes": 0, "unanimous_final": true,
2575 }))
2576 .unwrap(),
2577 );
2578 state
2579 }
2580
2581 #[tokio::test]
2582 async fn a_real_failure_without_a_pr_raises_one_notice_and_a_retry_folds_into_it() {
2583 let (_d, repo) =
2584 origin_with(&[("Cargo.toml", "[package]\nname=\"x\"\nversion=\"0.1.0\"\n")]).await;
2585 let home = tempfile::tempdir().unwrap();
2586 let store = Notices::at(home.path().join("notifications"));
2587 let mut state = winner_state(&repo, "nope");
2589 let url = "https://example.invalid/pull/1";
2590 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2591 .await
2592 .expect_err("an unresolvable base is a failure");
2593 let listed = store.list();
2594 assert_eq!(listed.len(), 1, "{listed:?}");
2595 assert!(
2596 listed[0].message.contains("did not run"),
2597 "{}",
2598 listed[0].message
2599 );
2600 assert!(
2601 listed[0].message.contains("Cargo.toml"),
2602 "{}",
2603 listed[0].message
2604 );
2605 assert!(state.events.iter().any(|e| e.node == "bump"));
2606 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2607 .await
2608 .expect_err("still failing");
2609 let listed = store.list();
2610 assert_eq!(listed.len(), 1, "{listed:?}");
2611 assert_eq!(listed[0].count, 2);
2612 }
2613
2614 #[tokio::test]
2615 async fn not_eligible_cases_raise_no_notice() {
2616 let home = tempfile::tempdir().unwrap();
2617 let store = Notices::at(home.path().join("notifications"));
2618 let url = "https://example.invalid/pull/1";
2619 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2621 let mut state = winner_state(&repo, "main");
2622 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2623 .await
2624 .unwrap();
2625 let mut state = winner_state(&repo, "nope");
2627 state.config.merge.release_bump = false;
2628 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2629 .await
2630 .unwrap();
2631 let mut state = winner_state(&repo, "nope");
2633 state.tally = None;
2634 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2635 .await
2636 .unwrap();
2637 assert!(store.list().is_empty(), "{:?}", store.list());
2638 }
2639
2640 #[test]
2641 fn the_no_pr_notice_follows_the_configured_language() {
2642 let dir = tempfile::tempdir().unwrap();
2643 let store = Notices::at(dir.path().join("notifications"));
2644 let mut state = merged_state();
2645 state.config.graph.language = "ja".to_owned();
2646 let (n, _) = surface_problem(&mut state, &store, None, Some("0.2.0"), "boom").unwrap();
2647 assert!(n.message.contains("実行されませんでした"), "{}", n.message);
2648 assert!(n.message.contains("boom") && n.message.contains("v0.2.0"));
2649 }
2650
2651 #[test]
2652 fn a_failed_escalation_points_at_the_pending_pr_without_commenting() {
2653 let dir = tempfile::tempdir().unwrap();
2654 let store = Notices::at(dir.path().join("notifications"));
2655 let mut state = merged_state();
2656 let url = "https://example.invalid/pull/9";
2657 let (n, comment) = surface_problem_in(
2658 &mut state,
2659 &store,
2660 Some(url),
2661 Some("0.3.0"),
2662 "push failed",
2663 true,
2664 )
2665 .unwrap();
2666 assert!(comment.is_none());
2667 assert!(
2668 n.message.contains(url) && n.message.contains("v0.3.0"),
2669 "{}",
2670 n.message
2671 );
2672 assert!(!n.message.contains("open the release pull request by hand"));
2673 assert!(matches!(n.link, Some(Link::Url { .. })));
2674 }
2675
2676 #[test]
2677 fn a_failure_notice_names_the_pending_pr_and_the_first_line_of_the_cause() {
2678 let err = anyhow!("outer context").context("cargo build failed\nsecond line");
2679 let with_pr = Progress {
2680 version: Some("0.2.0".into()),
2681 pending_pr: Some("https://example.invalid/pull/9".into()),
2682 };
2683 let (v, reason) = notice_for_failure(&with_pr, &err);
2684 assert_eq!(v.as_deref(), Some("0.2.0"));
2685 assert_eq!(reason, "cargo build failed");
2686 let (v, reason) = notice_for_failure(&Progress::default(), &err);
2687 assert_eq!(v, None);
2688 assert_eq!(reason, "cargo build failed");
2689 }
2690
2691 const NO_RULES: &str = "gh pr merge --auto: GraphQL: Pull request Branch does not have \
2692 required protected branch rules (enablePullRequestAutoMerge)";
2693
2694 fn merged_state() -> RunState {
2695 run::set_home(std::env::temp_dir().join("magi-report-test-home"));
2698 let mut s = RunState::new(
2699 PathBuf::from("/no/such/repo"),
2700 "main".to_owned(),
2701 "0000000000000000000000000000000000000000".to_owned(),
2702 "task".to_owned(),
2703 Config::default(),
2704 );
2705 s.status = RunStatus::Merged;
2706 s
2707 }
2708
2709 #[test]
2710 fn the_known_automerge_refusal_names_branch_protection() {
2711 assert!(automerge_hint(NO_RULES).contains("branch protection with required"));
2712 let other = automerge_hint("gh: network unreachable");
2713 assert!(!other.contains("branch protection"), "{other}");
2714 let body = automerge_failure_comment(NO_RULES);
2715 assert!(body.contains("enablePullRequestAutoMerge"), "{body}");
2716 assert!(body.contains("Action required"), "{body}");
2717 }
2718
2719 const CLEAN: &str = "gh pr merge --auto: GraphQL: Pull request Pull request is in clean \
2720 status (enablePullRequestAutoMerge)";
2721
2722 #[test]
2723 fn clean_status_refusal_is_matched_narrowly() {
2724 assert!(is_clean_status_refusal(CLEAN));
2725 assert!(!is_clean_status_refusal(NO_RULES));
2726 assert!(!is_clean_status_refusal("gh: network unreachable"));
2727 assert!(!is_clean_status_refusal("Pull request is in clean status"));
2728 assert!(automerge_hint(CLEAN).contains("already green"));
2729 }
2730
2731 #[test]
2732 fn the_direct_merge_argv_matches_the_land_flags() {
2733 let a = bump_merge_argv("https://github.com/o/r/pull/9", "chore: release v1.0.0");
2734 let l = land::merge_argv(9, "chore: release v1.0.0");
2735 assert_eq!(a[..2], l[..2]);
2736 assert_eq!(a[3..], l[3..]);
2737 assert_eq!(a[2], "https://github.com/o/r/pull/9");
2738 }
2739
2740 #[test]
2741 fn a_direct_merge_is_judged_by_the_forge_not_the_exit_code() {
2742 let argv = bump_merge_argv("u", "t");
2743 let merged = |o: &AutomergeOutcome| matches!(o, AutomergeOutcome::MergedDirectly { .. });
2744 assert!(merged(&resolve_direct_merge(CLEAN, &argv, true, "", None)));
2745 let detached = "not on any branch";
2746 assert!(merged(&resolve_direct_merge(
2747 CLEAN,
2748 &argv,
2749 false,
2750 detached,
2751 Some(PrLifecycle::Merged)
2752 )));
2753 for after in [Some(PrLifecycle::Open), None] {
2754 let o = resolve_direct_merge(CLEAN, &argv, false, "boom", after);
2755 match o {
2756 AutomergeOutcome::Failed { reason } => {
2757 assert!(
2758 reason.contains("clean status") && reason.contains("boom"),
2759 "{reason}"
2760 )
2761 }
2762 other => panic!("expected Failed, got {other:?}"),
2763 }
2764 }
2765 }
2766
2767 #[test]
2768 fn a_directly_merged_bump_is_not_reported_as_pending_or_failed() {
2769 let mut state = merged_state();
2770 state.release_bump = Some(run::ReleaseBump {
2771 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
2772 version: Some("1.0.0".to_owned()),
2773 merged_directly: true,
2774 ..run::ReleaseBump::default()
2775 });
2776 assert!(!state.needs_attention());
2777 let text = crate::report::run(&state);
2778 assert!(text.contains("merged directly"), "{text}");
2779 assert!(!text.contains("FAILED"), "{text}");
2780 }
2781
2782 #[test]
2783 fn an_automerge_failure_is_recorded_shown_and_filed_and_survives_settling() {
2784 let dir = tempfile::tempdir().unwrap();
2785 let store = Notices::at(dir.path().join("notifications"));
2786 let questions = ask::Questions::at(dir.path().join("questions"));
2787 let mut state = merged_state();
2788 let url = "https://github.com/o/r/pull/35";
2789
2790 let (n, comment) =
2791 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
2792
2793 let comment = comment.expect("a PR was opened, so it gets a comment");
2795 assert!(comment.contains("branch protection"), "{comment}");
2796
2797 assert_eq!(state.status, RunStatus::Merged);
2799 assert!(state.needs_attention());
2800 let text = crate::report::run(&state);
2801 assert!(text.contains("release bump"), "{text}");
2802 assert!(text.contains("FAILED"), "{text}");
2803 assert!(text.contains(url), "{text}");
2804 assert!(text.contains("action required"), "{text}");
2805 assert!(crate::report::line(&state).contains("release needs a human"));
2806
2807 assert_eq!(n.severity, Severity::Error);
2809 assert_eq!(
2810 n.link,
2811 Some(Link::Url {
2812 url: url.to_owned()
2813 })
2814 );
2815 assert_eq!(store.list().len(), 1);
2816 assert!(questions.open_for(&state.id).is_empty());
2817
2818 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
2820 let listed = store.list();
2821 assert_eq!(listed.len(), 1);
2822 assert_eq!(listed[0].count, 2);
2823 }
2824
2825 #[test]
2826 fn a_bump_that_never_ran_is_surfaced_without_a_pr_comment() {
2827 let dir = tempfile::tempdir().unwrap();
2828 let store = Notices::at(dir.path().join("notifications"));
2829 let mut state = merged_state();
2830 let (n, comment) = surface_problem(&mut state, &store, None, None, "no agent").unwrap();
2831 assert!(comment.is_none());
2832 assert!(matches!(n.link, Some(Link::Run { .. })));
2833 assert!(state.needs_attention());
2834 }
2835
2836 #[test]
2837 fn version_parses_and_bumps_each_digit() {
2838 let v = Version::parse("0.4.0").unwrap();
2839 assert_eq!(
2840 v,
2841 Version {
2842 major: 0,
2843 minor: 4,
2844 patch: 0
2845 }
2846 );
2847
2848 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
2849 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
2850 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
2851 }
2852
2853 #[test]
2854 fn version_tolerates_a_prerelease_suffix_on_patch() {
2855 let v = Version::parse("1.2.3-rc1").unwrap();
2856 assert_eq!(
2857 v,
2858 Version {
2859 major: 1,
2860 minor: 2,
2861 patch: 3
2862 }
2863 );
2864 }
2865
2866 #[test]
2867 fn version_rejects_garbage() {
2868 assert!(Version::parse("not-a-version").is_err());
2869 assert!(Version::parse("1.2").is_err());
2870 }
2871
2872 #[test]
2873 fn decision_parses_each_level() {
2874 for (json, level) in [
2875 (
2876 r#"{"level":"major","reason":"drops a config key"}"#,
2877 BumpLevel::Major,
2878 ),
2879 (
2880 r#"{"level":"minor","reason":"adds a new flag"}"#,
2881 BumpLevel::Minor,
2882 ),
2883 (
2884 r#"{"level":"patch","reason":"fixes a race"}"#,
2885 BumpLevel::Patch,
2886 ),
2887 ] {
2888 let decision = parse_decision(json).unwrap();
2889 assert_eq!(decision.level, level);
2890 assert!(!decision.reason.is_empty());
2891 }
2892 }
2893
2894 #[test]
2895 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
2896 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
2897 let decision = parse_decision(text).unwrap();
2898 assert_eq!(decision.level, BumpLevel::Minor);
2899 assert_eq!(decision.reason, "new HTTP route");
2900 }
2901
2902 #[test]
2903 fn a_broken_reply_is_an_error_not_a_default() {
2904 assert!(parse_decision("I decline to answer.").is_err());
2905 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
2906 assert!(
2907 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
2908 "an empty reason must not pass either"
2909 );
2910 assert!(
2911 parse_decision(r#"{"level":"patch"}"#).is_err(),
2912 "a reply with no reason at all must not pass"
2913 );
2914 }
2915
2916 #[test]
2917 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
2918 let prompt = decision_prompt(
2919 "feat: add a phone endpoint",
2920 "add POST /api/widgets",
2921 "1 file changed, 10 insertions(+)",
2922 &["src/web.rs".to_owned()],
2923 "0.8.0",
2924 );
2925 assert!(prompt.contains("0.8.0"), "the current version is stated");
2926 assert!(
2927 prompt.contains("below `1.0.0`")
2928 && prompt.contains("`minor` is the digit that carries a breaking change"),
2929 "the 0.x rule must be explicit: {prompt}"
2930 );
2931 assert!(
2932 prompt.contains("choose the larger"),
2933 "the tie-break toward the bigger digit must be explicit: {prompt}"
2934 );
2935 }
2936
2937 #[test]
2938 fn release_only_diffs_are_recognised() {
2939 assert!(is_release_only(&["Cargo.toml".to_owned()]));
2940 assert!(is_release_only(&[
2941 "Cargo.toml".to_owned(),
2942 "Cargo.lock".to_owned()
2943 ]));
2944 assert!(!is_release_only(&[]));
2945 assert!(!is_release_only(&[
2946 "Cargo.toml".to_owned(),
2947 "src/main.rs".to_owned()
2948 ]));
2949 }
2950
2951 #[test]
2952 fn cargo_version_rewrite_touches_only_the_package_table() {
2953 let toml = "\
2954[package]\n\
2955# a comment mentioning version on purpose\n\
2956name = \"magi-cli\"\n\
2957version = \"0.8.0\"\n\
2958edition = \"2024\"\n\
2959\n\
2960[dependencies]\n\
2961foo = { version = \"1.2.3\" }\n";
2962 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
2963 assert!(out.contains("version = \"0.9.0\""));
2964 assert!(
2965 out.contains("foo = { version = \"1.2.3\" }"),
2966 "a dependency's own version pin must survive: {out}"
2967 );
2968 assert!(
2969 out.contains("# a comment mentioning version on purpose"),
2970 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
2971 );
2972 assert_eq!(
2973 out.lines().count(),
2974 toml.lines().count(),
2975 "the rewrite replaces one line, it does not add or remove any"
2976 );
2977 }
2978
2979 #[test]
2980 fn cargo_version_rewrite_fails_without_a_package_table() {
2981 let toml = "[dependencies]\nfoo = \"1\"\n";
2982 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
2983 }
2984
2985 #[test]
2991 fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
2992 let toml = "\
2993[workspace]\n\
2994members = [\"crates/a\", \"crates/b\"]\n\
2995\n\
2996[workspace.package]\n\
2997version = \"0.45.18\"\n\
2998edition = \"2024\"\n\
2999\n\
3000[workspace.dependencies]\n\
3001foo = { version = \"1.2.3\" }\n";
3002 let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
3003 assert!(out.contains("version = \"0.45.19\""));
3004 assert!(
3005 out.contains("foo = { version = \"1.2.3\" }"),
3006 "a workspace dependency's own version pin must survive: {out}"
3007 );
3008 assert_eq!(
3009 out.lines().count(),
3010 toml.lines().count(),
3011 "the rewrite replaces one line, it does not add or remove any"
3012 );
3013 }
3014
3015 #[test]
3019 fn cargo_version_rewrite_bumps_an_internal_workspace_dependency_pin() {
3020 let toml = "\
3021[workspace]\n\
3022members = [\"crates/kanade-shared\"]\n\
3023\n\
3024[workspace.package]\n\
3025version = \"0.48.2\"\n\
3026\n\
3027[workspace.dependencies]\n\
3028kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n";
3029 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3030 assert!(out.contains("[workspace.package]\nversion = \"0.48.3\"\n"));
3031 assert!(
3032 out.contains(
3033 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3034 ),
3035 "the internal pin must move with the workspace version: {out}"
3036 );
3037 }
3038
3039 #[test]
3041 fn cargo_version_rewrite_bumps_an_internal_pin_with_version_before_path() {
3042 let toml = "\
3043[workspace.package]\n\
3044version = \"1.0.0\"\n\
3045\n\
3046[workspace.dependencies]\n\
3047inner = { version = \"1.0.0\", path = \"crates/inner\" }\n";
3048 let out = rewrite_cargo_version(toml, "1.0.1").unwrap();
3049 assert!(out.contains("inner = { version = \"1.0.1\", path = \"crates/inner\" }"));
3050 }
3051
3052 #[test]
3055 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_table_form() {
3056 let toml = "\
3057[workspace.package]\n\
3058version = \"2.3.0\"\n\
3059\n\
3060[workspace.dependencies.inner]\n\
3061path = \"crates/inner\"\n\
3062version = \"2.3.0\"\n";
3063 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3064 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3065 assert!(out.contains(
3066 "[workspace.dependencies.inner]\npath = \"crates/inner\"\nversion = \"2.4.0\"\n"
3067 ));
3068 }
3069
3070 #[test]
3075 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_key_form() {
3076 let toml = "\
3077[workspace.package]\n\
3078version = \"2.3.0\"\n\
3079\n\
3080[workspace.dependencies]\n\
3081inner.path = \"crates/inner\"\n\
3082inner.version = \"2.3.0\"\n";
3083 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3084 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3085 assert!(out.contains("inner.path = \"crates/inner\"\ninner.version = \"2.4.0\"\n"));
3086 }
3087
3088 #[test]
3091 fn cargo_version_rewrite_leaves_a_path_only_workspace_dependency_untouched() {
3092 let toml = "\
3093[workspace.package]\n\
3094version = \"0.1.0\"\n\
3095\n\
3096[workspace.dependencies]\n\
3097internal-only = { path = \"crates/internal-only\" }\n";
3098 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3099 assert!(out.contains("internal-only = { path = \"crates/internal-only\" }"));
3100 }
3101
3102 #[test]
3106 fn cargo_version_rewrite_leaves_an_external_dependency_untouched() {
3107 let toml = "\
3108[workspace.package]\n\
3109version = \"0.1.0\"\n\
3110\n\
3111[workspace.dependencies]\n\
3112serde = { version = \"1\", features = [\"derive\"] }\n\
3113inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3114 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3115 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3116 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3117 }
3118
3119 #[test]
3123 fn cargo_version_rewrite_without_workspace_dependencies_table_still_bumps_package() {
3124 let toml = "[workspace.package]\nversion = \"0.9.0\"\nedition = \"2024\"\n";
3125 let out = rewrite_cargo_version(toml, "0.10.0").unwrap();
3126 assert_eq!(
3127 out,
3128 "[workspace.package]\nversion = \"0.10.0\"\nedition = \"2024\"\n"
3129 );
3130 }
3131
3132 #[test]
3135 fn cargo_version_rewrite_handles_a_kanade_shaped_workspace_dependencies_table() {
3136 let toml = "\
3137[workspace.package]\n\
3138version = \"0.48.2\"\n\
3139\n\
3140[workspace.dependencies]\n\
3141anyhow = { version = \"1\" }\n\
3142serde = { version = \"1\", features = [\"derive\"] }\n\
3143kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n\
3144kanade-core = { path = \"crates/kanade-core\", version = \"0.48.2\" }\n\
3145kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }\n";
3146 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3147 assert!(out.contains("anyhow = { version = \"1\" }"));
3148 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3149 assert!(
3150 out.contains(
3151 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3152 )
3153 );
3154 assert!(
3155 out.contains("kanade-core = { path = \"crates/kanade-core\", version = \"0.48.3\" }")
3156 );
3157 assert!(out.contains("kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }"));
3158 }
3159
3160 #[test]
3164 fn cargo_version_rewrite_bails_on_an_unrepresentable_inline_table() {
3165 let toml = "\
3166[workspace.package]\n\
3167version = \"0.1.0\"\n\
3168\n\
3169[workspace.dependencies]\n\
3170inner = { path = \"crates/inner\",\n\
3171 version = \"0.1.0\" }\n";
3172 assert!(rewrite_cargo_version(toml, "0.2.0").is_err());
3173 }
3174
3175 #[test]
3178 fn cargo_version_rewrite_recognises_a_commented_workspace_dependencies_header() {
3179 let toml = "\
3180[workspace.package]\n\
3181version = \"0.1.0\"\n\
3182\n\
3183[workspace.dependencies] # internal pins\n\
3184inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3185 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3186 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3187 }
3188
3189 #[test]
3192 fn cargo_version_rewrite_bumps_a_quoted_dependency_key() {
3193 let toml = "\
3194[workspace.package]\n\
3195version = \"0.1.0\"\n\
3196\n\
3197[workspace.dependencies]\n\
3198\"inner\" = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3199 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3200 assert!(out.contains("\"inner\" = { path = \"crates/inner\", version = \"0.2.0\" }"));
3201 }
3202
3203 #[test]
3207 fn cargo_version_rewrite_bumps_a_literal_string_version_pin() {
3208 let toml = "\
3209[workspace.package]\n\
3210version = \"0.1.0\"\n\
3211\n\
3212[workspace.dependencies]\n\
3213inner = { path = 'crates/inner', version = '0.1.0' }\n";
3214 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3215 assert!(out.contains("inner = { path = 'crates/inner', version = '0.2.0' }"));
3216 }
3217
3218 #[test]
3224 fn cargo_version_rewrite_does_not_require_the_whole_file_to_parse() {
3225 let toml = "\
3226[workspace.package]\n\
3227version = \"0.1.0\"\n\
3228\n\
3229[workspace.dependencies]\n\
3230inner = { path = \"crates/inner\", version = \"0.1.0\" }\n\
3231\n\
3232[workspace.package]\n\
3233edition = \"2024\"\n";
3234 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3235 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3236 assert!(
3237 toml::from_str::<toml::Value>(toml).is_err(),
3238 "the fixture itself must be invalid as a whole file, or this test proves nothing"
3239 );
3240 }
3241
3242 #[test]
3243 fn current_version_prefers_the_package_table_when_both_exist() {
3244 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
3245 assert_eq!(current_version(toml).unwrap(), "0.8.0");
3246 }
3247
3248 #[test]
3251 fn current_version_falls_back_to_workspace_package_without_a_package_table() {
3252 let toml = "\
3253[workspace]\n\
3254members = [\"crates/a\", \"crates/b\"]\n\
3255\n\
3256[workspace.package]\n\
3257version = \"0.45.18\"\n";
3258 assert_eq!(current_version(toml).unwrap(), "0.45.18");
3259 }
3260
3261 #[test]
3262 fn coalesce_proceeds_with_nothing_pending() {
3263 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
3264 }
3265
3266 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
3269 PendingBump {
3270 target_version: target_version.to_owned(),
3271 level,
3272 branch: format!("chore/release-v{target_version}"),
3273 pr_url: "https://example.invalid/pull/9".to_owned(),
3274 }
3275 }
3276
3277 #[test]
3278 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
3279 let pending = test_pending("0.9.0", BumpLevel::Minor);
3280 assert_eq!(
3281 coalesce(Some(&pending), "0.8.0").unwrap(),
3282 Coalesce::Skip {
3283 target_version: "0.9.0".to_owned()
3284 }
3285 );
3286 }
3287
3288 #[test]
3289 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
3290 let pending = test_pending("0.9.0", BumpLevel::Minor);
3291 assert_eq!(
3293 coalesce(Some(&pending), "0.9.0").unwrap(),
3294 Coalesce::Proceed
3295 );
3296 assert_eq!(
3298 coalesce(Some(&pending), "1.0.0").unwrap(),
3299 Coalesce::Proceed
3300 );
3301 }
3302
3303 #[test]
3304 fn pending_action_escalates_only_for_a_more_severe_decision() {
3305 assert_eq!(
3306 pending_action(BumpLevel::Patch, BumpLevel::Patch),
3307 PendingAction::AlreadyCovered
3308 );
3309 assert_eq!(
3310 pending_action(BumpLevel::Patch, BumpLevel::Minor),
3311 PendingAction::Escalate
3312 );
3313 assert_eq!(
3314 pending_action(BumpLevel::Patch, BumpLevel::Major),
3315 PendingAction::Escalate
3316 );
3317 assert_eq!(
3318 pending_action(BumpLevel::Minor, BumpLevel::Patch),
3319 PendingAction::AlreadyCovered
3320 );
3321 assert_eq!(
3322 pending_action(BumpLevel::Major, BumpLevel::Minor),
3323 PendingAction::AlreadyCovered
3324 );
3325 assert_eq!(
3326 pending_action(BumpLevel::Major, BumpLevel::Major),
3327 PendingAction::AlreadyCovered
3328 );
3329 }
3330
3331 #[test]
3332 fn pr_state_parsing_reads_open_and_not_open() {
3333 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
3334 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
3335 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
3336 }
3337
3338 #[test]
3339 fn a_lock_is_exclusive_until_dropped() {
3340 let dir = tempfile::tempdir().unwrap();
3341 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3342 let first = MarkerLock::acquire(&marker)
3343 .unwrap()
3344 .expect("first attempt takes the lock");
3345 assert!(
3346 MarkerLock::acquire(&marker).unwrap().is_none(),
3347 "a second attempt must be refused while the first holds it"
3348 );
3349 drop(first);
3350 assert!(
3351 MarkerLock::acquire(&marker).unwrap().is_some(),
3352 "dropping the guard releases the lock for the next attempt"
3353 );
3354 }
3355
3356 #[test]
3357 fn a_stale_lock_is_reclaimed() {
3358 let dir = tempfile::tempdir().unwrap();
3359 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3360 let lock_path = marker.with_extension("lock");
3361 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
3362 std::fs::write(&lock_path, b"").unwrap();
3363 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
3364 std::fs::OpenOptions::new()
3365 .write(true)
3366 .open(&lock_path)
3367 .unwrap()
3368 .set_modified(old)
3369 .unwrap();
3370 assert!(
3371 MarkerLock::acquire(&marker).unwrap().is_some(),
3372 "a lock older than the stale window must be reclaimed rather than block forever"
3373 );
3374 }
3375
3376 #[tokio::test]
3377 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
3378 let dir = tempfile::tempdir().unwrap();
3379 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3380 let held = MarkerLock::acquire(&marker)
3381 .unwrap()
3382 .expect("seed the contention");
3383 let releaser = tokio::spawn(async move {
3384 tokio::time::sleep(Duration::from_millis(20)).await;
3385 drop(held);
3386 });
3387 let waited =
3388 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
3389 .await
3390 .unwrap();
3391 assert!(
3392 waited.is_some(),
3393 "a merge landing behind another's still-running decision must not be dropped - it \
3394 must wait for that decision to finish and then judge against what it left behind"
3395 );
3396 releaser.await.unwrap();
3397 }
3398
3399 #[tokio::test]
3400 async fn a_lock_held_past_the_ceiling_gives_up() {
3401 let dir = tempfile::tempdir().unwrap();
3402 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3403 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
3404 let waited =
3405 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
3406 .await
3407 .unwrap();
3408 assert!(
3409 waited.is_none(),
3410 "a lock genuinely held past the ceiling must eventually give up rather than wait \
3411 forever"
3412 );
3413 }
3414
3415 #[test]
3416 fn level_between_reads_off_the_differing_digit() {
3417 assert_eq!(
3418 level_between(
3419 Version::parse("0.8.0").unwrap(),
3420 Version::parse("1.0.0").unwrap()
3421 ),
3422 Some(BumpLevel::Major)
3423 );
3424 assert_eq!(
3425 level_between(
3426 Version::parse("0.8.0").unwrap(),
3427 Version::parse("0.9.0").unwrap()
3428 ),
3429 Some(BumpLevel::Minor)
3430 );
3431 assert_eq!(
3432 level_between(
3433 Version::parse("0.8.0").unwrap(),
3434 Version::parse("0.8.1").unwrap()
3435 ),
3436 Some(BumpLevel::Patch)
3437 );
3438 assert_eq!(
3439 level_between(
3440 Version::parse("0.8.0").unwrap(),
3441 Version::parse("0.8.0").unwrap()
3442 ),
3443 None
3444 );
3445 }
3446
3447 #[test]
3448 fn open_release_pr_is_found_among_unrelated_pull_requests() {
3449 let json = r#"[
3450 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
3451 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
3452 ]"#;
3453 let found = parse_open_release_pr(json).unwrap();
3454 assert_eq!(
3455 found,
3456 Some((
3457 "chore/release-v0.9.0".to_owned(),
3458 "https://example.invalid/pull/2".to_owned()
3459 ))
3460 );
3461 }
3462
3463 #[test]
3464 fn no_open_release_pr_reads_as_none_not_an_error() {
3465 let json =
3466 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
3467 assert_eq!(parse_open_release_pr(json).unwrap(), None);
3468 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
3469 }
3470
3471 #[test]
3472 fn marker_round_trips_through_disk() {
3473 let dir = tempfile::tempdir().unwrap();
3474 let path = marker_path(dir.path(), Path::new("/repos/magi"));
3475 assert!(read_marker(&path).is_none());
3476
3477 let marker = test_pending("0.9.0", BumpLevel::Patch);
3478 write_marker(&path, &marker).unwrap();
3479 let read_back = read_marker(&path).unwrap();
3480 assert_eq!(read_back.target_version, "0.9.0");
3481 assert_eq!(read_back.level, BumpLevel::Patch);
3482 assert_eq!(read_back.pr_url, marker.pr_url);
3483
3484 clear_marker(&path);
3485 assert!(read_marker(&path).is_none());
3486 }
3487
3488 #[test]
3489 fn different_repos_get_different_marker_files() {
3490 let dir = tempfile::tempdir().unwrap();
3491 let a = marker_path(dir.path(), Path::new("/repos/a"));
3492 let b = marker_path(dir.path(), Path::new("/repos/b"));
3493 assert_ne!(a, b);
3494 }
3495
3496 #[test]
3500 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
3501 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
3502 assert!(
3503 is_release_only(&files),
3504 "the bump pull request's own diff must read as release-only"
3505 );
3506 }
3507
3508 #[test]
3509 fn should_release_bump_reads_only_a_merged_status() {
3510 assert!(should_release_bump(RunStatus::Merged));
3511 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
3512 assert!(!should_release_bump(other));
3513 }
3514 }
3515
3516 #[test]
3520 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
3521 let pr = land::PrState {
3522 url: "https://github.com/o/r/pull/1".to_owned(),
3523 number: 1,
3524 state: PrLifecycle::Merged,
3525 checks: land::Checks::Green,
3526 failing: Vec::new(),
3527 review_comments: Vec::new(),
3528 blocking: land::Blocking::No,
3529 };
3530 assert_eq!(
3531 land::decide(&pr, 0, 4, Duration::ZERO),
3532 land::Step::Done { merged: true }
3533 );
3534 assert!(should_release_bump(RunStatus::Merged));
3535 }
3536
3537 #[test]
3542 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
3543 let pr = land::PrState {
3544 url: "https://github.com/o/r/pull/2".to_owned(),
3545 number: 2,
3546 state: PrLifecycle::Open,
3547 checks: land::Checks::Green,
3548 failing: Vec::new(),
3549 review_comments: Vec::new(),
3550 blocking: land::Blocking::No,
3551 };
3552 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
3553 assert!(should_release_bump(RunStatus::Merged));
3556 }
3557
3558 #[test]
3561 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
3562 let argv = land::merge_argv(3, "feat: something");
3563 let outcome = land::merged_after_all(
3564 &argv,
3565 "could not determine current branch: not on any branch",
3566 Some(PrLifecycle::Merged),
3567 );
3568 assert!(outcome.is_some(), "the forge's confirmation must win");
3569 assert!(should_release_bump(RunStatus::Merged));
3570
3571 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
3574 assert!(land::merged_after_all(&argv, "network error", None).is_none());
3575 }
3576
3577 #[test]
3579 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
3580 let pr = land::PrState {
3581 url: "https://github.com/o/r/pull/4".to_owned(),
3582 number: 4,
3583 state: PrLifecycle::Closed,
3584 checks: land::Checks::Green,
3585 failing: Vec::new(),
3586 review_comments: Vec::new(),
3587 blocking: land::Blocking::No,
3588 };
3589 assert_eq!(
3590 land::decide(&pr, 0, 4, Duration::ZERO),
3591 land::Step::Done { merged: false }
3592 );
3593 assert!(!should_release_bump(RunStatus::Blocked));
3594 }
3595}