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
582pub(crate) fn 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
592async fn branch_version(repo: &Path, remote: &str, branch: &str) -> Option<String> {
596 let from_name = branch.strip_prefix("chore/release-v").map(str::to_owned);
597 let tracking = format!("refs/remotes/{remote}/{branch}");
600 let spec = format!("+refs/heads/{branch}:{tracking}");
601 let fetched = git::git_raw(repo, &["fetch", "--quiet", remote, &spec]).await;
602 if fetched.is_ok_and(|o| o.code == Some(0))
603 && let Ok(out) = git::git_raw(repo, &["show", &format!("{tracking}:Cargo.toml")]).await
604 && out.code == Some(0)
605 && let Ok(v) = current_version(&out.stdout)
606 {
607 return Some(v);
608 }
609 from_name
610}
611
612pub fn decision_prompt(
622 subject: &str,
623 instruction: &str,
624 diffstat: &str,
625 files: &[String],
626 current_version: &str,
627) -> String {
628 let mut s = format!(
629 "A pull request just merged into the base branch. Decide which digit \
630 of this project's `major.minor.patch` version this change earns, so \
631 a release bump can be opened for exactly it.\n\n\
632 Current version: {current_version}\n\n\
633 # Merge subject\n\n{subject}\n\n\
634 # The task that produced it\n\n{instruction}\n\n\
635 # Files changed ({} total)\n\n",
636 files.len()
637 );
638 const MAX_FILES: usize = 50;
639 for f in files.iter().take(MAX_FILES) {
640 let _ = writeln!(s, "- {f}");
641 }
642 if files.len() > MAX_FILES {
643 let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
644 }
645 let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
646
647 s.push_str(
648 "\n# How to decide\n\n\
649 This project is below version `1.0.0`. At that stage **`minor` is \
650 the digit that carries a breaking change** - do not spend `major` \
651 below `1.0.0`.\n\n\
652 A change is breaking, and earns `minor`, when it changes any of: \
653 the public API reachable from `src/lib.rs`, a CLI subcommand or \
654 flag, an HTTP API route or response shape, a configuration key, or \
655 the on-disk shape of persisted state.\n\n\
656 A user-visible new capability that breaks none of the above also \
657 earns `minor`.\n\n\
658 A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
659 **When it is not obvious which digit applies, choose the larger \
660 one.** An oversized bump costs nothing; a breaking change shipped as \
661 `patch` breaks every downstream update that pins a range.\n\n\
662 # Output\n\n\
663 Reply with exactly one fenced JSON object and nothing that matters \
664 outside it:\n\n\
665 ```json\n\
666 {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
667 ```\n",
668 );
669 let _ = write!(
671 s,
672 "\n{}\n\nThe `reason` goes into a GitHub pull request body, so write it \
673 in English.\n",
674 crate::prompt::GITHUB_ENGLISH_HEADING
675 );
676 s
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize)]
695pub struct PendingBump {
696 pub target_version: String,
698 pub level: BumpLevel,
701 pub branch: String,
704 pub pr_url: String,
707}
708
709pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
713 let key = repo.to_string_lossy();
714 home.join("bump")
715 .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
716}
717
718pub fn read_marker(path: &Path) -> Option<PendingBump> {
722 let body = std::fs::read_to_string(path).ok()?;
723 serde_json::from_str(&body).ok()
724}
725
726pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
730 if let Some(parent) = path.parent() {
731 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
732 }
733 let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
734 let tmp = path.with_extension("json.tmp");
735 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
736 std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
737 Ok(())
738}
739
740pub fn clear_marker(path: &Path) {
743 let _ = std::fs::remove_file(path);
744}
745
746#[derive(Debug, Clone, PartialEq, Eq)]
750pub enum Coalesce {
751 Proceed,
754 Skip {
756 target_version: String,
758 },
759}
760
761pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
763 let Some(pending) = pending else {
764 return Ok(Coalesce::Proceed);
765 };
766 let current = Version::parse(current_version)?;
767 let target = Version::parse(&pending.target_version)?;
768 if current >= target {
769 return Ok(Coalesce::Proceed);
770 }
771 Ok(Coalesce::Skip {
772 target_version: pending.target_version.clone(),
773 })
774}
775
776#[derive(Debug, Clone, Copy, PartialEq, Eq)]
778pub enum PendingAction {
779 AlreadyCovered,
782 Escalate,
785}
786
787pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
796 if decision_level.severity() > pending_level.severity() {
797 PendingAction::Escalate
798 } else {
799 PendingAction::AlreadyCovered
800 }
801}
802
803fn parse_pr_state(json: &str) -> Result<bool> {
805 #[derive(Deserialize)]
806 struct State {
807 state: String,
808 }
809 let parsed: State =
810 serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
811 Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
812}
813
814async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
827 let out = tokio::process::Command::new("gh")
828 .args(["pr", "view", pr_url, "--json", "state"])
829 .current_dir(repo)
830 .quiet()
831 .stdin(std::process::Stdio::null())
832 .output()
833 .await
834 .context("spawn gh pr view")?;
835 if !out.status.success() {
836 bail!(
837 "gh pr view {pr_url}: {}",
838 String::from_utf8_lossy(&out.stderr).trim()
839 );
840 }
841 parse_pr_state(&String::from_utf8_lossy(&out.stdout))
842}
843
844const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
852
853struct MarkerLock {
865 path: PathBuf,
866}
867
868impl MarkerLock {
869 fn acquire(marker: &Path) -> Result<Option<Self>> {
873 let path = marker.with_extension("lock");
874 if let Some(parent) = path.parent() {
875 std::fs::create_dir_all(parent)
876 .with_context(|| format!("create {}", parent.display()))?;
877 }
878 if Self::try_create(&path)? {
879 return Ok(Some(Self { path }));
880 }
881 if Self::is_stale(&path) {
882 let _ = std::fs::remove_file(&path);
883 if Self::try_create(&path)? {
884 return Ok(Some(Self { path }));
885 }
886 }
887 Ok(None)
888 }
889
890 fn try_create(path: &Path) -> Result<bool> {
891 match std::fs::OpenOptions::new()
892 .write(true)
893 .create_new(true)
894 .open(path)
895 {
896 Ok(_) => Ok(true),
897 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
898 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
899 }
900 }
901
902 fn is_stale(path: &Path) -> bool {
903 std::fs::metadata(path)
904 .and_then(|m| m.modified())
905 .ok()
906 .and_then(|m| m.elapsed().ok())
907 .is_some_and(|age| age >= LOCK_STALE_AFTER)
908 }
909}
910
911impl Drop for MarkerLock {
912 fn drop(&mut self) {
913 let _ = std::fs::remove_file(&self.path);
914 }
915}
916
917const LOCK_POLL: Duration = Duration::from_secs(5);
919
920const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
933
934async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
937 wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
938}
939
940async fn wait_for_marker_lock_with(
944 marker: &Path,
945 poll: Duration,
946 ceiling: Duration,
947) -> Result<Option<MarkerLock>> {
948 let mut waited = Duration::ZERO;
949 loop {
950 if let Some(lock) = MarkerLock::acquire(marker)? {
951 return Ok(Some(lock));
952 }
953 if waited >= ceiling {
954 return Ok(None);
955 }
956 tokio::time::sleep(poll).await;
957 waited += poll;
958 }
959}
960
961fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
969 if to.major != from.major {
970 Some(BumpLevel::Major)
971 } else if to.minor != from.minor {
972 Some(BumpLevel::Minor)
973 } else if to.patch != from.patch {
974 Some(BumpLevel::Patch)
975 } else {
976 None
977 }
978}
979
980fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
983 #[derive(Deserialize)]
984 struct Pr {
985 url: String,
986 #[serde(rename = "headRefName")]
987 head_ref_name: String,
988 }
989 let list: Vec<Pr> =
990 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
991 Ok(list
992 .into_iter()
993 .find(|p| p.head_ref_name.starts_with("chore/release-v"))
994 .map(|p| (p.head_ref_name, p.url)))
995}
996
997pub(crate) fn parse_open_release_prs(json: &str) -> Result<Vec<(String, String)>> {
1000 #[derive(Deserialize)]
1001 struct Pr {
1002 url: String,
1003 #[serde(rename = "headRefName")]
1004 head_ref_name: String,
1005 }
1006 let list: Vec<Pr> =
1007 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
1008 Ok(list
1009 .into_iter()
1010 .filter(|p| p.head_ref_name.starts_with("chore/release-v"))
1011 .map(|p| (p.head_ref_name, p.url))
1012 .collect())
1013}
1014
1015pub(crate) async fn list_open_release_prs(repo: &Path) -> Result<Vec<(String, String)>> {
1017 let out = tokio::process::Command::new("gh")
1018 .args([
1019 "pr",
1020 "list",
1021 "--state",
1022 "open",
1023 "--limit",
1024 "100",
1025 "--json",
1026 "url,headRefName",
1027 ])
1028 .current_dir(repo)
1029 .env_remove("GH_REPO")
1030 .quiet()
1031 .stdin(std::process::Stdio::null())
1032 .output()
1033 .await
1034 .context("spawn gh pr list")?;
1035 if !out.status.success() {
1036 bail!(
1037 "gh pr list: {}",
1038 String::from_utf8_lossy(&out.stderr).trim()
1039 );
1040 }
1041 parse_open_release_prs(&String::from_utf8_lossy(&out.stdout))
1042}
1043
1044async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
1059 let out = tokio::process::Command::new("gh")
1060 .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
1061 .current_dir(repo)
1062 .quiet()
1063 .stdin(std::process::Stdio::null())
1064 .output()
1065 .await
1066 .context("spawn gh pr list")?;
1067 if !out.status.success() {
1068 bail!(
1069 "gh pr list: {}",
1070 String::from_utf8_lossy(&out.stderr).trim()
1071 );
1072 }
1073 parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
1074}
1075
1076pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
1096 after_merge_at(state, pr_url, None, None).await
1097}
1098
1099#[derive(Debug, Default)]
1101struct Progress {
1102 version: Option<String>,
1104 pending_pr: Option<String>,
1106}
1107
1108fn notice_for_failure(progress: &Progress, err: &anyhow::Error) -> (Option<String>, String) {
1114 let chain = format!("{err:#}");
1115 let first = chain.lines().next().unwrap_or_default().trim();
1116 (progress.version.clone(), first.to_owned())
1117}
1118
1119async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
1125 let out = git::git(
1126 repo,
1127 &[
1128 "ls-tree",
1129 "--name-only",
1130 &format!("{remote}/{base}"),
1131 "--",
1132 "Cargo.toml",
1133 ],
1134 )
1135 .await
1136 .context("look for Cargo.toml on the base branch")?;
1137 Ok(!out.trim().is_empty())
1138}
1139
1140async fn after_merge_at(
1144 state: &mut RunState,
1145 pr_url: &str,
1146 home: Option<&Path>,
1147 store: Option<&Notices>,
1148) -> Result<()> {
1149 let mut progress = Progress::default();
1150 let result = after_merge_inner(state, pr_url, home, &mut progress).await;
1151 if let Err(e) = &result {
1152 let (version, reason) = notice_for_failure(&progress, e);
1153 let pending = progress.pending_pr.as_deref();
1154 let store = store.cloned().unwrap_or_else(Notices::open);
1155 report_problem_in(
1156 state,
1157 &store,
1158 pending,
1159 version.as_deref(),
1160 &reason,
1161 pending.is_some(),
1162 )
1163 .await;
1164 }
1165 result
1166}
1167
1168async fn after_merge_inner(
1169 state: &mut RunState,
1170 pr_url: &str,
1171 home: Option<&Path>,
1172 progress: &mut Progress,
1173) -> Result<()> {
1174 if !state.config.merge.release_bump {
1175 return Ok(());
1176 }
1177 let Some(winner) = state.winner().cloned() else {
1178 return Ok(());
1179 };
1180 let repo = state.repo.clone();
1181 let base = state.base_branch.clone();
1182 let remote = state.config.merge.remote.clone();
1183
1184 let files = git::changed_files(&winner.worktree, &base, &winner.branch)
1185 .await
1186 .unwrap_or_default();
1187 if is_release_only(&files) {
1188 state.event(
1189 "bump",
1190 "the merged change touches only the release manifest; not treating it as a trigger",
1191 );
1192 return Ok(());
1193 }
1194
1195 git::fetch(&repo, &remote, &base).await.ok();
1198 if !base_has_cargo_toml(&repo, &remote, &base).await? {
1199 state.event(
1200 "bump",
1201 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping",
1202 );
1203 return Ok(());
1204 }
1205
1206 let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
1207 let Some(_lock) = wait_for_marker_lock(&marker).await? else {
1214 state.event(
1215 "bump",
1216 "another release bump decision held the lock past the wait ceiling; skipping this round",
1217 );
1218 return Ok(());
1219 };
1220
1221 git::fetch(&repo, &remote, &base).await.ok();
1222 let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
1223 .await
1224 .context("read Cargo.toml from the base branch")?;
1225 let base_version = current_version(&cargo_toml)?;
1226
1227 let mut pending = read_marker(&marker);
1228 if let Some(p) = &pending {
1229 match coalesce(Some(p), &base_version)? {
1230 Coalesce::Proceed => {
1231 clear_marker(&marker);
1234 pending = None;
1235 }
1236 Coalesce::Skip { target_version } => {
1237 if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
1238 state.event(
1239 "bump",
1240 format!(
1241 "the pending release bump to v{target_version} ({}) is no longer \
1242 open; treating it as abandoned",
1243 p.pr_url
1244 ),
1245 );
1246 clear_marker(&marker);
1247 pending = None;
1248 }
1249 }
1254 }
1255 }
1256
1257 if pending.is_none() {
1258 if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
1263 && let Some(target) = branch_version(&repo, &remote, &branch)
1264 .await
1265 .and_then(|v| Version::parse(&v).ok())
1266 {
1267 let base_parsed = Version::parse(&base_version)?;
1268 if target > base_parsed
1269 && let Some(level) = level_between(base_parsed, target)
1270 {
1271 let adopted = PendingBump {
1272 target_version: target.to_string(),
1273 level,
1274 branch,
1275 pr_url: url,
1276 };
1277 let _ = write_marker(&marker, &adopted);
1280 pending = Some(adopted);
1281 }
1282 }
1283 }
1284
1285 progress.pending_pr = pending.as_ref().map(|p| p.pr_url.clone());
1286 let title = pr_title(&repo, pr_url).await.unwrap_or_default();
1287 let subject = land::merge_subject(
1288 crate::graph::landing_title(state, &title),
1289 &crate::graph::landing_subject_source(state),
1290 );
1291 let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
1292 .await
1293 .unwrap_or_default();
1294 let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
1295
1296 let spec: AgentSpec = agent::pick(
1302 &state.config.agents,
1303 crate::config::primary(state.config.roles.chatter.as_ref()),
1304 &agent::installed,
1305 )
1306 .context("choose an agent for the release-bump decision")?;
1307 let mut seat = SeatState::new("bump", &spec.id, state.seed);
1308 let artifacts = agent::artifacts_dir(&state.dir());
1309 let out = agent::invoke(
1310 &spec,
1311 &mut seat,
1312 &Invocation {
1313 cwd: &repo,
1314 prompt: &prompt,
1315 timeout: DECISION_TIMEOUT,
1316 allow_write: false,
1319 unsandboxed: false,
1320 sessions: false,
1321 artifacts: &artifacts,
1322 stem: "bump-decision",
1323 run: &state.id,
1324 node: "bump",
1325 cache_dir: state.config.cache_dir().as_deref(),
1326 attachments: &[],
1327 writable: &[],
1328 },
1329 )
1330 .await
1331 .context("ask an agent how big the merged change was")?;
1332 if !out.usable() {
1333 bail!(
1334 "the release-bump decision produced nothing usable (exit {:?}, timed out: {})",
1335 out.exit_code,
1336 out.timed_out
1337 );
1338 }
1339 let mut decision = parse_decision(&out.text).context("parse the release-bump decision")?;
1340 let mut violations =
1341 crate::github_text::check_for(&state.config.graph, "", &decision.reason, None);
1342 violations.retain(|v| *v != crate::github_text::Violation::SensitiveData);
1344 if violations.is_empty() {
1345 decision.reason = crate::scrub::scrub(&decision.reason, &crate::scrub::Identity::current());
1346 }
1347 if state.config.graph.github_text_guard && !violations.is_empty() {
1348 state.event(
1349 "github-text",
1350 format!("release reason rejected: {violations:?}; requesting one rewrite"),
1351 );
1352 let rewrite = format!(
1353 "{prompt}\n\nYour release reason failed the GitHub text gate: {violations:?}. Return the same decision JSON with an English reason containing no local identity or secrets. Previous reason: {}",
1354 decision.reason
1355 );
1356 let retry = agent::invoke(
1357 &spec,
1358 &mut seat,
1359 &Invocation {
1360 cwd: &repo,
1361 prompt: &rewrite,
1362 timeout: crate::graph::retry_budget(DECISION_TIMEOUT, true),
1363 allow_write: false,
1364 unsandboxed: false,
1365 sessions: false,
1366 artifacts: &artifacts,
1367 stem: "bump-rewrite",
1368 run: &state.id,
1369 node: "bump",
1370 cache_dir: state.config.cache_dir().as_deref(),
1371 attachments: &[],
1372 writable: &[],
1373 },
1374 )
1375 .await;
1376 let reason = retry
1377 .ok()
1378 .filter(|o| o.usable())
1379 .and_then(|o| parse_decision(&o.text).ok())
1380 .filter(|d| {
1381 d.level == decision.level
1382 && crate::github_text::check_for(&state.config.graph, "", &d.reason, None)
1383 .is_empty()
1384 })
1385 .map(|d| d.reason);
1386 if let Some(reason) = reason {
1387 decision.reason = reason;
1388 state.event("github-text", "release reason rewrite passed");
1389 } else {
1390 decision.reason = "The merged change requires a release.".to_owned();
1391 state.event(
1392 "github-text",
1393 "release reason rewrite unavailable or rejected; using neutral text",
1394 );
1395 }
1396 }
1397
1398 if let Some(p) = pending {
1399 return match pending_action(p.level, decision.level) {
1400 PendingAction::AlreadyCovered => {
1401 state.event(
1402 "bump",
1403 format!(
1404 "a release bump to v{} ({}) already covers at least a {} change; not \
1405 opening another",
1406 p.target_version,
1407 p.pr_url,
1408 decision.level.as_str()
1409 ),
1410 );
1411 Ok(())
1412 }
1413 PendingAction::Escalate => {
1414 progress.version = Some(
1415 Version::parse(&base_version)?
1416 .bump(decision.level)
1417 .to_string(),
1418 );
1419 escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
1420 }
1421 };
1422 }
1423
1424 let next = Version::parse(&base_version)?
1425 .bump(decision.level)
1426 .to_string();
1427 progress.version = Some(next.clone());
1428 let branch = format!("chore/release-v{next}");
1429 let worktree = state.dir().join("bump");
1430 let (shared, branch_ref, next_ref, decision_ref) = (&mut *state, &branch, &next, &decision);
1431 let (pr_url_opened, outcome) =
1432 release_attempt(
1433 &repo,
1434 &remote,
1435 &base,
1436 &worktree,
1437 &branch,
1438 |head| {
1439 let repo = repo.clone();
1440 async move { gh_open_pr_for_head(&repo, &head).await }
1441 },
1442 |wt| async move {
1443 open_bump_pr(shared, &wt, branch_ref, next_ref, decision_ref, pr_url).await
1444 },
1445 )
1446 .await?;
1447 let local = outcome == AutomergeOutcome::LocalGate;
1448 let (automerge_warning, merged_detail) = match outcome {
1449 AutomergeOutcome::Enabled | AutomergeOutcome::LocalGate => (None, None),
1450 AutomergeOutcome::MergedDirectly { detail } => (None, Some(detail)),
1451 AutomergeOutcome::Failed { reason } => (Some(reason), None),
1452 };
1453
1454 let marker_write = write_marker(
1463 &marker,
1464 &PendingBump {
1465 target_version: next.clone(),
1466 level: decision.level,
1467 branch,
1468 pr_url: pr_url_opened.clone(),
1469 },
1470 );
1471 state.event(
1472 "bump",
1473 format!(
1474 "opened a {} release bump to v{next} ({}): {pr_url_opened}",
1475 decision.level.as_str(),
1476 decision.reason
1477 ),
1478 );
1479 if let Err(e) = marker_write {
1480 state.event(
1481 "bump",
1482 format!(
1483 "could not record the pending release bump marker for v{next}: {e:#}; a later \
1484 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
1485 the forge either"
1486 ),
1487 );
1488 }
1489 state.release_bump = Some(run::ReleaseBump {
1490 pr_url: Some(pr_url_opened.clone()),
1491 version: Some(next.clone()),
1492 automerge_enabled: automerge_warning.is_none() && merged_detail.is_none() && !local,
1493 merged_directly: merged_detail.is_some(),
1494 local,
1495 ..run::ReleaseBump::default()
1496 });
1497 if let Some(detail) = merged_detail {
1498 clear_marker(&marker);
1501 state.event("bump", format!("merged v{next} directly: {detail}"));
1502 }
1503 if let Some(warning) = automerge_warning {
1504 state.event(
1505 "bump",
1506 format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
1507 );
1508 report_problem(state, Some(&pr_url_opened), Some(&next), &warning).await;
1509 }
1510 Ok(())
1511}
1512
1513pub const NOTICE_NODE: &str = "release-bump";
1518
1519fn automerge_hint(reason: &str) -> &'static str {
1526 let r = reason.to_lowercase();
1527 if is_clean_status_refusal(reason) {
1528 "merge the release pull request by hand; CI is already green"
1529 } else if r.contains("enablepullrequestautomerge") || r.contains("protected branch rules") {
1530 "merge the release pull request by hand, and enable branch protection with required \
1531 status checks on the base branch so automerge can work next time"
1532 } else {
1533 "merge the release pull request by hand"
1534 }
1535}
1536
1537fn automerge_failure_comment(reason: &str) -> String {
1539 format!(
1540 "magi could not enable automerge on this pull request: {reason}\n\n\
1541 Action required: {}. Until then the release does not happen.",
1542 automerge_hint(reason)
1543 )
1544}
1545
1546#[cfg(test)]
1554fn surface_problem(
1555 state: &mut RunState,
1556 store: &Notices,
1557 pr_url: Option<&str>,
1558 version: Option<&str>,
1559 reason: &str,
1560) -> Result<(Notice, Option<String>)> {
1561 surface_problem_in(state, store, pr_url, version, reason, false)
1562}
1563
1564fn surface_problem_in(
1568 state: &mut RunState,
1569 store: &Notices,
1570 pr_url: Option<&str>,
1571 version: Option<&str>,
1572 reason: &str,
1573 pending: bool,
1574) -> Result<(Notice, Option<String>)> {
1575 let action = if let (true, Some(url)) = (pending, pr_url) {
1576 let ja = crate::lang::is_japanese(&state.config.graph.language);
1577 let first = reason.lines().next().filter(|l| !l.is_empty());
1578 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1579 if ja {
1580 let target = version.map(|v| format!(" v{v}")).unwrap_or_default();
1581 format!(
1582 "既存のリリース PR {url} を{target}へ更新できませんでした{cause}。PR を手で更新してください"
1583 )
1584 } else {
1585 let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1586 format!(
1587 "the pending release pull request {url} could not be updated{target}{cause}; update it by hand"
1588 )
1589 }
1590 } else if pr_url.is_some() {
1591 automerge_hint(reason).to_owned()
1592 } else {
1593 let ja = crate::lang::is_japanese(&state.config.graph.language);
1594 let first = reason.lines().next().filter(|l| !l.is_empty());
1595 if ja {
1596 let target = version.map(|v| format!(" (v{v})")).unwrap_or_default();
1597 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1598 format!(
1599 "リリースバンプ{target}は実行されませんでした{cause}。リリース PR を手で開いてください"
1600 )
1601 } else {
1602 let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1603 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1604 format!(
1605 "the release bump{target} did not run{cause}; open the release pull request by hand"
1606 )
1607 }
1608 };
1609 let record = state.release_bump.get_or_insert_with(Default::default);
1610 record.pr_url = pr_url.map(str::to_owned).or(record.pr_url.take());
1611 record.version = version.map(str::to_owned).or(record.version.take());
1612 record.automerge_enabled = false;
1613 record.problem = Some(reason.to_owned());
1614 record.action_required = Some(action.clone());
1615
1616 let mut notice = Notice::error(
1617 &format!("release-bump:{}", state.id),
1618 format!(
1619 "Run {} merged, but its release step failed: {action}.",
1620 state.id
1621 ),
1622 );
1623 notice = match pr_url {
1624 Some(url) => notice.link(Link::Url {
1625 url: url.to_owned(),
1626 }),
1627 None => notice.link(Link::Run {
1628 id: state.id.clone(),
1629 }),
1630 };
1631 let notice = store
1632 .raise(notice)
1633 .context("raise the release-bump notification")?;
1634 state.event("bump", format!("needs attention: {action}"));
1635 let comment = pr_url
1636 .filter(|_| !pending)
1637 .map(|_| automerge_failure_comment(reason));
1638 Ok((notice, comment))
1639}
1640
1641pub async fn report_problem(
1646 state: &mut RunState,
1647 pr_url: Option<&str>,
1648 version: Option<&str>,
1649 reason: &str,
1650) {
1651 report_problem_in(state, &Notices::open(), pr_url, version, reason, false).await;
1652}
1653
1654async fn report_problem_in(
1656 state: &mut RunState,
1657 store: &Notices,
1658 pr_url: Option<&str>,
1659 version: Option<&str>,
1660 reason: &str,
1661 pending: bool,
1662) {
1663 match surface_problem_in(state, store, pr_url, version, reason, pending) {
1664 Ok((notice, comment)) => {
1665 if let (Some(url), Some(body)) = (pr_url, comment)
1666 && let Err(e) = gh_pr_comment(state, url, &body).await
1667 {
1668 state.event("bump", format!("could not comment on {url}: {e:#}"));
1669 }
1670 let summary = match pr_url {
1673 Some(url) if pending => format!("Release PR could not be updated: {url}"),
1674 Some(url) => format!("Release PR needs a human: {url}"),
1675 None if crate::lang::is_japanese(&state.config.graph.language) => {
1676 "リリースバンプが実行されませんでした".to_owned()
1677 }
1678 None => "Release bump did not run".to_owned(),
1679 };
1680 let q = ask::Question::new(
1681 state.id.clone(),
1682 NOTICE_NODE.to_owned(),
1683 "bump".to_owned(),
1684 summary,
1685 notice.message.clone(),
1686 Vec::new(),
1687 );
1688 if let Err(e) = ask::notify(&state.config.notify, &q).await {
1689 tracing::warn!(
1690 "could not notify about the release bump of {}: {e:#}",
1691 state.id
1692 );
1693 }
1694 }
1695 Err(e) => state.event("bump", format!("could not raise a notice: {e:#}")),
1696 }
1697}
1698
1699pub(crate) async fn gh_pr_comment(state: &mut RunState, pr_url: &str, body: &str) -> Result<()> {
1700 let (_, body) = crate::github_text::prepare(state, "", body);
1701 let cwd = &state.repo;
1702 let out = tokio::process::Command::new("gh")
1703 .args(["pr", "comment", pr_url, "--body", &body])
1704 .current_dir(cwd)
1705 .quiet()
1706 .stdin(std::process::Stdio::null())
1707 .output()
1708 .await
1709 .context("spawn gh pr comment")?;
1710 if out.status.success() {
1711 Ok(())
1712 } else {
1713 bail!(
1714 "gh pr comment: {}",
1715 String::from_utf8_lossy(&out.stderr).trim()
1716 )
1717 }
1718}
1719
1720async fn escalate_pending(
1729 state: &mut RunState,
1730 repo: &Path,
1731 remote: &str,
1732 pending: &PendingBump,
1733 decision: &BumpDecision,
1734 base_version: &str,
1735 marker: &Path,
1736) -> Result<()> {
1737 let next = Version::parse(base_version)?
1738 .bump(decision.level)
1739 .to_string();
1740 let worktree = state.dir().join("bump");
1741 git::worktree_remove(repo, &worktree).await.ok();
1742 let checked_out = git::git_raw(
1743 repo,
1744 &[
1745 "worktree",
1746 "add",
1747 "--force",
1748 &worktree.to_string_lossy(),
1749 &pending.branch,
1750 ],
1751 )
1752 .await?;
1753 if !checked_out.ok() {
1754 bail!(
1755 "checking out the pending release branch {} failed: {}",
1756 pending.branch,
1757 checked_out.stderr
1758 );
1759 }
1760
1761 let pushed: Result<()> = async {
1766 let cargo_toml_path = worktree.join("Cargo.toml");
1767 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1768 .await
1769 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1770 let rewritten = rewrite_cargo_version(&toml, &next)?;
1771 tokio::fs::write(&cargo_toml_path, rewritten)
1772 .await
1773 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1774 sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1775 let committed = git::commit_all(
1776 &worktree,
1777 &format!(
1778 "chore: release v{next} (supersedes v{})",
1779 pending.target_version
1780 ),
1781 )
1782 .await
1783 .context("commit the escalated version bump")?;
1784 if !committed {
1785 bail!("escalating the version bump left nothing to commit");
1786 }
1787 let pushed = git::push(&worktree, remote, &pending.branch).await?;
1788 if !pushed.ok() {
1789 bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1790 }
1791 Ok(())
1792 }
1793 .await;
1794 if let Err(e) = pushed {
1795 git::worktree_remove(repo, &worktree).await.ok();
1796 return Err(e);
1797 }
1798
1799 let (title, _) = crate::github_text::prepare(
1803 state,
1804 &format!("chore: release v{next} ({} bump)", decision.level.as_str()),
1805 "",
1806 );
1807 let title_warning = match gh_pr_edit_title(&worktree, &pending.pr_url, &title).await {
1808 Ok(()) => None,
1809 Err(e) => Some(e.to_string()),
1810 };
1811 git::worktree_remove(repo, &worktree).await.ok();
1812
1813 let marker_write = write_marker(
1814 marker,
1815 &PendingBump {
1816 target_version: next.clone(),
1817 level: decision.level,
1818 branch: pending.branch.clone(),
1819 pr_url: pending.pr_url.clone(),
1820 },
1821 );
1822 state.event(
1823 "bump",
1824 format!(
1825 "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1826 pending.target_version,
1827 decision.level.as_str(),
1828 decision.reason,
1829 pending.pr_url
1830 ),
1831 );
1832 if let Err(e) = marker_write {
1833 state.event(
1834 "bump",
1835 format!(
1836 "could not update the pending release bump marker to v{next}: {e:#}; a later \
1837 merge may misjudge whether it is already covered"
1838 ),
1839 );
1840 }
1841 if let Some(warning) = title_warning {
1842 state.event(
1843 "bump",
1844 format!(
1845 "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1846 subject may still read the superseded version",
1847 pending.pr_url
1848 ),
1849 );
1850 }
1851 Ok(())
1852}
1853
1854async fn gh_open_pr_for_head(repo: &Path, branch: &str) -> Result<bool> {
1857 let out = tokio::process::Command::new("gh")
1858 .args([
1859 "pr", "list", "--head", branch, "--state", "open", "--json", "url",
1860 ])
1861 .current_dir(repo)
1862 .quiet()
1863 .stdin(std::process::Stdio::null())
1864 .output()
1865 .await
1866 .context("spawn gh pr list")?;
1867 if !out.status.success() {
1868 bail!(
1869 "gh pr list --head {branch}: {}",
1870 String::from_utf8_lossy(&out.stderr).trim()
1871 );
1872 }
1873 let prs: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout)
1874 .with_context(|| format!("parse the pull requests headed by {branch}"))?;
1875 Ok(!prs.is_empty())
1876}
1877
1878async fn release_attempt<T, F, Fut, P, PFut>(
1887 repo: &Path,
1888 remote: &str,
1889 base: &str,
1890 worktree: &Path,
1891 branch: &str,
1892 open_pr: P,
1893 fill: F,
1894) -> Result<T>
1895where
1896 F: FnOnce(PathBuf) -> Fut,
1897 Fut: std::future::Future<Output = Result<T>>,
1898 P: Fn(String) -> PFut,
1899 PFut: std::future::Future<Output = Result<bool>>,
1900{
1901 let start = format!("{remote}/{base}");
1902 git::worktree_remove(repo, worktree).await.ok();
1903 let mut retried = false;
1904 if git::branch_exists(repo, branch).await? {
1905 reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await?;
1906 retried = true;
1907 }
1908 loop {
1909 if let Err(e) = git::git(repo, &["branch", branch, &start]).await {
1914 let e = e.context("create the release branch");
1915 if !git::branch_exists(repo, branch).await.unwrap_or(false) {
1916 return Err(e);
1917 }
1918 if retried {
1920 return Err(anyhow!(
1921 "{e:#}; left {branch} in place: it is not one this attempt created"
1922 ));
1923 }
1924 retried = true;
1925 if let Err(r) = reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await {
1926 return Err(anyhow!("{r:#} (after: {e:#})"));
1927 }
1928 continue;
1929 }
1930 if let Some(parent) = worktree.parent() {
1931 tokio::fs::create_dir_all(parent).await.ok();
1932 }
1933 let path = worktree.to_string_lossy();
1934 if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
1935 let e = e.context("create the release-bump worktree");
1936 return Err(discard_attempt(repo, remote, worktree, branch, e).await);
1937 }
1938 break;
1939 }
1940 match fill(worktree.to_path_buf()).await {
1941 Ok(v) => {
1942 git::worktree_remove(repo, worktree).await.ok();
1945 Ok(v)
1946 }
1947 Err(e) => Err(discard_attempt(repo, remote, worktree, branch, e).await),
1948 }
1949}
1950
1951async fn discard_attempt(
1953 repo: &Path,
1954 remote: &str,
1955 worktree: &Path,
1956 branch: &str,
1957 cause: anyhow::Error,
1958) -> anyhow::Error {
1959 git::worktree_remove(repo, worktree).await.ok();
1960 if !git::branch_exists(repo, branch).await.unwrap_or(false) {
1961 return cause;
1962 }
1963 let note = match git::remote_has_branch(repo, remote, branch).await {
1966 Ok(false) => match git::branch_delete(repo, branch).await {
1967 Ok(true) => return cause,
1968 _ => format!("could not delete the local branch {branch}"),
1969 },
1970 Ok(true) => format!("left {branch} in place: it was pushed to {remote}"),
1971 Err(e) => format!(
1972 "left {branch} in place: could not tell whether it was pushed to {remote} ({e:#})"
1973 ),
1974 };
1975 anyhow!("{cause:#}; {note}")
1976}
1977
1978async fn reclaim_stale_branch<P, PFut>(
1983 repo: &Path,
1984 remote: &str,
1985 start: &str,
1986 branch: &str,
1987 open_pr: &P,
1988) -> Result<()>
1989where
1990 P: Fn(String) -> PFut,
1991 PFut: std::future::Future<Output = Result<bool>>,
1992{
1993 match git::remote_has_branch(repo, remote, branch).await {
1994 Ok(false) => {}
1995 Ok(true) => bail!("the branch {branch} already exists and is on {remote}; left alone"),
1996 Err(e) => bail!(
1997 "the branch {branch} already exists and could not check whether {remote} has it ({e:#}); left alone"
1998 ),
1999 }
2000 match open_pr(branch.to_string()).await {
2001 Ok(false) => {}
2002 Ok(true) => {
2003 bail!("the branch {branch} already exists and has an open pull request; left alone")
2004 }
2005 Err(e) => bail!(
2006 "the branch {branch} already exists and could not check for an open pull request \
2007 ({e:#}); left alone"
2008 ),
2009 }
2010 match git::commits_ahead(repo, start, branch).await {
2011 Ok(0) => {}
2012 Ok(n) => bail!(
2013 "the branch {branch} already exists with {n} commit(s) not in {start}; left alone"
2014 ),
2015 Err(e) => bail!(
2016 "the branch {branch} already exists and could not compare it with {start} ({e:#}); \
2017 left alone"
2018 ),
2019 }
2020 if let Some(held) = git::worktree_holding(repo, branch).await? {
2021 let same = |a: &Path, b: &Path| match (a.canonicalize(), b.canonicalize()) {
2022 (Ok(a), Ok(b)) => a == b,
2023 _ => a == b,
2024 };
2025 if same(&held, repo) {
2026 bail!(
2027 "the branch {branch} already exists and is checked out in the main checkout; left alone"
2028 );
2029 }
2030 git::worktree_remove(repo, &held).await.ok();
2031 }
2032 if !git::branch_delete(repo, branch).await? {
2033 bail!("the branch {branch} already exists and could not be deleted; left alone");
2034 }
2035 Ok(())
2036}
2037
2038async fn open_bump_pr(
2044 state: &mut RunState,
2045 worktree: &Path,
2046 branch: &str,
2047 next_version: &str,
2048 decision: &BumpDecision,
2049 source_pr_url: &str,
2050) -> Result<(String, AutomergeOutcome)> {
2051 let cargo_toml_path = worktree.join("Cargo.toml");
2052 let toml = tokio::fs::read_to_string(&cargo_toml_path)
2053 .await
2054 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
2055 let rewritten = rewrite_cargo_version(&toml, next_version)?;
2056 tokio::fs::write(&cargo_toml_path, rewritten)
2057 .await
2058 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
2059
2060 sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
2061
2062 let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
2063 .await
2064 .context("commit the version bump")?;
2065 if !committed {
2066 bail!("the version bump left nothing to commit");
2067 }
2068
2069 let remote = state.config.merge.remote.clone();
2070 let pushed = git::push(worktree, &remote, branch).await?;
2071 if !pushed.ok() {
2072 bail!("pushing {branch} failed: {}", pushed.stderr);
2073 }
2074
2075 let (title, body) = release_pr(
2076 decision.level.as_str(),
2077 &decision.reason,
2078 next_version,
2079 &state.id,
2080 source_pr_url,
2081 );
2082 let (title, body) = crate::github_text::prepare(state, &title, &body);
2083 let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
2084 if state.config.release.is_local() {
2088 crate::release_watch::register(&crate::run::home(), &state.repo, &url, &state.id);
2091 return Ok((url, AutomergeOutcome::LocalGate));
2092 }
2093 let outcome = match gh_enable_automerge(worktree, &url).await {
2094 Ok(()) => AutomergeOutcome::Enabled,
2095 Err(e) => {
2096 let reason = e.to_string();
2097 if is_clean_status_refusal(&reason) {
2098 gh_merge_directly(worktree, &url, &title, reason).await
2099 } else {
2100 AutomergeOutcome::Failed { reason }
2101 }
2102 }
2103 };
2104 Ok((url, outcome))
2105}
2106
2107#[derive(Debug, Clone, PartialEq, Eq)]
2109enum AutomergeOutcome {
2110 Enabled,
2112 MergedDirectly { detail: String },
2114 LocalGate,
2116 Failed { reason: String },
2118}
2119
2120fn is_clean_status_refusal(reason: &str) -> bool {
2126 let r = reason.to_lowercase();
2127 r.contains("is in clean status") && r.contains("enablepullrequestautomerge")
2128}
2129
2130pub(crate) fn bump_merge_argv(pr_url: &str, subject: &str) -> Vec<String> {
2133 [
2134 "pr",
2135 "merge",
2136 pr_url,
2137 "--squash",
2138 "--delete-branch",
2139 "--subject",
2140 subject,
2141 ]
2142 .map(str::to_owned)
2143 .to_vec()
2144}
2145
2146fn resolve_direct_merge(
2150 refusal: &str,
2151 argv: &[String],
2152 merge_ok: bool,
2153 stderr: &str,
2154 after: Option<land::PrLifecycle>,
2155) -> AutomergeOutcome {
2156 if merge_ok {
2157 return AutomergeOutcome::MergedDirectly {
2158 detail: format!("automerge was refused ({refusal}); gh {}", argv.join(" ")),
2159 };
2160 }
2161 match land::merged_after_all(argv, stderr, after) {
2162 Some(m) => AutomergeOutcome::MergedDirectly { detail: m.detail },
2163 None => AutomergeOutcome::Failed {
2164 reason: format!("{refusal}; merging directly failed too: {}", stderr.trim()),
2165 },
2166 }
2167}
2168
2169async fn gh_merge_directly(
2173 cwd: &Path,
2174 pr_url: &str,
2175 subject: &str,
2176 refusal: String,
2177) -> AutomergeOutcome {
2178 let argv = bump_merge_argv(pr_url, subject);
2179 let out = match tokio::process::Command::new("gh")
2180 .args(&argv)
2181 .current_dir(cwd)
2182 .quiet()
2183 .stdin(std::process::Stdio::null())
2184 .output()
2185 .await
2186 {
2187 Ok(o) => o,
2188 Err(e) => {
2189 return AutomergeOutcome::Failed {
2190 reason: format!("{refusal}; could not spawn gh to merge directly: {e}"),
2191 };
2192 }
2193 };
2194 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
2195 let after = if out.status.success() {
2198 None
2199 } else {
2200 land::lifecycle(cwd, pr_url).await.ok()
2201 };
2202 resolve_direct_merge(&refusal, &argv, out.status.success(), &stderr, after)
2203}
2204
2205async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
2213 let mut cmd = tokio::process::Command::new("cargo");
2214 cmd.arg("build").current_dir(worktree).quiet();
2215 if let Some(dir) = cache_dir {
2216 cmd.env("CARGO_TARGET_DIR", dir);
2217 }
2218 let out = cmd
2219 .stdin(std::process::Stdio::null())
2220 .output()
2221 .await
2222 .context("spawn cargo build")?;
2223 if !out.status.success() {
2224 bail!(
2225 "cargo build failed while syncing Cargo.lock: {}",
2226 String::from_utf8_lossy(&out.stderr).trim()
2227 );
2228 }
2229 Ok(())
2230}
2231
2232fn release_pr(
2237 level: &str,
2238 reason: &str,
2239 next_version: &str,
2240 run_id: &str,
2241 source_pr_url: &str,
2242) -> (String, String) {
2243 let title = format!("chore: release v{next_version} ({level} bump)");
2244 let body = format!(
2245 "## Background\n\n\
2246 A change that was just merged is a `{level}` change, so the crate needs a new \
2247 release: {reason}\n\n\
2248 Triggered by magi run `{run_id}`, which landed {source}.\n\n\
2249 ## Change\n\n\
2250 Raises the package version to `v{next_version}` in `Cargo.toml`, with \
2251 `Cargo.lock` following it. Nothing else changes.\n\n\
2252 ## Risk\n\n\
2253 Version-bump-only, so there is nothing here for a reviewer to find. Merging \
2254 it starts the release pipeline (auto-tag, then the release workflow).",
2255 source = source_pr_url,
2256 );
2257 (title, body)
2258}
2259
2260async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
2262 let out = tokio::process::Command::new("gh")
2263 .args(["pr", "view", pr_url, "--json", "title"])
2264 .current_dir(repo)
2265 .quiet()
2266 .stdin(std::process::Stdio::null())
2267 .output()
2268 .await
2269 .context("spawn gh pr view")?;
2270 if !out.status.success() {
2271 bail!(
2272 "gh pr view {pr_url}: {}",
2273 String::from_utf8_lossy(&out.stderr).trim()
2274 );
2275 }
2276 #[derive(Deserialize)]
2277 struct Title {
2278 title: String,
2279 }
2280 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
2281 .context("parse `gh pr view --json title` output")?;
2282 Ok(parsed.title)
2283}
2284
2285async fn gh_pr_create(
2286 cwd: &Path,
2287 base: &str,
2288 head: &str,
2289 title: &str,
2290 body: &str,
2291) -> Result<String> {
2292 let out = tokio::process::Command::new("gh")
2293 .args([
2294 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
2295 ])
2296 .current_dir(cwd)
2297 .quiet()
2298 .stdin(std::process::Stdio::null())
2299 .output()
2300 .await
2301 .context("spawn gh pr create")?;
2302 if out.status.success() {
2303 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2304 } else {
2305 bail!(
2306 "gh pr create: {}",
2307 String::from_utf8_lossy(&out.stderr).trim()
2308 )
2309 }
2310}
2311
2312async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
2316 let out = tokio::process::Command::new("gh")
2317 .args([
2318 "pr",
2319 "merge",
2320 pr_url,
2321 "--auto",
2322 "--squash",
2323 "--delete-branch",
2324 ])
2325 .current_dir(cwd)
2326 .quiet()
2327 .stdin(std::process::Stdio::null())
2328 .output()
2329 .await
2330 .context("spawn gh pr merge --auto")?;
2331 if out.status.success() {
2332 Ok(())
2333 } else {
2334 bail!(
2335 "gh pr merge --auto: {}",
2336 String::from_utf8_lossy(&out.stderr).trim()
2337 )
2338 }
2339}
2340
2341async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
2345 let out = tokio::process::Command::new("gh")
2346 .args(["pr", "edit", pr_url, "--title", title])
2347 .current_dir(cwd)
2348 .quiet()
2349 .stdin(std::process::Stdio::null())
2350 .output()
2351 .await
2352 .context("spawn gh pr edit")?;
2353 if out.status.success() {
2354 Ok(())
2355 } else {
2356 bail!(
2357 "gh pr edit --title: {}",
2358 String::from_utf8_lossy(&out.stderr).trim()
2359 )
2360 }
2361}
2362
2363#[cfg(test)]
2364mod tests {
2365 use super::*;
2366 use crate::notices::Severity;
2367
2368 #[test]
2369 fn github_facing_bump_text_is_english() {
2370 let (title, body) =
2371 release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
2372 assert!(crate::github_text::check(&title, &body).is_empty());
2373 assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
2374 assert_eq!(title, "chore: release v0.37.0 (minor bump)");
2375 assert!(
2376 body.contains("## Background") && body.contains("## Change"),
2377 "{body}"
2378 );
2379 let p = decision_prompt("s", "i", "d", &[], "0.36.5");
2380 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
2381 }
2382 use crate::config::Config;
2383 use crate::land::PrLifecycle;
2384
2385 #[tokio::test]
2391 async fn a_disabled_config_does_nothing() {
2392 let config = Config {
2393 merge: crate::config::Merge {
2394 release_bump: false,
2395 ..crate::config::Merge::default()
2396 },
2397 ..Config::default()
2398 };
2399 let mut state = RunState::new(
2400 PathBuf::from("/no/such/repo"),
2401 "main".to_owned(),
2402 "0000000000000000000000000000000000000000".to_owned(),
2403 "irrelevant".to_owned(),
2404 config,
2405 );
2406 after_merge(&mut state, "https://example.invalid/pull/1")
2407 .await
2408 .expect("a disabled config must return Ok without touching anything");
2409 assert!(
2410 state.events.is_empty(),
2411 "nothing should happen at all, not even a logged event"
2412 );
2413 }
2414
2415 async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
2417 let dir = tempfile::tempdir().unwrap();
2418 let origin = dir.path().join("origin.git");
2419 let repo = dir.path().join("repo");
2420 let o = origin.to_string_lossy().into_owned();
2421 git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
2422 .await
2423 .unwrap();
2424 tokio::fs::create_dir_all(&repo).await.unwrap();
2425 git::git(&repo, &["init", "-b", "main"]).await.unwrap();
2426 git::git(&repo, &["config", "user.name", "test"])
2427 .await
2428 .unwrap();
2429 git::git(&repo, &["config", "user.email", "test@example.com"])
2430 .await
2431 .unwrap();
2432 for (name, body) in files {
2433 tokio::fs::write(repo.join(name), body).await.unwrap();
2434 }
2435 git::git(&repo, &["add", "-A"]).await.unwrap();
2436 git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
2437 git::git(&repo, &["remote", "add", "origin", &o])
2438 .await
2439 .unwrap();
2440 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
2441 (dir, repo)
2442 }
2443
2444 async fn no_pr(_: String) -> Result<bool> {
2445 Ok(false)
2446 }
2447
2448 async fn with_branch(ahead: bool) -> (tempfile::TempDir, PathBuf) {
2451 let (d, repo) = origin_with(&[("f", "x\n")]).await;
2452 git::git(&repo, &["branch", "b"]).await.unwrap();
2453 if ahead {
2454 git::git(&repo, &["checkout", "-q", "b"]).await.unwrap();
2455 git::git(&repo, &["commit", "--allow-empty", "-m", "wip"])
2456 .await
2457 .unwrap();
2458 git::git(&repo, &["checkout", "-q", "main"]).await.unwrap();
2459 }
2460 (d, repo)
2461 }
2462
2463 async fn attempt(repo: &Path, open_pr: bool, fail_after_push: Option<bool>) -> Result<()> {
2464 let wt = repo.parent().unwrap().join("bump");
2465 release_attempt(
2466 repo,
2467 "origin",
2468 "main",
2469 &wt,
2470 "b",
2471 |_| async move { Ok(open_pr) },
2472 |w| async move {
2473 if fail_after_push == Some(true) {
2474 git::push(&w, "origin", "b").await?;
2475 }
2476 if fail_after_push.is_some() {
2477 bail!("cargo build failed");
2478 }
2479 Ok(())
2480 },
2481 )
2482 .await
2483 }
2484
2485 #[tokio::test]
2486 async fn a_stale_ancestor_branch_is_deleted_and_the_attempt_proceeds() {
2487 let (_d, repo) = with_branch(false).await;
2488 attempt(&repo, false, None).await.unwrap();
2489 assert!(git::branch_exists(&repo, "b").await.unwrap());
2491 }
2492
2493 #[tokio::test]
2494 async fn a_stale_branch_holding_a_leftover_worktree_is_reclaimed() {
2495 let (_d, repo) = with_branch(false).await;
2496 let old = repo.parent().unwrap().join("old");
2497 git::git(&repo, &["worktree", "add", &old.to_string_lossy(), "b"])
2498 .await
2499 .unwrap();
2500 attempt(&repo, false, None).await.unwrap();
2501 assert!(!old.exists());
2502 }
2503
2504 #[tokio::test]
2505 async fn a_stale_branch_with_an_unmerged_commit_is_kept_with_a_reason() {
2506 let (_d, repo) = with_branch(true).await;
2507 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2508 assert!(e.contains("`b`") || e.contains("branch b"), "{e}");
2509 assert!(e.contains("1 commit(s) not in origin/main"), "{e}");
2510 assert!(git::branch_exists(&repo, "b").await.unwrap());
2511 }
2512
2513 #[tokio::test]
2514 async fn a_stale_branch_on_the_remote_is_kept() {
2515 let (_d, repo) = with_branch(false).await;
2516 git::git(&repo, &["push", "origin", "b"]).await.unwrap();
2517 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2518 assert!(e.contains("branch b") && e.contains("is on origin"), "{e}");
2519 assert!(git::branch_exists(&repo, "b").await.unwrap());
2520 }
2521
2522 #[tokio::test]
2523 async fn a_stale_branch_with_an_open_pull_request_is_kept() {
2524 let (_d, repo) = with_branch(false).await;
2525 let e = attempt(&repo, true, None).await.unwrap_err().to_string();
2526 assert!(e.contains("open pull request"), "{e}");
2527 assert!(git::branch_exists(&repo, "b").await.unwrap());
2528 }
2529
2530 #[tokio::test]
2531 async fn an_unanswerable_pull_request_check_keeps_the_branch() {
2532 let (_d, repo) = with_branch(false).await;
2533 let wt = repo.parent().unwrap().join("bump");
2534 let e = release_attempt(
2535 &repo,
2536 "origin",
2537 "main",
2538 &wt,
2539 "b",
2540 |_| async { bail!("offline") },
2541 |_| async { Ok(()) },
2542 )
2543 .await
2544 .unwrap_err()
2545 .to_string();
2546 assert!(
2547 e.contains("could not check for an open pull request"),
2548 "{e}"
2549 );
2550 assert!(git::branch_exists(&repo, "b").await.unwrap());
2551 }
2552
2553 #[tokio::test]
2554 async fn a_failed_attempt_removes_its_own_worktree_and_branch() {
2555 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2556 let wt = repo.parent().unwrap().join("bump");
2557 let e = attempt(&repo, false, Some(false)).await.unwrap_err();
2558 assert!(format!("{e:#}").contains("cargo build failed"));
2559 assert!(!wt.exists());
2560 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2561 }
2562
2563 #[tokio::test]
2564 async fn a_pushed_branch_survives_a_failed_attempt_and_the_reason_says_so() {
2565 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2566 let e = attempt(&repo, false, Some(true)).await.unwrap_err();
2567 let e = format!("{e:#}");
2568 assert!(
2569 e.contains("cargo build failed") && e.contains("pushed to origin"),
2570 "{e}"
2571 );
2572 assert!(git::branch_exists(&repo, "b").await.unwrap());
2573 }
2574
2575 #[tokio::test]
2576 async fn a_branch_left_by_a_half_done_worktree_add_is_removed() {
2577 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2580 let blocker = repo.parent().unwrap().join("blocker");
2581 tokio::fs::write(&blocker, "file").await.unwrap();
2582 let wt = blocker.join("bump");
2583 let r = release_attempt(
2584 &repo,
2585 "origin",
2586 "main",
2587 &wt,
2588 "b",
2589 |_| async { bail!("offline") },
2590 |_| async { Ok(()) },
2591 )
2592 .await;
2593 assert!(r.is_err());
2594 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2595 }
2596
2597 #[tokio::test]
2598 async fn the_retry_is_taken_at_most_once() {
2599 let (_d, repo) = with_branch(false).await;
2602 let blocker = repo.parent().unwrap().join("blocker");
2603 tokio::fs::write(&blocker, "file").await.unwrap();
2604 let wt = blocker.join("bump");
2605 let r = release_attempt(&repo, "origin", "main", &wt, "b", no_pr, |_| async {
2606 Ok(())
2607 })
2608 .await;
2609 assert!(r.is_err());
2610 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2611 }
2612
2613 #[tokio::test]
2614 async fn base_has_cargo_toml_tells_rust_from_non_rust() {
2615 let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
2616 assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
2617 let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
2618 assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
2619 assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
2621 }
2622
2623 #[tokio::test]
2624 async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
2625 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2626 let home = tempfile::tempdir().unwrap();
2627 let mut state = RunState::new(
2628 repo.clone(),
2629 "main".to_owned(),
2630 "0000000000000000000000000000000000000000".to_owned(),
2631 "task".to_owned(),
2632 Config::default(),
2633 );
2634 state.candidates.push(crate::run::Candidate {
2635 index: 0,
2636 label: 'A',
2637 agent: "x".to_owned(),
2638 branch: "main".to_owned(),
2639 worktree: repo.clone(),
2640 summary: String::new(),
2641 stat: String::new(),
2642 files: 1,
2643 commits: 1,
2644 empty: false,
2645 failed: None,
2646 verified_noop: None,
2647 duration_ms: 0,
2648 folded: false,
2649 });
2650 state.tally = Some(
2651 serde_json::from_value(serde_json::json!({
2652 "first_choice": {}, "borda": {}, "winner": "A",
2653 "unanimous_initial": true, "deliberated": false,
2654 "changed_votes": 0, "unanimous_final": true,
2655 }))
2656 .unwrap(),
2657 );
2658 after_merge_at(
2659 &mut state,
2660 "https://example.invalid/pull/1",
2661 Some(home.path()),
2662 None,
2663 )
2664 .await
2665 .expect("a non-Rust repository is not an error");
2666 let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
2667 assert_eq!(bumps.len(), 1, "{:?}", state.events);
2668 assert_eq!(
2669 bumps[0].message,
2670 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
2671 );
2672 assert!(
2673 std::fs::read_dir(home.path()).unwrap().next().is_none(),
2674 "no marker and no lock may be created"
2675 );
2676 }
2677
2678 fn winner_state(repo: &Path, base: &str) -> RunState {
2679 let mut state = RunState::new(
2680 repo.to_path_buf(),
2681 base.to_owned(),
2682 "0000000000000000000000000000000000000000".to_owned(),
2683 "task".to_owned(),
2684 Config::default(),
2685 );
2686 state.candidates.push(crate::run::Candidate {
2687 index: 0,
2688 label: 'A',
2689 agent: "x".to_owned(),
2690 branch: "main".to_owned(),
2691 worktree: repo.to_path_buf(),
2692 summary: String::new(),
2693 stat: String::new(),
2694 files: 1,
2695 commits: 1,
2696 empty: false,
2697 failed: None,
2698 verified_noop: None,
2699 duration_ms: 0,
2700 folded: false,
2701 });
2702 state.tally = Some(
2703 serde_json::from_value(serde_json::json!({
2704 "first_choice": {}, "borda": {}, "winner": "A",
2705 "unanimous_initial": true, "deliberated": false,
2706 "changed_votes": 0, "unanimous_final": true,
2707 }))
2708 .unwrap(),
2709 );
2710 state
2711 }
2712
2713 #[tokio::test]
2714 async fn a_real_failure_without_a_pr_raises_one_notice_and_a_retry_folds_into_it() {
2715 let (_d, repo) =
2716 origin_with(&[("Cargo.toml", "[package]\nname=\"x\"\nversion=\"0.1.0\"\n")]).await;
2717 let home = tempfile::tempdir().unwrap();
2718 let store = Notices::at(home.path().join("notifications"));
2719 let mut state = winner_state(&repo, "nope");
2721 let url = "https://example.invalid/pull/1";
2722 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2723 .await
2724 .expect_err("an unresolvable base is a failure");
2725 let listed = store.list();
2726 assert_eq!(listed.len(), 1, "{listed:?}");
2727 assert!(
2728 listed[0].message.contains("did not run"),
2729 "{}",
2730 listed[0].message
2731 );
2732 assert!(
2733 listed[0].message.contains("Cargo.toml"),
2734 "{}",
2735 listed[0].message
2736 );
2737 assert!(state.events.iter().any(|e| e.node == "bump"));
2738 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2739 .await
2740 .expect_err("still failing");
2741 let listed = store.list();
2742 assert_eq!(listed.len(), 1, "{listed:?}");
2743 assert_eq!(listed[0].count, 2);
2744 }
2745
2746 #[tokio::test]
2747 async fn not_eligible_cases_raise_no_notice() {
2748 let home = tempfile::tempdir().unwrap();
2749 let store = Notices::at(home.path().join("notifications"));
2750 let url = "https://example.invalid/pull/1";
2751 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2753 let mut state = winner_state(&repo, "main");
2754 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2755 .await
2756 .unwrap();
2757 let mut state = winner_state(&repo, "nope");
2759 state.config.merge.release_bump = false;
2760 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2761 .await
2762 .unwrap();
2763 let mut state = winner_state(&repo, "nope");
2765 state.tally = None;
2766 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2767 .await
2768 .unwrap();
2769 assert!(store.list().is_empty(), "{:?}", store.list());
2770 }
2771
2772 #[test]
2773 fn the_no_pr_notice_follows_the_configured_language() {
2774 let dir = tempfile::tempdir().unwrap();
2775 let store = Notices::at(dir.path().join("notifications"));
2776 let mut state = merged_state();
2777 state.config.graph.language = "ja".to_owned();
2778 let (n, _) = surface_problem(&mut state, &store, None, Some("0.2.0"), "boom").unwrap();
2779 assert!(n.message.contains("実行されませんでした"), "{}", n.message);
2780 assert!(n.message.contains("boom") && n.message.contains("v0.2.0"));
2781 }
2782
2783 #[test]
2784 fn a_failed_escalation_points_at_the_pending_pr_without_commenting() {
2785 let dir = tempfile::tempdir().unwrap();
2786 let store = Notices::at(dir.path().join("notifications"));
2787 let mut state = merged_state();
2788 let url = "https://example.invalid/pull/9";
2789 let (n, comment) = surface_problem_in(
2790 &mut state,
2791 &store,
2792 Some(url),
2793 Some("0.3.0"),
2794 "push failed",
2795 true,
2796 )
2797 .unwrap();
2798 assert!(comment.is_none());
2799 assert!(
2800 n.message.contains(url) && n.message.contains("v0.3.0"),
2801 "{}",
2802 n.message
2803 );
2804 assert!(!n.message.contains("open the release pull request by hand"));
2805 assert!(matches!(n.link, Some(Link::Url { .. })));
2806 }
2807
2808 #[test]
2809 fn a_failure_notice_names_the_pending_pr_and_the_first_line_of_the_cause() {
2810 let err = anyhow!("outer context").context("cargo build failed\nsecond line");
2811 let with_pr = Progress {
2812 version: Some("0.2.0".into()),
2813 pending_pr: Some("https://example.invalid/pull/9".into()),
2814 };
2815 let (v, reason) = notice_for_failure(&with_pr, &err);
2816 assert_eq!(v.as_deref(), Some("0.2.0"));
2817 assert_eq!(reason, "cargo build failed");
2818 let (v, reason) = notice_for_failure(&Progress::default(), &err);
2819 assert_eq!(v, None);
2820 assert_eq!(reason, "cargo build failed");
2821 }
2822
2823 const NO_RULES: &str = "gh pr merge --auto: GraphQL: Pull request Branch does not have \
2824 required protected branch rules (enablePullRequestAutoMerge)";
2825
2826 fn merged_state() -> RunState {
2827 run::pin_test_home();
2830 let mut s = RunState::new(
2831 PathBuf::from("/no/such/repo"),
2832 "main".to_owned(),
2833 "0000000000000000000000000000000000000000".to_owned(),
2834 "task".to_owned(),
2835 Config::default(),
2836 );
2837 s.status = RunStatus::Merged;
2838 s
2839 }
2840
2841 #[test]
2842 fn the_known_automerge_refusal_names_branch_protection() {
2843 assert!(automerge_hint(NO_RULES).contains("branch protection with required"));
2844 let other = automerge_hint("gh: network unreachable");
2845 assert!(!other.contains("branch protection"), "{other}");
2846 let body = automerge_failure_comment(NO_RULES);
2847 assert!(body.contains("enablePullRequestAutoMerge"), "{body}");
2848 assert!(body.contains("Action required"), "{body}");
2849 }
2850
2851 const CLEAN: &str = "gh pr merge --auto: GraphQL: Pull request Pull request is in clean \
2852 status (enablePullRequestAutoMerge)";
2853
2854 #[test]
2855 fn clean_status_refusal_is_matched_narrowly() {
2856 assert!(is_clean_status_refusal(CLEAN));
2857 assert!(!is_clean_status_refusal(NO_RULES));
2858 assert!(!is_clean_status_refusal("gh: network unreachable"));
2859 assert!(!is_clean_status_refusal("Pull request is in clean status"));
2860 assert!(automerge_hint(CLEAN).contains("already green"));
2861 }
2862
2863 #[test]
2864 fn the_direct_merge_argv_matches_the_land_flags() {
2865 let a = bump_merge_argv("https://github.com/o/r/pull/9", "chore: release v1.0.0");
2866 let l = land::merge_argv(9, "chore: release v1.0.0");
2867 assert_eq!(a[..2], l[..2]);
2868 assert_eq!(a[3..], l[3..]);
2869 assert_eq!(a[2], "https://github.com/o/r/pull/9");
2870 }
2871
2872 #[test]
2873 fn a_direct_merge_is_judged_by_the_forge_not_the_exit_code() {
2874 let argv = bump_merge_argv("u", "t");
2875 let merged = |o: &AutomergeOutcome| matches!(o, AutomergeOutcome::MergedDirectly { .. });
2876 assert!(merged(&resolve_direct_merge(CLEAN, &argv, true, "", None)));
2877 let detached = "not on any branch";
2878 assert!(merged(&resolve_direct_merge(
2879 CLEAN,
2880 &argv,
2881 false,
2882 detached,
2883 Some(PrLifecycle::Merged)
2884 )));
2885 for after in [Some(PrLifecycle::Open), None] {
2886 let o = resolve_direct_merge(CLEAN, &argv, false, "boom", after);
2887 match o {
2888 AutomergeOutcome::Failed { reason } => {
2889 assert!(
2890 reason.contains("clean status") && reason.contains("boom"),
2891 "{reason}"
2892 )
2893 }
2894 other => panic!("expected Failed, got {other:?}"),
2895 }
2896 }
2897 }
2898
2899 #[test]
2900 fn a_local_mode_bump_reads_back_and_old_records_are_not_local() {
2901 let old: run::ReleaseBump =
2902 serde_json::from_str(r#"{"pr_url":"u","automerge_enabled":true}"#).unwrap();
2903 assert!(!old.local);
2904 let mut state = merged_state();
2905 state.release_bump = Some(run::ReleaseBump {
2906 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
2907 version: Some("1.0.0".to_owned()),
2908 local: true,
2909 ..run::ReleaseBump::default()
2910 });
2911 assert!(!state.needs_attention());
2913 let text = crate::report::run(&state);
2914 assert!(text.contains("release.mode = local"), "{text}");
2915 assert!(!text.contains("FAILED"), "{text}");
2916 }
2917
2918 #[test]
2919 fn a_directly_merged_bump_is_not_reported_as_pending_or_failed() {
2920 let mut state = merged_state();
2921 state.release_bump = Some(run::ReleaseBump {
2922 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
2923 version: Some("1.0.0".to_owned()),
2924 merged_directly: true,
2925 ..run::ReleaseBump::default()
2926 });
2927 assert!(!state.needs_attention());
2928 let text = crate::report::run(&state);
2929 assert!(text.contains("merged directly"), "{text}");
2930 assert!(!text.contains("FAILED"), "{text}");
2931 }
2932
2933 #[test]
2934 fn an_automerge_failure_is_recorded_shown_and_filed_and_survives_settling() {
2935 let dir = tempfile::tempdir().unwrap();
2936 let store = Notices::at(dir.path().join("notifications"));
2937 let questions = ask::Questions::at(dir.path().join("questions"));
2938 let mut state = merged_state();
2939 let url = "https://github.com/o/r/pull/35";
2940
2941 let (n, comment) =
2942 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
2943
2944 let comment = comment.expect("a PR was opened, so it gets a comment");
2946 assert!(comment.contains("branch protection"), "{comment}");
2947
2948 assert_eq!(state.status, RunStatus::Merged);
2950 assert!(state.needs_attention());
2951 let text = crate::report::run(&state);
2952 assert!(text.contains("release bump"), "{text}");
2953 assert!(text.contains("FAILED"), "{text}");
2954 assert!(text.contains(url), "{text}");
2955 assert!(text.contains("action required"), "{text}");
2956 assert!(crate::report::line(&state).contains("release needs a human"));
2957
2958 assert_eq!(n.severity, Severity::Error);
2960 assert_eq!(
2961 n.link,
2962 Some(Link::Url {
2963 url: url.to_owned()
2964 })
2965 );
2966 assert_eq!(store.list().len(), 1);
2967 assert!(questions.open_for(&state.id).is_empty());
2968
2969 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
2971 let listed = store.list();
2972 assert_eq!(listed.len(), 1);
2973 assert_eq!(listed[0].count, 2);
2974 }
2975
2976 #[test]
2977 fn a_bump_that_never_ran_is_surfaced_without_a_pr_comment() {
2978 let dir = tempfile::tempdir().unwrap();
2979 let store = Notices::at(dir.path().join("notifications"));
2980 let mut state = merged_state();
2981 let (n, comment) = surface_problem(&mut state, &store, None, None, "no agent").unwrap();
2982 assert!(comment.is_none());
2983 assert!(matches!(n.link, Some(Link::Run { .. })));
2984 assert!(state.needs_attention());
2985 }
2986
2987 #[test]
2988 fn version_parses_and_bumps_each_digit() {
2989 let v = Version::parse("0.4.0").unwrap();
2990 assert_eq!(
2991 v,
2992 Version {
2993 major: 0,
2994 minor: 4,
2995 patch: 0
2996 }
2997 );
2998
2999 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
3000 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
3001 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
3002 }
3003
3004 #[test]
3005 fn version_tolerates_a_prerelease_suffix_on_patch() {
3006 let v = Version::parse("1.2.3-rc1").unwrap();
3007 assert_eq!(
3008 v,
3009 Version {
3010 major: 1,
3011 minor: 2,
3012 patch: 3
3013 }
3014 );
3015 }
3016
3017 #[test]
3018 fn version_rejects_garbage() {
3019 assert!(Version::parse("not-a-version").is_err());
3020 assert!(Version::parse("1.2").is_err());
3021 }
3022
3023 #[test]
3024 fn decision_parses_each_level() {
3025 for (json, level) in [
3026 (
3027 r#"{"level":"major","reason":"drops a config key"}"#,
3028 BumpLevel::Major,
3029 ),
3030 (
3031 r#"{"level":"minor","reason":"adds a new flag"}"#,
3032 BumpLevel::Minor,
3033 ),
3034 (
3035 r#"{"level":"patch","reason":"fixes a race"}"#,
3036 BumpLevel::Patch,
3037 ),
3038 ] {
3039 let decision = parse_decision(json).unwrap();
3040 assert_eq!(decision.level, level);
3041 assert!(!decision.reason.is_empty());
3042 }
3043 }
3044
3045 #[test]
3046 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
3047 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
3048 let decision = parse_decision(text).unwrap();
3049 assert_eq!(decision.level, BumpLevel::Minor);
3050 assert_eq!(decision.reason, "new HTTP route");
3051 }
3052
3053 #[test]
3054 fn a_broken_reply_is_an_error_not_a_default() {
3055 assert!(parse_decision("I decline to answer.").is_err());
3056 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
3057 assert!(
3058 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
3059 "an empty reason must not pass either"
3060 );
3061 assert!(
3062 parse_decision(r#"{"level":"patch"}"#).is_err(),
3063 "a reply with no reason at all must not pass"
3064 );
3065 }
3066
3067 #[test]
3068 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
3069 let prompt = decision_prompt(
3070 "feat: add a phone endpoint",
3071 "add POST /api/widgets",
3072 "1 file changed, 10 insertions(+)",
3073 &["src/web.rs".to_owned()],
3074 "0.8.0",
3075 );
3076 assert!(prompt.contains("0.8.0"), "the current version is stated");
3077 assert!(
3078 prompt.contains("below `1.0.0`")
3079 && prompt.contains("`minor` is the digit that carries a breaking change"),
3080 "the 0.x rule must be explicit: {prompt}"
3081 );
3082 assert!(
3083 prompt.contains("choose the larger"),
3084 "the tie-break toward the bigger digit must be explicit: {prompt}"
3085 );
3086 }
3087
3088 #[test]
3089 fn release_only_diffs_are_recognised() {
3090 assert!(is_release_only(&["Cargo.toml".to_owned()]));
3091 assert!(is_release_only(&[
3092 "Cargo.toml".to_owned(),
3093 "Cargo.lock".to_owned()
3094 ]));
3095 assert!(!is_release_only(&[]));
3096 assert!(!is_release_only(&[
3097 "Cargo.toml".to_owned(),
3098 "src/main.rs".to_owned()
3099 ]));
3100 }
3101
3102 #[test]
3103 fn cargo_version_rewrite_touches_only_the_package_table() {
3104 let toml = "\
3105[package]\n\
3106# a comment mentioning version on purpose\n\
3107name = \"magi-cli\"\n\
3108version = \"0.8.0\"\n\
3109edition = \"2024\"\n\
3110\n\
3111[dependencies]\n\
3112foo = { version = \"1.2.3\" }\n";
3113 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
3114 assert!(out.contains("version = \"0.9.0\""));
3115 assert!(
3116 out.contains("foo = { version = \"1.2.3\" }"),
3117 "a dependency's own version pin must survive: {out}"
3118 );
3119 assert!(
3120 out.contains("# a comment mentioning version on purpose"),
3121 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
3122 );
3123 assert_eq!(
3124 out.lines().count(),
3125 toml.lines().count(),
3126 "the rewrite replaces one line, it does not add or remove any"
3127 );
3128 }
3129
3130 #[test]
3131 fn cargo_version_rewrite_fails_without_a_package_table() {
3132 let toml = "[dependencies]\nfoo = \"1\"\n";
3133 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
3134 }
3135
3136 #[test]
3142 fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
3143 let toml = "\
3144[workspace]\n\
3145members = [\"crates/a\", \"crates/b\"]\n\
3146\n\
3147[workspace.package]\n\
3148version = \"0.45.18\"\n\
3149edition = \"2024\"\n\
3150\n\
3151[workspace.dependencies]\n\
3152foo = { version = \"1.2.3\" }\n";
3153 let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
3154 assert!(out.contains("version = \"0.45.19\""));
3155 assert!(
3156 out.contains("foo = { version = \"1.2.3\" }"),
3157 "a workspace dependency's own version pin must survive: {out}"
3158 );
3159 assert_eq!(
3160 out.lines().count(),
3161 toml.lines().count(),
3162 "the rewrite replaces one line, it does not add or remove any"
3163 );
3164 }
3165
3166 #[test]
3170 fn cargo_version_rewrite_bumps_an_internal_workspace_dependency_pin() {
3171 let toml = "\
3172[workspace]\n\
3173members = [\"crates/kanade-shared\"]\n\
3174\n\
3175[workspace.package]\n\
3176version = \"0.48.2\"\n\
3177\n\
3178[workspace.dependencies]\n\
3179kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n";
3180 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3181 assert!(out.contains("[workspace.package]\nversion = \"0.48.3\"\n"));
3182 assert!(
3183 out.contains(
3184 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3185 ),
3186 "the internal pin must move with the workspace version: {out}"
3187 );
3188 }
3189
3190 #[test]
3192 fn cargo_version_rewrite_bumps_an_internal_pin_with_version_before_path() {
3193 let toml = "\
3194[workspace.package]\n\
3195version = \"1.0.0\"\n\
3196\n\
3197[workspace.dependencies]\n\
3198inner = { version = \"1.0.0\", path = \"crates/inner\" }\n";
3199 let out = rewrite_cargo_version(toml, "1.0.1").unwrap();
3200 assert!(out.contains("inner = { version = \"1.0.1\", path = \"crates/inner\" }"));
3201 }
3202
3203 #[test]
3206 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_table_form() {
3207 let toml = "\
3208[workspace.package]\n\
3209version = \"2.3.0\"\n\
3210\n\
3211[workspace.dependencies.inner]\n\
3212path = \"crates/inner\"\n\
3213version = \"2.3.0\"\n";
3214 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3215 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3216 assert!(out.contains(
3217 "[workspace.dependencies.inner]\npath = \"crates/inner\"\nversion = \"2.4.0\"\n"
3218 ));
3219 }
3220
3221 #[test]
3226 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_key_form() {
3227 let toml = "\
3228[workspace.package]\n\
3229version = \"2.3.0\"\n\
3230\n\
3231[workspace.dependencies]\n\
3232inner.path = \"crates/inner\"\n\
3233inner.version = \"2.3.0\"\n";
3234 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3235 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3236 assert!(out.contains("inner.path = \"crates/inner\"\ninner.version = \"2.4.0\"\n"));
3237 }
3238
3239 #[test]
3242 fn cargo_version_rewrite_leaves_a_path_only_workspace_dependency_untouched() {
3243 let toml = "\
3244[workspace.package]\n\
3245version = \"0.1.0\"\n\
3246\n\
3247[workspace.dependencies]\n\
3248internal-only = { path = \"crates/internal-only\" }\n";
3249 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3250 assert!(out.contains("internal-only = { path = \"crates/internal-only\" }"));
3251 }
3252
3253 #[test]
3257 fn cargo_version_rewrite_leaves_an_external_dependency_untouched() {
3258 let toml = "\
3259[workspace.package]\n\
3260version = \"0.1.0\"\n\
3261\n\
3262[workspace.dependencies]\n\
3263serde = { version = \"1\", features = [\"derive\"] }\n\
3264inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3265 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3266 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3267 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3268 }
3269
3270 #[test]
3274 fn cargo_version_rewrite_without_workspace_dependencies_table_still_bumps_package() {
3275 let toml = "[workspace.package]\nversion = \"0.9.0\"\nedition = \"2024\"\n";
3276 let out = rewrite_cargo_version(toml, "0.10.0").unwrap();
3277 assert_eq!(
3278 out,
3279 "[workspace.package]\nversion = \"0.10.0\"\nedition = \"2024\"\n"
3280 );
3281 }
3282
3283 #[test]
3286 fn cargo_version_rewrite_handles_a_kanade_shaped_workspace_dependencies_table() {
3287 let toml = "\
3288[workspace.package]\n\
3289version = \"0.48.2\"\n\
3290\n\
3291[workspace.dependencies]\n\
3292anyhow = { version = \"1\" }\n\
3293serde = { version = \"1\", features = [\"derive\"] }\n\
3294kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n\
3295kanade-core = { path = \"crates/kanade-core\", version = \"0.48.2\" }\n\
3296kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }\n";
3297 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3298 assert!(out.contains("anyhow = { version = \"1\" }"));
3299 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3300 assert!(
3301 out.contains(
3302 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3303 )
3304 );
3305 assert!(
3306 out.contains("kanade-core = { path = \"crates/kanade-core\", version = \"0.48.3\" }")
3307 );
3308 assert!(out.contains("kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }"));
3309 }
3310
3311 #[test]
3315 fn cargo_version_rewrite_bails_on_an_unrepresentable_inline_table() {
3316 let toml = "\
3317[workspace.package]\n\
3318version = \"0.1.0\"\n\
3319\n\
3320[workspace.dependencies]\n\
3321inner = { path = \"crates/inner\",\n\
3322 version = \"0.1.0\" }\n";
3323 assert!(rewrite_cargo_version(toml, "0.2.0").is_err());
3324 }
3325
3326 #[test]
3329 fn cargo_version_rewrite_recognises_a_commented_workspace_dependencies_header() {
3330 let toml = "\
3331[workspace.package]\n\
3332version = \"0.1.0\"\n\
3333\n\
3334[workspace.dependencies] # internal pins\n\
3335inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3336 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3337 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3338 }
3339
3340 #[test]
3343 fn cargo_version_rewrite_bumps_a_quoted_dependency_key() {
3344 let toml = "\
3345[workspace.package]\n\
3346version = \"0.1.0\"\n\
3347\n\
3348[workspace.dependencies]\n\
3349\"inner\" = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3350 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3351 assert!(out.contains("\"inner\" = { path = \"crates/inner\", version = \"0.2.0\" }"));
3352 }
3353
3354 #[test]
3358 fn cargo_version_rewrite_bumps_a_literal_string_version_pin() {
3359 let toml = "\
3360[workspace.package]\n\
3361version = \"0.1.0\"\n\
3362\n\
3363[workspace.dependencies]\n\
3364inner = { path = 'crates/inner', version = '0.1.0' }\n";
3365 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3366 assert!(out.contains("inner = { path = 'crates/inner', version = '0.2.0' }"));
3367 }
3368
3369 #[test]
3375 fn cargo_version_rewrite_does_not_require_the_whole_file_to_parse() {
3376 let toml = "\
3377[workspace.package]\n\
3378version = \"0.1.0\"\n\
3379\n\
3380[workspace.dependencies]\n\
3381inner = { path = \"crates/inner\", version = \"0.1.0\" }\n\
3382\n\
3383[workspace.package]\n\
3384edition = \"2024\"\n";
3385 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3386 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3387 assert!(
3388 toml::from_str::<toml::Value>(toml).is_err(),
3389 "the fixture itself must be invalid as a whole file, or this test proves nothing"
3390 );
3391 }
3392
3393 #[test]
3394 fn current_version_prefers_the_package_table_when_both_exist() {
3395 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
3396 assert_eq!(current_version(toml).unwrap(), "0.8.0");
3397 }
3398
3399 #[test]
3402 fn current_version_falls_back_to_workspace_package_without_a_package_table() {
3403 let toml = "\
3404[workspace]\n\
3405members = [\"crates/a\", \"crates/b\"]\n\
3406\n\
3407[workspace.package]\n\
3408version = \"0.45.18\"\n";
3409 assert_eq!(current_version(toml).unwrap(), "0.45.18");
3410 }
3411
3412 #[test]
3413 fn coalesce_proceeds_with_nothing_pending() {
3414 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
3415 }
3416
3417 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
3420 PendingBump {
3421 target_version: target_version.to_owned(),
3422 level,
3423 branch: format!("chore/release-v{target_version}"),
3424 pr_url: "https://example.invalid/pull/9".to_owned(),
3425 }
3426 }
3427
3428 #[test]
3429 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
3430 let pending = test_pending("0.9.0", BumpLevel::Minor);
3431 assert_eq!(
3432 coalesce(Some(&pending), "0.8.0").unwrap(),
3433 Coalesce::Skip {
3434 target_version: "0.9.0".to_owned()
3435 }
3436 );
3437 }
3438
3439 #[test]
3440 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
3441 let pending = test_pending("0.9.0", BumpLevel::Minor);
3442 assert_eq!(
3444 coalesce(Some(&pending), "0.9.0").unwrap(),
3445 Coalesce::Proceed
3446 );
3447 assert_eq!(
3449 coalesce(Some(&pending), "1.0.0").unwrap(),
3450 Coalesce::Proceed
3451 );
3452 }
3453
3454 #[test]
3455 fn pending_action_escalates_only_for_a_more_severe_decision() {
3456 assert_eq!(
3457 pending_action(BumpLevel::Patch, BumpLevel::Patch),
3458 PendingAction::AlreadyCovered
3459 );
3460 assert_eq!(
3461 pending_action(BumpLevel::Patch, BumpLevel::Minor),
3462 PendingAction::Escalate
3463 );
3464 assert_eq!(
3465 pending_action(BumpLevel::Patch, BumpLevel::Major),
3466 PendingAction::Escalate
3467 );
3468 assert_eq!(
3469 pending_action(BumpLevel::Minor, BumpLevel::Patch),
3470 PendingAction::AlreadyCovered
3471 );
3472 assert_eq!(
3473 pending_action(BumpLevel::Major, BumpLevel::Minor),
3474 PendingAction::AlreadyCovered
3475 );
3476 assert_eq!(
3477 pending_action(BumpLevel::Major, BumpLevel::Major),
3478 PendingAction::AlreadyCovered
3479 );
3480 }
3481
3482 #[test]
3483 fn pr_state_parsing_reads_open_and_not_open() {
3484 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
3485 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
3486 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
3487 }
3488
3489 #[test]
3490 fn a_lock_is_exclusive_until_dropped() {
3491 let dir = tempfile::tempdir().unwrap();
3492 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3493 let first = MarkerLock::acquire(&marker)
3494 .unwrap()
3495 .expect("first attempt takes the lock");
3496 assert!(
3497 MarkerLock::acquire(&marker).unwrap().is_none(),
3498 "a second attempt must be refused while the first holds it"
3499 );
3500 drop(first);
3501 assert!(
3502 MarkerLock::acquire(&marker).unwrap().is_some(),
3503 "dropping the guard releases the lock for the next attempt"
3504 );
3505 }
3506
3507 #[test]
3508 fn a_stale_lock_is_reclaimed() {
3509 let dir = tempfile::tempdir().unwrap();
3510 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3511 let lock_path = marker.with_extension("lock");
3512 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
3513 std::fs::write(&lock_path, b"").unwrap();
3514 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
3515 std::fs::OpenOptions::new()
3516 .write(true)
3517 .open(&lock_path)
3518 .unwrap()
3519 .set_modified(old)
3520 .unwrap();
3521 assert!(
3522 MarkerLock::acquire(&marker).unwrap().is_some(),
3523 "a lock older than the stale window must be reclaimed rather than block forever"
3524 );
3525 }
3526
3527 #[tokio::test]
3528 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
3529 let dir = tempfile::tempdir().unwrap();
3530 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3531 let held = MarkerLock::acquire(&marker)
3532 .unwrap()
3533 .expect("seed the contention");
3534 let releaser = tokio::spawn(async move {
3535 tokio::time::sleep(Duration::from_millis(20)).await;
3536 drop(held);
3537 });
3538 let waited =
3539 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
3540 .await
3541 .unwrap();
3542 assert!(
3543 waited.is_some(),
3544 "a merge landing behind another's still-running decision must not be dropped - it \
3545 must wait for that decision to finish and then judge against what it left behind"
3546 );
3547 releaser.await.unwrap();
3548 }
3549
3550 #[tokio::test]
3551 async fn a_lock_held_past_the_ceiling_gives_up() {
3552 let dir = tempfile::tempdir().unwrap();
3553 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3554 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
3555 let waited =
3556 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
3557 .await
3558 .unwrap();
3559 assert!(
3560 waited.is_none(),
3561 "a lock genuinely held past the ceiling must eventually give up rather than wait \
3562 forever"
3563 );
3564 }
3565
3566 #[test]
3567 fn level_between_reads_off_the_differing_digit() {
3568 assert_eq!(
3569 level_between(
3570 Version::parse("0.8.0").unwrap(),
3571 Version::parse("1.0.0").unwrap()
3572 ),
3573 Some(BumpLevel::Major)
3574 );
3575 assert_eq!(
3576 level_between(
3577 Version::parse("0.8.0").unwrap(),
3578 Version::parse("0.9.0").unwrap()
3579 ),
3580 Some(BumpLevel::Minor)
3581 );
3582 assert_eq!(
3583 level_between(
3584 Version::parse("0.8.0").unwrap(),
3585 Version::parse("0.8.1").unwrap()
3586 ),
3587 Some(BumpLevel::Patch)
3588 );
3589 assert_eq!(
3590 level_between(
3591 Version::parse("0.8.0").unwrap(),
3592 Version::parse("0.8.0").unwrap()
3593 ),
3594 None
3595 );
3596 }
3597
3598 #[test]
3599 fn open_release_pr_is_found_among_unrelated_pull_requests() {
3600 let json = r#"[
3601 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
3602 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
3603 ]"#;
3604 let found = parse_open_release_pr(json).unwrap();
3605 assert_eq!(
3606 found,
3607 Some((
3608 "chore/release-v0.9.0".to_owned(),
3609 "https://example.invalid/pull/2".to_owned()
3610 ))
3611 );
3612 }
3613
3614 #[test]
3615 fn no_open_release_pr_reads_as_none_not_an_error() {
3616 let json =
3617 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
3618 assert_eq!(parse_open_release_pr(json).unwrap(), None);
3619 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
3620 }
3621
3622 #[test]
3623 fn marker_round_trips_through_disk() {
3624 let dir = tempfile::tempdir().unwrap();
3625 let path = marker_path(dir.path(), Path::new("/repos/magi"));
3626 assert!(read_marker(&path).is_none());
3627
3628 let marker = test_pending("0.9.0", BumpLevel::Patch);
3629 write_marker(&path, &marker).unwrap();
3630 let read_back = read_marker(&path).unwrap();
3631 assert_eq!(read_back.target_version, "0.9.0");
3632 assert_eq!(read_back.level, BumpLevel::Patch);
3633 assert_eq!(read_back.pr_url, marker.pr_url);
3634
3635 clear_marker(&path);
3636 assert!(read_marker(&path).is_none());
3637 }
3638
3639 #[test]
3640 fn different_repos_get_different_marker_files() {
3641 let dir = tempfile::tempdir().unwrap();
3642 let a = marker_path(dir.path(), Path::new("/repos/a"));
3643 let b = marker_path(dir.path(), Path::new("/repos/b"));
3644 assert_ne!(a, b);
3645 }
3646
3647 #[test]
3651 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
3652 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
3653 assert!(
3654 is_release_only(&files),
3655 "the bump pull request's own diff must read as release-only"
3656 );
3657 }
3658
3659 #[test]
3660 fn should_release_bump_reads_only_a_merged_status() {
3661 assert!(should_release_bump(RunStatus::Merged));
3662 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
3663 assert!(!should_release_bump(other));
3664 }
3665 }
3666
3667 #[test]
3671 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
3672 let pr = land::PrState {
3673 url: "https://github.com/o/r/pull/1".to_owned(),
3674 number: 1,
3675 state: PrLifecycle::Merged,
3676 checks: land::Checks::Green,
3677 failing: Vec::new(),
3678 review_comments: Vec::new(),
3679 blocking: land::Blocking::No,
3680 };
3681 assert_eq!(
3682 land::decide(&pr, 0, 4, Duration::ZERO),
3683 land::Step::Done { merged: true }
3684 );
3685 assert!(should_release_bump(RunStatus::Merged));
3686 }
3687
3688 #[test]
3693 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
3694 let pr = land::PrState {
3695 url: "https://github.com/o/r/pull/2".to_owned(),
3696 number: 2,
3697 state: PrLifecycle::Open,
3698 checks: land::Checks::Green,
3699 failing: Vec::new(),
3700 review_comments: Vec::new(),
3701 blocking: land::Blocking::No,
3702 };
3703 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
3704 assert!(should_release_bump(RunStatus::Merged));
3707 }
3708
3709 #[test]
3712 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
3713 let argv = land::merge_argv(3, "feat: something");
3714 let outcome = land::merged_after_all(
3715 &argv,
3716 "could not determine current branch: not on any branch",
3717 Some(PrLifecycle::Merged),
3718 );
3719 assert!(outcome.is_some(), "the forge's confirmation must win");
3720 assert!(should_release_bump(RunStatus::Merged));
3721
3722 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
3725 assert!(land::merged_after_all(&argv, "network error", None).is_none());
3726 }
3727
3728 #[test]
3730 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
3731 let pr = land::PrState {
3732 url: "https://github.com/o/r/pull/4".to_owned(),
3733 number: 4,
3734 state: PrLifecycle::Closed,
3735 checks: land::Checks::Green,
3736 failing: Vec::new(),
3737 review_comments: Vec::new(),
3738 blocking: land::Blocking::No,
3739 };
3740 assert_eq!(
3741 land::decide(&pr, 0, 4, Duration::ZERO),
3742 land::Step::Done { merged: false }
3743 );
3744 assert!(!should_release_bump(RunStatus::Blocked));
3745 }
3746}