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