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
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct DecisionAttempt {
42 pub agent: String,
44 pub outcome: String,
46}
47
48async fn ask_decision(
51 state: &mut RunState,
52 repo: &Path,
53 prompt: &str,
54 timeout: Duration,
55) -> Result<(AgentSpec, SeatState, BumpDecision)> {
56 let chain = agent::pick_chain(
57 &state.config.agents,
58 state.config.roles.chatter.as_ref(),
59 &agent::installed,
60 "chatter (release-bump decision)",
61 )?;
62 let attempt_timeout = timeout / u32::try_from(chain.len()).unwrap_or(u32::MAX);
65 let artifacts = agent::artifacts_dir(&state.dir());
66 let cache = state.config.cache_dir();
67 let mut failures = Vec::new();
68 for (n, spec) in chain.iter().enumerate() {
69 let mut seat = crate::graph::handover_seat("bump", &spec.id, state.next_seat_seed());
70 let attempt = state.bump_decision_attempts.len() + 1;
73 let stem = format!("bump-decision-{attempt}");
74 state.event(
75 "bump",
76 format!("decision attempt {attempt}: trying {} ({stem})", spec.id),
77 );
78 let outcome = agent::invoke(
79 spec,
80 &mut seat,
81 &Invocation {
82 scope: crate::agent::InvocationScope::Repository,
83 coordination: state.config.coordination.as_ref(),
84 fetched_base: None,
85 cwd: repo,
86 prompt,
87 timeout: attempt_timeout,
88 allow_write: false,
89 unsandboxed: false,
90 sessions: false,
91 artifacts: &artifacts,
92 stem: &stem,
93 run: &state.id,
94 node: "bump",
95 cache_dir: cache.as_deref(),
96 attachments: &[],
97 writable: &[],
98 },
99 )
100 .await;
101 let parsed = if agent::chain_advances(&outcome) {
102 None
103 } else {
104 outcome
105 .as_ref()
106 .ok()
107 .and_then(|out| parse_decision(&out.text).ok())
108 };
109 let (class, reason) = match &outcome {
112 Ok(out) if out.quota_exhausted() => {
113 (run::FailClass::Quota, "rate/quota limit".to_owned())
114 }
115 Ok(out) if out.timed_out => (run::FailClass::Timeout, "timed out".to_owned()),
116 Ok(out) if out.exit_code != Some(0) => (
117 run::FailClass::Other("cli".into()),
118 format!(
119 "CLI failed ({})",
120 crate::notices::exit_status(out.exit_code)
121 ),
122 ),
123 Ok(out) if out.text.trim().is_empty() => (
124 run::FailClass::Other("empty".into()),
125 "empty output".to_owned(),
126 ),
127 Err(_) => (
128 run::FailClass::Other("cli".into()),
129 "CLI invocation failed; inspect attempt artifacts".to_owned(),
130 ),
131 _ => (
132 run::FailClass::Other("json".into()),
133 "invalid decision JSON or missing reason".to_owned(),
134 ),
135 };
136 let status = if parsed.is_some() {
137 "valid decision"
138 } else {
139 &reason
140 };
141 state.bump_decision_attempts.push(DecisionAttempt {
142 agent: spec.id.clone(),
143 outcome: status.to_owned(),
144 });
145 state.event(
146 "bump",
147 format!("decision attempt {attempt}: {}: {status}", spec.id),
148 );
149 if let Some(decision) = parsed {
150 return Ok((spec.clone(), seat, decision));
151 }
152 failures.push(format!("{}: {reason}", spec.id));
153 if let Some(next) = chain.get(n + 1) {
154 crate::graph::record_handover(
155 state, "bump", "bump", &spec.id, &next.id, &class, &reason,
156 );
157 }
158 }
159 bail!(
160 "release-bump decision exhausted {} eligible agent(s): {}. Check CLI subscription limits, availability and bump-decision attempt artifacts; adjust [roles] chatter or retry after recovery. No release PR was created by this decision",
161 chain.len(),
162 failures.join("; ")
163 )
164}
165
166async fn guard_reason(
169 state: &mut RunState,
170 repo: &Path,
171 prompt: &str,
172 spec: &AgentSpec,
173 seat: &mut SeatState,
174 decision: &mut BumpDecision,
175) {
176 let artifacts = agent::artifacts_dir(&state.dir());
177 let mut violations =
178 crate::github_text::check_for(&state.config.graph, "", &decision.reason, None);
179 violations.retain(|v| *v != crate::github_text::Violation::SensitiveData);
181 if violations.is_empty() {
182 decision.reason = crate::scrub::scrub(&decision.reason, &crate::scrub::Identity::current());
183 }
184 if state.config.graph.github_text_guard && !violations.is_empty() {
185 state.event(
186 "github-text",
187 format!("release reason rejected: {violations:?}; requesting one rewrite"),
188 );
189 let rewrite = format!(
190 "{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: {}",
191 decision.reason
192 );
193 let retry = agent::invoke(
194 spec,
195 seat,
196 &Invocation {
197 scope: crate::agent::InvocationScope::Repository,
198 coordination: state.config.coordination.as_ref(),
199 fetched_base: None,
200 cwd: repo,
201 prompt: &rewrite,
202 timeout: crate::graph::retry_budget(DECISION_TIMEOUT, true),
203 allow_write: false,
204 unsandboxed: false,
205 sessions: false,
206 artifacts: &artifacts,
207 stem: "bump-rewrite",
208 run: &state.id,
209 node: "bump",
210 cache_dir: state.config.cache_dir().as_deref(),
211 attachments: &[],
212 writable: &[],
213 },
214 )
215 .await;
216 let reason = retry
217 .ok()
218 .filter(|o| o.usable())
219 .and_then(|o| parse_decision(&o.text).ok())
220 .filter(|d| {
221 d.level == decision.level
222 && crate::github_text::check_for(&state.config.graph, "", &d.reason, None)
223 .is_empty()
224 })
225 .map(|d| d.reason);
226 if let Some(reason) = reason {
227 decision.reason = reason;
228 state.event("github-text", "release reason rewrite passed");
229 } else {
230 decision.reason = "The merged change requires a release.".to_owned();
231 state.event(
232 "github-text",
233 "release reason rewrite unavailable or rejected; using neutral text",
234 );
235 }
236 }
237}
238
239const DECISION_TIMEOUT: Duration = Duration::from_secs(600);
247
248pub fn should_release_bump(status: RunStatus) -> bool {
264 status == RunStatus::Merged
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "lowercase")]
270pub enum BumpLevel {
271 Major,
273 Minor,
275 Patch,
277}
278
279impl BumpLevel {
280 pub fn as_str(self) -> &'static str {
282 match self {
283 Self::Major => "major",
284 Self::Minor => "minor",
285 Self::Patch => "patch",
286 }
287 }
288
289 fn severity(self) -> u8 {
294 match self {
295 Self::Patch => 0,
296 Self::Minor => 1,
297 Self::Major => 2,
298 }
299 }
300}
301
302#[derive(Debug, Clone, Deserialize)]
309pub struct BumpDecision {
310 pub level: BumpLevel,
312 pub reason: String,
315}
316
317pub fn parse_decision(text: &str) -> Result<BumpDecision> {
321 let decision: BumpDecision = verdict::extract_json(text)?;
322 if decision.reason.trim().is_empty() {
323 bail!("the bump decision carried no reason");
324 }
325 Ok(decision)
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
331pub struct Version {
332 pub major: u64,
334 pub minor: u64,
336 pub patch: u64,
338}
339
340impl Version {
341 pub fn parse(s: &str) -> Result<Self> {
346 let s = s.trim();
347 let mut parts = s.splitn(3, '.');
348 let major = parts
349 .next()
350 .with_context(|| format!("`{s}` has no major component"))?;
351 let minor = parts
352 .next()
353 .with_context(|| format!("`{s}` has no minor component"))?;
354 let patch = parts
355 .next()
356 .with_context(|| format!("`{s}` has no patch component"))?;
357 let patch_digits: String = patch.chars().take_while(char::is_ascii_digit).collect();
358 Ok(Self {
359 major: major
360 .trim()
361 .parse()
362 .with_context(|| format!("`{major}` is not a number"))?,
363 minor: minor
364 .trim()
365 .parse()
366 .with_context(|| format!("`{minor}` is not a number"))?,
367 patch: patch_digits
368 .parse()
369 .with_context(|| format!("`{patch}` has no numeric patch component"))?,
370 })
371 }
372
373 #[must_use]
376 pub fn bump(self, level: BumpLevel) -> Self {
377 match level {
378 BumpLevel::Major => Self {
379 major: self.major + 1,
380 minor: 0,
381 patch: 0,
382 },
383 BumpLevel::Minor => Self {
384 major: self.major,
385 minor: self.minor + 1,
386 patch: 0,
387 },
388 BumpLevel::Patch => Self {
389 major: self.major,
390 minor: self.minor,
391 patch: self.patch + 1,
392 },
393 }
394 }
395}
396
397impl std::fmt::Display for Version {
398 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
400 }
401}
402
403pub fn is_release_only(files: &[String]) -> bool {
412 !files.is_empty() && files.iter().all(|f| f == "Cargo.toml" || f == "Cargo.lock")
413}
414
415fn rewrite_table_version(toml: &str, table: &str, new_version: &str) -> Result<String> {
426 let mut out = String::with_capacity(toml.len() + 8);
427 let mut in_table = false;
428 let mut done = false;
429 for line in toml.split_inclusive('\n') {
430 let trimmed = line.trim();
431 if trimmed.starts_with('[') {
432 in_table = trimmed == table;
433 }
434 if !done && in_table && trimmed.split('=').next().map(str::trim) == Some("version") {
435 let newline = if line.ends_with("\r\n") { "\r\n" } else { "\n" };
436 let _ = write!(out, "version = \"{new_version}\"{newline}");
437 done = true;
438 continue;
439 }
440 out.push_str(line);
441 }
442 if !done {
443 bail!("no `version` field found under `{table}`");
444 }
445 Ok(out)
446}
447
448fn strip_trailing_comment(s: &str) -> &str {
454 let mut in_string = false;
455 let mut quote = '"';
456 for (i, c) in s.char_indices() {
457 if in_string {
458 if c == quote {
459 in_string = false;
460 }
461 } else if c == '"' || c == '\'' {
462 in_string = true;
463 quote = c;
464 } else if c == '#' {
465 return s[..i].trim_end();
466 }
467 }
468 s
469}
470
471fn unquote_key(key: &str) -> &str {
477 for quote in ['"', '\''] {
478 if let Some(inner) = key.strip_prefix(quote).and_then(|k| k.strip_suffix(quote)) {
479 return inner;
480 }
481 }
482 key
483}
484
485fn extract_workspace_dependencies_fragment(toml: &str) -> String {
497 let mut fragment = String::new();
498 let mut capturing = false;
499 for line in toml.split_inclusive('\n') {
500 let header = strip_trailing_comment(line.trim());
501 if header.starts_with('[') {
502 if header == "[workspace.dependencies]" {
503 capturing = true;
504 fragment.push_str("[dependencies]\n");
505 } else if let Some(name) = header
506 .strip_prefix("[workspace.dependencies.")
507 .and_then(|rest| rest.strip_suffix(']'))
508 {
509 capturing = true;
510 let _ = writeln!(fragment, "[dependencies.{name}]");
511 } else {
512 capturing = false;
513 }
514 continue;
515 }
516 if capturing {
517 fragment.push_str(line);
518 }
519 }
520 fragment
521}
522
523fn internal_pin_names(toml: &str) -> Result<Vec<String>> {
541 let fragment = extract_workspace_dependencies_fragment(toml);
542 if fragment.trim().is_empty() {
543 return Ok(Vec::new());
544 }
545 let value: toml::Value =
546 toml::from_str(&fragment).context("failed to parse `[workspace.dependencies]` as TOML")?;
547 let mut names: Vec<String> = value
548 .get("dependencies")
549 .and_then(|d| d.as_table())
550 .into_iter()
551 .flatten()
552 .filter(|(_, dep)| {
553 dep.as_table()
554 .is_some_and(|t| t.contains_key("path") && t.contains_key("version"))
555 })
556 .map(|(name, _)| name.clone())
557 .collect();
558 names.sort();
559 Ok(names)
560}
561
562fn rewrite_quoted_field(line: &str, key: &str, new_value: &str) -> Option<String> {
572 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-';
573 let mut search_from = 0;
574 while let Some(rel) = line[search_from..].find(key) {
575 let key_start = search_from + rel;
576 let before_ok = key_start == 0 || !is_ident(line[..key_start].chars().next_back().unwrap());
577 let eq_pos = key_start
578 + key.len()
579 + (line[key_start + key.len()..].len()
580 - line[key_start + key.len()..].trim_start().len());
581 if before_ok && line[eq_pos..].starts_with('=') {
582 let after_eq = &line[eq_pos + 1..];
583 let ws_len = after_eq.len() - after_eq.trim_start().len();
584 let quote_pos = eq_pos + 1 + ws_len;
585 if let Some(quote_char) = line[quote_pos..]
586 .chars()
587 .next()
588 .filter(|c| *c == '"' || *c == '\'')
589 {
590 let value_begin = quote_pos + quote_char.len_utf8();
591 if let Some(end_rel) = line[value_begin..].find(quote_char) {
592 let value_end = value_begin + end_rel;
593 let mut out = String::with_capacity(line.len());
594 out.push_str(&line[..value_begin]);
595 out.push_str(new_value);
596 out.push_str(&line[value_end..]);
597 return Some(out);
598 }
599 }
600 }
601 search_from = key_start + key.len();
602 }
603 None
604}
605
606fn rewrite_workspace_dependency_pins(
621 toml: &str,
622 names: &[String],
623 new_version: &str,
624) -> Result<String> {
625 if names.is_empty() {
626 return Ok(toml.to_owned());
627 }
628 let mut out = String::with_capacity(toml.len() + names.len() * 8);
629 let mut in_flat_table = false;
630 let mut in_named_table: Option<String> = None;
631 let mut rewritten: std::collections::HashSet<String> = std::collections::HashSet::new();
632
633 for line in toml.split_inclusive('\n') {
634 let trimmed = line.trim();
635 if trimmed.starts_with('[') {
636 let header = strip_trailing_comment(trimmed);
637 in_flat_table = header == "[workspace.dependencies]";
638 in_named_table = header
639 .strip_prefix("[workspace.dependencies.")
640 .and_then(|rest| rest.strip_suffix(']'))
641 .map(str::to_owned);
642 out.push_str(line);
643 continue;
644 }
645
646 let key = trimmed.split('=').next().map(str::trim).map(unquote_key);
647
648 if in_flat_table {
649 if let Some(name) = key.and_then(|k| names.iter().find(|n| n.as_str() == k)) {
651 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
652 rewritten.insert(name.clone());
653 out.push_str(&rewritten_line);
654 continue;
655 }
656 } else if let Some(name) = key.and_then(|k| {
657 k.strip_suffix(".version")
658 .and_then(|prefix| names.iter().find(|n| n.as_str() == prefix))
659 }) {
660 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
665 rewritten.insert(name.clone());
666 out.push_str(&rewritten_line);
667 continue;
668 }
669 }
670 } else if let Some(table_name) = &in_named_table {
671 if key == Some("version") && names.iter().any(|n| n == table_name) {
672 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
673 rewritten.insert(table_name.clone());
674 out.push_str(&rewritten_line);
675 continue;
676 }
677 }
678 }
679
680 out.push_str(line);
681 }
682
683 for name in names {
684 if !rewritten.contains(name) {
685 bail!(
686 "could not rewrite the `[workspace.dependencies]` version pin \
687 for `{name}` - its `version` field was not found in a shape \
688 this rewrite understands"
689 );
690 }
691 }
692 Ok(out)
693}
694
695fn verify_pins_rewritten(toml: &str, names: &[String], new_version: &str) -> Result<()> {
703 let fragment = extract_workspace_dependencies_fragment(toml);
704 let value: toml::Value = toml::from_str(&fragment)
705 .context("rewritten `[workspace.dependencies]` failed to parse")?;
706 let deps = value.get("dependencies").and_then(|d| d.as_table());
707 for name in names {
708 let actual = deps
709 .and_then(|d| d.get(name))
710 .and_then(|dep| dep.as_table())
711 .and_then(|t| t.get("version"))
712 .and_then(|v| v.as_str());
713 if actual != Some(new_version) {
714 bail!(
715 "the `[workspace.dependencies]` version pin for `{name}` did \
716 not end up at `{new_version}` after the rewrite"
717 );
718 }
719 }
720 Ok(())
721}
722
723pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
745 let rewritten = rewrite_table_version(toml, "[package]", new_version)
746 .or_else(|_| rewrite_table_version(toml, "[workspace.package]", new_version))
747 .context("no `version` field found under `[package]` or `[workspace.package]`")?;
748
749 let pin_names = internal_pin_names(&rewritten)?;
750 if pin_names.is_empty() {
751 return Ok(rewritten);
752 }
753
754 let rewritten = rewrite_workspace_dependency_pins(&rewritten, &pin_names, new_version)?;
755 verify_pins_rewritten(&rewritten, &pin_names, new_version)?;
756 Ok(rewritten)
757}
758
759fn version_in_table(toml: &str, table: &str) -> Option<String> {
761 let mut in_table = false;
762 for line in toml.lines() {
763 let trimmed = line.trim();
764 if trimmed.starts_with('[') {
765 in_table = trimmed == table;
766 continue;
767 }
768 if !in_table {
769 continue;
770 }
771 let mut parts = trimmed.splitn(2, '=');
772 let key = parts.next().map(str::trim);
773 let Some(value) = parts.next() else {
774 continue;
775 };
776 if key == Some("version") {
777 return Some(value.trim().trim_matches('"').to_owned());
778 }
779 }
780 None
781}
782
783pub(crate) fn current_version(toml: &str) -> Result<String> {
788 version_in_table(toml, "[package]")
789 .or_else(|| version_in_table(toml, "[workspace.package]"))
790 .context("no `version` field found under `[package]` or `[workspace.package]`")
791}
792
793async fn branch_version(repo: &Path, remote: &str, branch: &str) -> Option<String> {
797 let from_name = branch.strip_prefix("chore/release-v").map(str::to_owned);
798 let tracking = format!("refs/remotes/{remote}/{branch}");
801 let spec = format!("+refs/heads/{branch}:{tracking}");
802 let fetched = git::git_raw(repo, &["fetch", "--quiet", remote, &spec]).await;
803 if fetched.is_ok_and(|o| o.code == Some(0))
804 && let Ok(out) = git::git_raw(repo, &["show", &format!("{tracking}:Cargo.toml")]).await
805 && out.code == Some(0)
806 && let Ok(v) = current_version(&out.stdout)
807 {
808 return Some(v);
809 }
810 from_name
811}
812
813pub fn decision_prompt(
823 subject: &str,
824 instruction: &str,
825 diffstat: &str,
826 files: &[String],
827 current_version: &str,
828) -> String {
829 let mut s = format!(
830 "A pull request just merged into the base branch. Decide which digit \
831 of this project's `major.minor.patch` version this change earns, so \
832 a release bump can be opened for exactly it.\n\n\
833 Current version: {current_version}\n\n\
834 # Merge subject\n\n{subject}\n\n\
835 # The task that produced it\n\n{instruction}\n\n\
836 # Files changed ({} total)\n\n",
837 files.len()
838 );
839 const MAX_FILES: usize = 50;
840 for f in files.iter().take(MAX_FILES) {
841 let _ = writeln!(s, "- {f}");
842 }
843 if files.len() > MAX_FILES {
844 let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
845 }
846 let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
847
848 s.push_str(
849 "\n# How to decide\n\n\
850 This project is below version `1.0.0`. At that stage **`minor` is \
851 the digit that carries a breaking change** - do not spend `major` \
852 below `1.0.0`.\n\n\
853 A change is breaking, and earns `minor`, when it changes any of: \
854 the public API reachable from `src/lib.rs`, a CLI subcommand or \
855 flag, an HTTP API route or response shape, a configuration key, or \
856 the on-disk shape of persisted state.\n\n\
857 A user-visible new capability that breaks none of the above also \
858 earns `minor`.\n\n\
859 A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
860 **When it is not obvious which digit applies, choose the larger \
861 one.** An oversized bump costs nothing; a breaking change shipped as \
862 `patch` breaks every downstream update that pins a range.\n\n\
863 # Output\n\n\
864 Reply with exactly one fenced JSON object and nothing that matters \
865 outside it:\n\n\
866 ```json\n\
867 {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
868 ```\n",
869 );
870 let _ = write!(
872 s,
873 "\n{}\n\nThe `reason` goes into a GitHub pull request body, so write it \
874 in English.\n",
875 crate::prompt::GITHUB_ENGLISH_HEADING
876 );
877 s
878}
879
880#[derive(Debug, Clone, Serialize, Deserialize)]
896pub struct PendingBump {
897 pub target_version: String,
899 pub level: BumpLevel,
902 pub branch: String,
905 pub pr_url: String,
908}
909
910pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
914 let key = repo.to_string_lossy();
915 home.join("bump")
916 .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
917}
918
919pub fn read_marker(path: &Path) -> Option<PendingBump> {
923 let body = std::fs::read_to_string(path).ok()?;
924 serde_json::from_str(&body).ok()
925}
926
927pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
931 if let Some(parent) = path.parent() {
932 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
933 }
934 let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
935 let tmp = path.with_extension("json.tmp");
936 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
937 std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
938 Ok(())
939}
940
941pub fn clear_marker(path: &Path) {
944 let _ = std::fs::remove_file(path);
945}
946
947#[derive(Debug, Clone, PartialEq, Eq)]
951pub enum Coalesce {
952 Proceed,
955 Skip {
957 target_version: String,
959 },
960}
961
962pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
964 let Some(pending) = pending else {
965 return Ok(Coalesce::Proceed);
966 };
967 let current = Version::parse(current_version)?;
968 let target = Version::parse(&pending.target_version)?;
969 if current >= target {
970 return Ok(Coalesce::Proceed);
971 }
972 Ok(Coalesce::Skip {
973 target_version: pending.target_version.clone(),
974 })
975}
976
977#[derive(Debug, Clone, Copy, PartialEq, Eq)]
979pub enum PendingAction {
980 AlreadyCovered,
983 Escalate,
986}
987
988pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
997 if decision_level.severity() > pending_level.severity() {
998 PendingAction::Escalate
999 } else {
1000 PendingAction::AlreadyCovered
1001 }
1002}
1003
1004fn parse_pr_state(json: &str) -> Result<bool> {
1006 #[derive(Deserialize)]
1007 struct State {
1008 state: String,
1009 }
1010 let parsed: State =
1011 serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
1012 Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
1013}
1014
1015async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
1028 let out = tokio::process::Command::new("gh")
1029 .args(["pr", "view", pr_url, "--json", "state"])
1030 .current_dir(repo)
1031 .quiet()
1032 .stdin(std::process::Stdio::null())
1033 .output()
1034 .await
1035 .context("spawn gh pr view")?;
1036 if !out.status.success() {
1037 bail!(
1038 "gh pr view {pr_url}: {}",
1039 String::from_utf8_lossy(&out.stderr).trim()
1040 );
1041 }
1042 parse_pr_state(&String::from_utf8_lossy(&out.stdout))
1043}
1044
1045const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
1053
1054struct MarkerLock {
1066 path: PathBuf,
1067}
1068
1069impl MarkerLock {
1070 #[cfg(test)]
1074 fn acquire(marker: &Path) -> Result<Option<Self>> {
1075 Self::acquire_with(marker, crate::lock_file::Retry::new(None))
1076 }
1077
1078 fn acquire_with(marker: &Path, retry: crate::lock_file::Retry) -> Result<Option<Self>> {
1079 let path = marker.with_extension("lock");
1080 if let Some(parent) = path.parent() {
1081 std::fs::create_dir_all(parent)
1082 .with_context(|| format!("create {}", parent.display()))?;
1083 }
1084 if Self::try_create(&path, retry)? {
1085 return Ok(Some(Self { path }));
1086 }
1087 if Self::is_stale(&path) {
1088 let _ = std::fs::remove_file(&path);
1089 if Self::try_create(&path, retry)? {
1090 return Ok(Some(Self { path }));
1091 }
1092 }
1093 Ok(None)
1094 }
1095
1096 fn try_create(path: &Path, retry: crate::lock_file::Retry) -> Result<bool> {
1097 match retry.create(path) {
1098 Ok(_) => Ok(true),
1099 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1100 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
1101 }
1102 }
1103
1104 fn is_stale(path: &Path) -> bool {
1105 std::fs::metadata(path)
1106 .and_then(|m| m.modified())
1107 .ok()
1108 .and_then(|m| m.elapsed().ok())
1109 .is_some_and(|age| age >= LOCK_STALE_AFTER)
1110 }
1111}
1112
1113impl Drop for MarkerLock {
1114 fn drop(&mut self) {
1115 let _ = std::fs::remove_file(&self.path);
1116 }
1117}
1118
1119const LOCK_POLL: Duration = Duration::from_secs(5);
1121
1122const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
1135
1136async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
1139 wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
1140}
1141
1142async fn wait_for_marker_lock_with(
1146 marker: &Path,
1147 poll: Duration,
1148 ceiling: Duration,
1149) -> Result<Option<MarkerLock>> {
1150 let deadline = std::time::Instant::now() + ceiling;
1151 let retry = crate::lock_file::Retry::new(Some(deadline));
1152 loop {
1153 let marker = marker.to_owned();
1155 let acquired =
1156 tokio::task::spawn_blocking(move || MarkerLock::acquire_with(&marker, retry))
1157 .await
1158 .context("join marker lock acquisition")??;
1159 if let Some(lock) = acquired {
1160 return Ok(Some(lock));
1161 }
1162 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1163 if remaining.is_zero() {
1164 return Ok(None);
1165 }
1166 tokio::time::sleep(poll.min(remaining)).await;
1167 }
1168}
1169
1170fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
1178 if to.major != from.major {
1179 Some(BumpLevel::Major)
1180 } else if to.minor != from.minor {
1181 Some(BumpLevel::Minor)
1182 } else if to.patch != from.patch {
1183 Some(BumpLevel::Patch)
1184 } else {
1185 None
1186 }
1187}
1188
1189fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
1192 #[derive(Deserialize)]
1193 struct Pr {
1194 url: String,
1195 #[serde(rename = "headRefName")]
1196 head_ref_name: String,
1197 }
1198 let list: Vec<Pr> =
1199 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
1200 Ok(list
1201 .into_iter()
1202 .find(|p| p.head_ref_name.starts_with("chore/release-v"))
1203 .map(|p| (p.head_ref_name, p.url)))
1204}
1205
1206pub(crate) fn parse_open_release_prs(json: &str) -> Result<Vec<(String, String)>> {
1209 #[derive(Deserialize)]
1210 struct Pr {
1211 url: String,
1212 #[serde(rename = "headRefName")]
1213 head_ref_name: String,
1214 }
1215 let list: Vec<Pr> =
1216 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
1217 Ok(list
1218 .into_iter()
1219 .filter(|p| p.head_ref_name.starts_with("chore/release-v"))
1220 .map(|p| (p.head_ref_name, p.url))
1221 .collect())
1222}
1223
1224pub(crate) async fn list_open_release_prs(repo: &Path) -> Result<Vec<(String, String)>> {
1226 let out = tokio::process::Command::new("gh")
1227 .args([
1228 "pr",
1229 "list",
1230 "--state",
1231 "open",
1232 "--limit",
1233 "100",
1234 "--json",
1235 "url,headRefName",
1236 ])
1237 .current_dir(repo)
1238 .env_remove("GH_REPO")
1239 .quiet()
1240 .stdin(std::process::Stdio::null())
1241 .output()
1242 .await
1243 .context("spawn gh pr list")?;
1244 if !out.status.success() {
1245 bail!(
1246 "gh pr list: {}",
1247 String::from_utf8_lossy(&out.stderr).trim()
1248 );
1249 }
1250 parse_open_release_prs(&String::from_utf8_lossy(&out.stdout))
1251}
1252
1253async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
1268 let out = tokio::process::Command::new("gh")
1269 .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
1270 .current_dir(repo)
1271 .quiet()
1272 .stdin(std::process::Stdio::null())
1273 .output()
1274 .await
1275 .context("spawn gh pr list")?;
1276 if !out.status.success() {
1277 bail!(
1278 "gh pr list: {}",
1279 String::from_utf8_lossy(&out.stderr).trim()
1280 );
1281 }
1282 parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
1283}
1284
1285pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
1305 after_merge_at(state, pr_url, None, None).await
1306}
1307
1308#[derive(Debug, Default)]
1310struct Progress {
1311 version: Option<String>,
1313 pending_pr: Option<String>,
1315}
1316
1317fn notice_for_failure(progress: &Progress, err: &anyhow::Error) -> (Option<String>, String) {
1323 let chain = format!("{err:#}");
1324 let first = chain.lines().next().unwrap_or_default().trim();
1325 (progress.version.clone(), first.to_owned())
1326}
1327
1328async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
1334 let out = git::git(
1335 repo,
1336 &[
1337 "ls-tree",
1338 "--name-only",
1339 &format!("{remote}/{base}"),
1340 "--",
1341 "Cargo.toml",
1342 ],
1343 )
1344 .await
1345 .context("look for Cargo.toml on the base branch")?;
1346 Ok(!out.trim().is_empty())
1347}
1348
1349async fn after_merge_at(
1353 state: &mut RunState,
1354 pr_url: &str,
1355 home: Option<&Path>,
1356 store: Option<&Notices>,
1357) -> Result<()> {
1358 let mut progress = Progress::default();
1359 let result = after_merge_inner(state, pr_url, home, &mut progress).await;
1360 if let Err(e) = &result {
1361 let (version, reason) = notice_for_failure(&progress, e);
1362 let pending = progress.pending_pr.as_deref();
1363 let store = store.cloned().unwrap_or_else(Notices::open);
1364 report_problem_in(
1365 state,
1366 &store,
1367 pending,
1368 version.as_deref(),
1369 &reason,
1370 pending.is_some(),
1371 )
1372 .await;
1373 }
1374 result
1375}
1376
1377async fn after_merge_inner(
1378 state: &mut RunState,
1379 pr_url: &str,
1380 home: Option<&Path>,
1381 progress: &mut Progress,
1382) -> Result<()> {
1383 if !state.config.merge.release_bump {
1384 return Ok(());
1385 }
1386 let Some(winner) = state.winner().cloned() else {
1387 return Ok(());
1388 };
1389 let repo = state.repo.clone();
1390 let base = state.base_branch.clone();
1391 let remote = state.config.merge.remote.clone();
1392
1393 let files = git::changed_files(&winner.worktree, &base, &winner.branch)
1394 .await
1395 .unwrap_or_default();
1396 if is_release_only(&files) {
1397 state.event(
1398 "bump",
1399 "the merged change touches only the release manifest; not treating it as a trigger",
1400 );
1401 return Ok(());
1402 }
1403
1404 git::fetch(&repo, &remote, &base).await.ok();
1407 if !base_has_cargo_toml(&repo, &remote, &base).await? {
1408 state.event(
1409 "bump",
1410 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping",
1411 );
1412 return Ok(());
1413 }
1414
1415 let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
1416 let Some(_lock) = wait_for_marker_lock(&marker).await? else {
1423 state.event(
1424 "bump",
1425 "another release bump decision held the lock past the wait ceiling; skipping this round",
1426 );
1427 return Ok(());
1428 };
1429
1430 git::fetch(&repo, &remote, &base).await.ok();
1431 let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
1432 .await
1433 .context("read Cargo.toml from the base branch")?;
1434 let base_version = current_version(&cargo_toml)?;
1435
1436 let mut pending = read_marker(&marker);
1437 if let Some(p) = &pending {
1438 match coalesce(Some(p), &base_version)? {
1439 Coalesce::Proceed => {
1440 clear_marker(&marker);
1443 pending = None;
1444 }
1445 Coalesce::Skip { target_version } => {
1446 if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
1447 state.event(
1448 "bump",
1449 format!(
1450 "the pending release bump to v{target_version} ({}) is no longer \
1451 open; treating it as abandoned",
1452 p.pr_url
1453 ),
1454 );
1455 clear_marker(&marker);
1456 pending = None;
1457 }
1458 }
1463 }
1464 }
1465
1466 if pending.is_none() {
1467 if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
1472 && let Some(target) = branch_version(&repo, &remote, &branch)
1473 .await
1474 .and_then(|v| Version::parse(&v).ok())
1475 {
1476 let base_parsed = Version::parse(&base_version)?;
1477 if target > base_parsed
1478 && let Some(level) = level_between(base_parsed, target)
1479 {
1480 let adopted = PendingBump {
1481 target_version: target.to_string(),
1482 level,
1483 branch,
1484 pr_url: url,
1485 };
1486 let _ = write_marker(&marker, &adopted);
1489 pending = Some(adopted);
1490 }
1491 }
1492 }
1493
1494 progress.pending_pr = pending.as_ref().map(|p| p.pr_url.clone());
1495 let title = pr_title(&repo, pr_url).await.unwrap_or_default();
1496 let subject = land::merge_subject(
1497 crate::graph::landing_title(state, &title),
1498 &crate::graph::landing_subject_source(state),
1499 );
1500 let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
1501 .await
1502 .unwrap_or_default();
1503 let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
1504
1505 let (spec, mut seat, mut decision) =
1506 ask_decision(state, &repo, &prompt, DECISION_TIMEOUT).await?;
1507 guard_reason(state, &repo, &prompt, &spec, &mut seat, &mut decision).await;
1508
1509 if let Some(p) = pending {
1510 return match pending_action(p.level, decision.level) {
1511 PendingAction::AlreadyCovered => {
1512 state.event(
1513 "bump",
1514 format!(
1515 "a release bump to v{} ({}) already covers at least a {} change; not \
1516 opening another",
1517 p.target_version,
1518 p.pr_url,
1519 decision.level.as_str()
1520 ),
1521 );
1522 Ok(())
1523 }
1524 PendingAction::Escalate => {
1525 progress.version = Some(
1526 Version::parse(&base_version)?
1527 .bump(decision.level)
1528 .to_string(),
1529 );
1530 escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
1531 }
1532 };
1533 }
1534
1535 let next = Version::parse(&base_version)?
1536 .bump(decision.level)
1537 .to_string();
1538 progress.version = Some(next.clone());
1539 let branch = format!("chore/release-v{next}");
1540 let worktree = state.dir().join("bump");
1541 let (shared, branch_ref, next_ref, decision_ref) = (&mut *state, &branch, &next, &decision);
1542 let (pr_url_opened, outcome) =
1543 release_attempt(
1544 &repo,
1545 &remote,
1546 &base,
1547 &worktree,
1548 &branch,
1549 |head| {
1550 let repo = repo.clone();
1551 async move { gh_open_pr_for_head(&repo, &head).await }
1552 },
1553 |wt| async move {
1554 open_bump_pr(shared, &wt, branch_ref, next_ref, decision_ref, pr_url).await
1555 },
1556 )
1557 .await?;
1558 let local = outcome == AutomergeOutcome::LocalGate;
1559 let (automerge_warning, merged_detail) = match outcome {
1560 AutomergeOutcome::Enabled | AutomergeOutcome::LocalGate => (None, None),
1561 AutomergeOutcome::MergedDirectly { detail } => (None, Some(detail)),
1562 AutomergeOutcome::Failed { reason } => (Some(reason), None),
1563 };
1564
1565 let marker_write = write_marker(
1574 &marker,
1575 &PendingBump {
1576 target_version: next.clone(),
1577 level: decision.level,
1578 branch,
1579 pr_url: pr_url_opened.clone(),
1580 },
1581 );
1582 state.event(
1583 "bump",
1584 format!(
1585 "opened a {} release bump to v{next} ({}): {pr_url_opened}",
1586 decision.level.as_str(),
1587 decision.reason
1588 ),
1589 );
1590 if let Err(e) = marker_write {
1591 state.event(
1592 "bump",
1593 format!(
1594 "could not record the pending release bump marker for v{next}: {e:#}; a later \
1595 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
1596 the forge either"
1597 ),
1598 );
1599 }
1600 state.release_bump = Some(run::ReleaseBump {
1601 pr_url: Some(pr_url_opened.clone()),
1602 version: Some(next.clone()),
1603 automerge_enabled: automerge_warning.is_none() && merged_detail.is_none() && !local,
1604 merged_directly: merged_detail.is_some(),
1605 local,
1606 ..run::ReleaseBump::default()
1607 });
1608 if let Some(detail) = merged_detail {
1609 clear_marker(&marker);
1612 state.event("bump", format!("merged v{next} directly: {detail}"));
1613 }
1614 if let Some(warning) = automerge_warning {
1615 state.event(
1616 "bump",
1617 format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
1618 );
1619 report_problem(state, Some(&pr_url_opened), Some(&next), &warning).await;
1620 }
1621 Ok(())
1622}
1623
1624pub const NOTICE_NODE: &str = "release-bump";
1629
1630fn automerge_hint(reason: &str) -> &'static str {
1637 let r = reason.to_lowercase();
1638 if is_clean_status_refusal(reason) {
1639 "merge the release pull request by hand; CI is already green"
1640 } else if r.contains("enablepullrequestautomerge") || r.contains("protected branch rules") {
1641 "merge the release pull request by hand, and enable branch protection with required \
1642 status checks on the base branch so automerge can work next time"
1643 } else {
1644 "merge the release pull request by hand"
1645 }
1646}
1647
1648fn automerge_failure_comment(reason: &str) -> String {
1650 format!(
1651 "magi could not enable automerge on this pull request: {reason}\n\n\
1652 Action required: {}. Until then the release does not happen.",
1653 automerge_hint(reason)
1654 )
1655}
1656
1657#[cfg(test)]
1665fn surface_problem(
1666 state: &mut RunState,
1667 store: &Notices,
1668 pr_url: Option<&str>,
1669 version: Option<&str>,
1670 reason: &str,
1671) -> Result<(Notice, Option<String>)> {
1672 surface_problem_in(state, store, pr_url, version, reason, false)
1673}
1674
1675fn surface_problem_in(
1679 state: &mut RunState,
1680 store: &Notices,
1681 pr_url: Option<&str>,
1682 version: Option<&str>,
1683 reason: &str,
1684 pending: bool,
1685) -> Result<(Notice, Option<String>)> {
1686 let action = if let (true, Some(url)) = (pending, pr_url) {
1687 let ja = crate::lang::is_japanese(&state.config.graph.language);
1688 let first = reason.lines().next().filter(|l| !l.is_empty());
1689 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1690 if ja {
1691 let target = version.map(|v| format!(" v{v}")).unwrap_or_default();
1692 format!(
1693 "既存のリリース PR {url} を{target}へ更新できませんでした{cause}。PR を手で更新してください"
1694 )
1695 } else {
1696 let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1697 format!(
1698 "the pending release pull request {url} could not be updated{target}{cause}; update it by hand"
1699 )
1700 }
1701 } else if pr_url.is_some() {
1702 automerge_hint(reason).to_owned()
1703 } else {
1704 let ja = crate::lang::is_japanese(&state.config.graph.language);
1705 let first = reason.lines().next().filter(|l| !l.is_empty());
1706 if ja {
1707 let target = version.map(|v| format!(" (v{v})")).unwrap_or_default();
1708 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1709 format!(
1710 "リリースバンプ{target}は実行されませんでした{cause}。リリース PR を手で開いてください"
1711 )
1712 } else {
1713 let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1714 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1715 format!(
1716 "the release bump{target} did not run{cause}; open the release pull request by hand"
1717 )
1718 }
1719 };
1720 let record = state.release_bump.get_or_insert_with(Default::default);
1721 record.pr_url = pr_url.map(str::to_owned).or(record.pr_url.take());
1722 record.version = version.map(str::to_owned).or(record.version.take());
1723 record.automerge_enabled = false;
1724 record.problem = Some(reason.to_owned());
1725 record.action_required = Some(action.clone());
1726
1727 let mut notice = Notice::error(
1728 &format!("release-bump:{}", state.id),
1729 format!(
1730 "Run {} merged, but its release step failed: {action}.",
1731 state.id
1732 ),
1733 );
1734 notice = match pr_url {
1735 Some(url) => notice.link(Link::Url {
1736 url: url.to_owned(),
1737 }),
1738 None => notice.link(Link::Run {
1739 id: state.id.clone(),
1740 }),
1741 };
1742 let notice = store
1743 .raise(notice)
1744 .context("raise the release-bump notification")?;
1745 state.event("bump", format!("needs attention: {action}"));
1746 let comment = pr_url
1747 .filter(|_| !pending)
1748 .map(|_| automerge_failure_comment(reason));
1749 Ok((notice, comment))
1750}
1751
1752pub async fn report_problem(
1757 state: &mut RunState,
1758 pr_url: Option<&str>,
1759 version: Option<&str>,
1760 reason: &str,
1761) {
1762 report_problem_in(state, &Notices::open(), pr_url, version, reason, false).await;
1763}
1764
1765async fn report_problem_in(
1767 state: &mut RunState,
1768 store: &Notices,
1769 pr_url: Option<&str>,
1770 version: Option<&str>,
1771 reason: &str,
1772 pending: bool,
1773) {
1774 match surface_problem_in(state, store, pr_url, version, reason, pending) {
1775 Ok((notice, comment)) => {
1776 if let (Some(url), Some(body)) = (pr_url, comment)
1777 && let Err(e) = gh_pr_comment(state, url, &body).await
1778 {
1779 state.event("bump", format!("could not comment on {url}: {e:#}"));
1780 }
1781 let summary = match pr_url {
1784 Some(url) if pending => format!("Release PR could not be updated: {url}"),
1785 Some(url) => format!("Release PR needs a human: {url}"),
1786 None if crate::lang::is_japanese(&state.config.graph.language) => {
1787 "リリースバンプが実行されませんでした".to_owned()
1788 }
1789 None => "Release bump did not run".to_owned(),
1790 };
1791 let q = ask::Question::new(
1792 state.id.clone(),
1793 NOTICE_NODE.to_owned(),
1794 "bump".to_owned(),
1795 summary,
1796 notice.message.clone(),
1797 Vec::new(),
1798 );
1799 if let Err(e) = ask::notify(&state.config.notify, &q).await {
1800 tracing::warn!(
1801 "could not notify about the release bump of {}: {e:#}",
1802 state.id
1803 );
1804 }
1805 }
1806 Err(e) => state.event("bump", format!("could not raise a notice: {e:#}")),
1807 }
1808}
1809
1810pub(crate) async fn gh_pr_comment(state: &mut RunState, pr_url: &str, body: &str) -> Result<()> {
1811 let (_, body) = crate::github_text::prepare(state, "", body);
1812 let cwd = &state.repo;
1813 let out = tokio::process::Command::new("gh")
1814 .args(["pr", "comment", pr_url, "--body", &body])
1815 .current_dir(cwd)
1816 .quiet()
1817 .stdin(std::process::Stdio::null())
1818 .output()
1819 .await
1820 .context("spawn gh pr comment")?;
1821 if out.status.success() {
1822 Ok(())
1823 } else {
1824 bail!(
1825 "gh pr comment: {}",
1826 String::from_utf8_lossy(&out.stderr).trim()
1827 )
1828 }
1829}
1830
1831async fn escalate_pending(
1840 state: &mut RunState,
1841 repo: &Path,
1842 remote: &str,
1843 pending: &PendingBump,
1844 decision: &BumpDecision,
1845 base_version: &str,
1846 marker: &Path,
1847) -> Result<()> {
1848 let next = Version::parse(base_version)?
1849 .bump(decision.level)
1850 .to_string();
1851 let worktree = state.dir().join("bump");
1852 git::worktree_remove(repo, &worktree).await.ok();
1853 let checked_out = git::git_raw(
1854 repo,
1855 &[
1856 "worktree",
1857 "add",
1858 "--force",
1859 &worktree.to_string_lossy(),
1860 &pending.branch,
1861 ],
1862 )
1863 .await?;
1864 if !checked_out.ok() {
1865 bail!(
1866 "checking out the pending release branch {} failed: {}",
1867 pending.branch,
1868 checked_out.stderr
1869 );
1870 }
1871
1872 let pushed: Result<()> = async {
1877 let cargo_toml_path = worktree.join("Cargo.toml");
1878 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1879 .await
1880 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1881 let rewritten = rewrite_cargo_version(&toml, &next)?;
1882 tokio::fs::write(&cargo_toml_path, rewritten)
1883 .await
1884 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1885 sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1886 let committed = git::commit_all(
1887 &worktree,
1888 &format!(
1889 "chore: release v{next} (supersedes v{})",
1890 pending.target_version
1891 ),
1892 )
1893 .await
1894 .context("commit the escalated version bump")?;
1895 if !committed {
1896 bail!("escalating the version bump left nothing to commit");
1897 }
1898 let pushed = git::push(&worktree, remote, &pending.branch).await?;
1899 if !pushed.ok() {
1900 bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1901 }
1902 Ok(())
1903 }
1904 .await;
1905 if let Err(e) = pushed {
1906 git::worktree_remove(repo, &worktree).await.ok();
1907 return Err(e);
1908 }
1909
1910 let (title, _) = crate::github_text::prepare(
1914 state,
1915 &format!("chore: release v{next} ({} bump)", decision.level.as_str()),
1916 "",
1917 );
1918 let title_warning = match gh_pr_edit_title(&worktree, &pending.pr_url, &title).await {
1919 Ok(()) => None,
1920 Err(e) => Some(e.to_string()),
1921 };
1922 git::worktree_remove(repo, &worktree).await.ok();
1923
1924 let marker_write = write_marker(
1925 marker,
1926 &PendingBump {
1927 target_version: next.clone(),
1928 level: decision.level,
1929 branch: pending.branch.clone(),
1930 pr_url: pending.pr_url.clone(),
1931 },
1932 );
1933 state.event(
1934 "bump",
1935 format!(
1936 "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1937 pending.target_version,
1938 decision.level.as_str(),
1939 decision.reason,
1940 pending.pr_url
1941 ),
1942 );
1943 if let Err(e) = marker_write {
1944 state.event(
1945 "bump",
1946 format!(
1947 "could not update the pending release bump marker to v{next}: {e:#}; a later \
1948 merge may misjudge whether it is already covered"
1949 ),
1950 );
1951 }
1952 if let Some(warning) = title_warning {
1953 state.event(
1954 "bump",
1955 format!(
1956 "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1957 subject may still read the superseded version",
1958 pending.pr_url
1959 ),
1960 );
1961 }
1962 Ok(())
1963}
1964
1965async fn gh_open_pr_for_head(repo: &Path, branch: &str) -> Result<bool> {
1968 let out = tokio::process::Command::new("gh")
1969 .args([
1970 "pr", "list", "--head", branch, "--state", "open", "--json", "url",
1971 ])
1972 .current_dir(repo)
1973 .quiet()
1974 .stdin(std::process::Stdio::null())
1975 .output()
1976 .await
1977 .context("spawn gh pr list")?;
1978 if !out.status.success() {
1979 bail!(
1980 "gh pr list --head {branch}: {}",
1981 String::from_utf8_lossy(&out.stderr).trim()
1982 );
1983 }
1984 let prs: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout)
1985 .with_context(|| format!("parse the pull requests headed by {branch}"))?;
1986 Ok(!prs.is_empty())
1987}
1988
1989async fn release_attempt<T, F, Fut, P, PFut>(
1998 repo: &Path,
1999 remote: &str,
2000 base: &str,
2001 worktree: &Path,
2002 branch: &str,
2003 open_pr: P,
2004 fill: F,
2005) -> Result<T>
2006where
2007 F: FnOnce(PathBuf) -> Fut,
2008 Fut: std::future::Future<Output = Result<T>>,
2009 P: Fn(String) -> PFut,
2010 PFut: std::future::Future<Output = Result<bool>>,
2011{
2012 let start = format!("{remote}/{base}");
2013 git::worktree_remove(repo, worktree).await.ok();
2014 let mut retried = false;
2015 if git::branch_exists(repo, branch).await? {
2016 reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await?;
2017 retried = true;
2018 }
2019 loop {
2020 if let Err(e) = git::git(repo, &["branch", branch, &start]).await {
2025 let e = e.context("create the release branch");
2026 if !git::branch_exists(repo, branch).await.unwrap_or(false) {
2027 return Err(e);
2028 }
2029 if retried {
2031 return Err(anyhow!(
2032 "{e:#}; left {branch} in place: it is not one this attempt created"
2033 ));
2034 }
2035 retried = true;
2036 if let Err(r) = reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await {
2037 return Err(anyhow!("{r:#} (after: {e:#})"));
2038 }
2039 continue;
2040 }
2041 if let Some(parent) = worktree.parent() {
2042 tokio::fs::create_dir_all(parent).await.ok();
2043 }
2044 let path = worktree.to_string_lossy();
2045 if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
2046 let e = e.context("create the release-bump worktree");
2047 return Err(discard_attempt(repo, remote, worktree, branch, e).await);
2048 }
2049 break;
2050 }
2051 match fill(worktree.to_path_buf()).await {
2052 Ok(v) => {
2053 git::worktree_remove(repo, worktree).await.ok();
2056 Ok(v)
2057 }
2058 Err(e) => Err(discard_attempt(repo, remote, worktree, branch, e).await),
2059 }
2060}
2061
2062async fn discard_attempt(
2064 repo: &Path,
2065 remote: &str,
2066 worktree: &Path,
2067 branch: &str,
2068 cause: anyhow::Error,
2069) -> anyhow::Error {
2070 git::worktree_remove(repo, worktree).await.ok();
2071 if !git::branch_exists(repo, branch).await.unwrap_or(false) {
2072 return cause;
2073 }
2074 let note = match git::remote_has_branch(repo, remote, branch).await {
2077 Ok(false) => match git::branch_delete(repo, branch).await {
2078 Ok(true) => return cause,
2079 _ => format!("could not delete the local branch {branch}"),
2080 },
2081 Ok(true) => format!("left {branch} in place: it was pushed to {remote}"),
2082 Err(e) => format!(
2083 "left {branch} in place: could not tell whether it was pushed to {remote} ({e:#})"
2084 ),
2085 };
2086 anyhow!("{cause:#}; {note}")
2087}
2088
2089async fn reclaim_stale_branch<P, PFut>(
2094 repo: &Path,
2095 remote: &str,
2096 start: &str,
2097 branch: &str,
2098 open_pr: &P,
2099) -> Result<()>
2100where
2101 P: Fn(String) -> PFut,
2102 PFut: std::future::Future<Output = Result<bool>>,
2103{
2104 match git::remote_has_branch(repo, remote, branch).await {
2105 Ok(false) => {}
2106 Ok(true) => bail!("the branch {branch} already exists and is on {remote}; left alone"),
2107 Err(e) => bail!(
2108 "the branch {branch} already exists and could not check whether {remote} has it ({e:#}); left alone"
2109 ),
2110 }
2111 match open_pr(branch.to_string()).await {
2112 Ok(false) => {}
2113 Ok(true) => {
2114 bail!("the branch {branch} already exists and has an open pull request; left alone")
2115 }
2116 Err(e) => bail!(
2117 "the branch {branch} already exists and could not check for an open pull request \
2118 ({e:#}); left alone"
2119 ),
2120 }
2121 match git::commits_ahead(repo, start, branch).await {
2122 Ok(0) => {}
2123 Ok(n) => bail!(
2124 "the branch {branch} already exists with {n} commit(s) not in {start}; left alone"
2125 ),
2126 Err(e) => bail!(
2127 "the branch {branch} already exists and could not compare it with {start} ({e:#}); \
2128 left alone"
2129 ),
2130 }
2131 if let Some(held) = git::worktree_holding(repo, branch).await? {
2132 let same = |a: &Path, b: &Path| match (a.canonicalize(), b.canonicalize()) {
2133 (Ok(a), Ok(b)) => a == b,
2134 _ => a == b,
2135 };
2136 if same(&held, repo) {
2137 bail!(
2138 "the branch {branch} already exists and is checked out in the main checkout; left alone"
2139 );
2140 }
2141 git::worktree_remove(repo, &held).await.ok();
2142 }
2143 if !git::branch_delete(repo, branch).await? {
2144 bail!("the branch {branch} already exists and could not be deleted; left alone");
2145 }
2146 Ok(())
2147}
2148
2149async fn open_bump_pr(
2155 state: &mut RunState,
2156 worktree: &Path,
2157 branch: &str,
2158 next_version: &str,
2159 decision: &BumpDecision,
2160 source_pr_url: &str,
2161) -> Result<(String, AutomergeOutcome)> {
2162 let cargo_toml_path = worktree.join("Cargo.toml");
2163 let toml = tokio::fs::read_to_string(&cargo_toml_path)
2164 .await
2165 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
2166 let rewritten = rewrite_cargo_version(&toml, next_version)?;
2167 tokio::fs::write(&cargo_toml_path, rewritten)
2168 .await
2169 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
2170
2171 sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
2172
2173 let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
2174 .await
2175 .context("commit the version bump")?;
2176 if !committed {
2177 bail!("the version bump left nothing to commit");
2178 }
2179
2180 let remote = state.config.merge.remote.clone();
2181 let pushed = git::push(worktree, &remote, branch).await?;
2182 if !pushed.ok() {
2183 bail!("pushing {branch} failed: {}", pushed.stderr);
2184 }
2185
2186 let (title, body) = release_pr(
2187 decision.level.as_str(),
2188 &decision.reason,
2189 next_version,
2190 &state.id,
2191 source_pr_url,
2192 );
2193 let (title, body) = crate::github_text::prepare(state, &title, &body);
2194 let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
2195 if state.config.release.is_local() {
2199 crate::release_watch::register(&crate::run::home(), &state.repo, &url, &state.id);
2202 return Ok((url, AutomergeOutcome::LocalGate));
2203 }
2204 let outcome = match gh_enable_automerge(worktree, &url).await {
2205 Ok(()) => AutomergeOutcome::Enabled,
2206 Err(e) => {
2207 let reason = e.to_string();
2208 if is_clean_status_refusal(&reason) {
2209 gh_merge_directly(worktree, &url, &title, reason).await
2210 } else {
2211 AutomergeOutcome::Failed { reason }
2212 }
2213 }
2214 };
2215 Ok((url, outcome))
2216}
2217
2218#[derive(Debug, Clone, PartialEq, Eq)]
2220enum AutomergeOutcome {
2221 Enabled,
2223 MergedDirectly { detail: String },
2225 LocalGate,
2227 Failed { reason: String },
2229}
2230
2231fn is_clean_status_refusal(reason: &str) -> bool {
2237 let r = reason.to_lowercase();
2238 r.contains("is in clean status") && r.contains("enablepullrequestautomerge")
2239}
2240
2241pub(crate) fn bump_merge_argv(pr_url: &str, subject: &str) -> Vec<String> {
2244 [
2245 "pr",
2246 "merge",
2247 pr_url,
2248 "--squash",
2249 "--delete-branch",
2250 "--subject",
2251 subject,
2252 ]
2253 .map(str::to_owned)
2254 .to_vec()
2255}
2256
2257fn resolve_direct_merge(
2261 refusal: &str,
2262 argv: &[String],
2263 merge_ok: bool,
2264 stderr: &str,
2265 after: Option<land::PrLifecycle>,
2266) -> AutomergeOutcome {
2267 if merge_ok {
2268 return AutomergeOutcome::MergedDirectly {
2269 detail: format!("automerge was refused ({refusal}); gh {}", argv.join(" ")),
2270 };
2271 }
2272 match land::merged_after_all(argv, stderr, after) {
2273 Some(m) => AutomergeOutcome::MergedDirectly { detail: m.detail },
2274 None => AutomergeOutcome::Failed {
2275 reason: format!("{refusal}; merging directly failed too: {}", stderr.trim()),
2276 },
2277 }
2278}
2279
2280async fn gh_merge_directly(
2284 cwd: &Path,
2285 pr_url: &str,
2286 subject: &str,
2287 refusal: String,
2288) -> AutomergeOutcome {
2289 let argv = bump_merge_argv(pr_url, subject);
2290 let out = match tokio::process::Command::new("gh")
2291 .args(&argv)
2292 .current_dir(cwd)
2293 .quiet()
2294 .stdin(std::process::Stdio::null())
2295 .output()
2296 .await
2297 {
2298 Ok(o) => o,
2299 Err(e) => {
2300 return AutomergeOutcome::Failed {
2301 reason: format!("{refusal}; could not spawn gh to merge directly: {e}"),
2302 };
2303 }
2304 };
2305 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
2306 let after = if out.status.success() {
2309 None
2310 } else {
2311 land::lifecycle(cwd, pr_url).await.ok()
2312 };
2313 resolve_direct_merge(&refusal, &argv, out.status.success(), &stderr, after)
2314}
2315
2316async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
2324 let mut cmd = tokio::process::Command::new("cargo");
2325 cmd.arg("build").current_dir(worktree).quiet();
2326 if let Some(dir) = cache_dir {
2327 cmd.env("CARGO_TARGET_DIR", dir);
2328 }
2329 let out = cmd
2330 .stdin(std::process::Stdio::null())
2331 .output()
2332 .await
2333 .context("spawn cargo build")?;
2334 if !out.status.success() {
2335 bail!(
2336 "cargo build failed while syncing Cargo.lock: {}",
2337 String::from_utf8_lossy(&out.stderr).trim()
2338 );
2339 }
2340 Ok(())
2341}
2342
2343fn release_pr(
2348 level: &str,
2349 reason: &str,
2350 next_version: &str,
2351 run_id: &str,
2352 source_pr_url: &str,
2353) -> (String, String) {
2354 let title = format!("chore: release v{next_version} ({level} bump)");
2355 let body = format!(
2356 "## Background\n\n\
2357 A change that was just merged is a `{level}` change, so the crate needs a new \
2358 release: {reason}\n\n\
2359 Triggered by magi run `{run_id}`, which landed {source}.\n\n\
2360 ## Change\n\n\
2361 Raises the package version to `v{next_version}` in `Cargo.toml`, with \
2362 `Cargo.lock` following it. Nothing else changes.\n\n\
2363 ## Risk\n\n\
2364 Version-bump-only, so there is nothing here for a reviewer to find. Merging \
2365 it starts the release pipeline (auto-tag, then the release workflow).",
2366 source = source_pr_url,
2367 );
2368 (title, body)
2369}
2370
2371async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
2373 let out = tokio::process::Command::new("gh")
2374 .args(["pr", "view", pr_url, "--json", "title"])
2375 .current_dir(repo)
2376 .quiet()
2377 .stdin(std::process::Stdio::null())
2378 .output()
2379 .await
2380 .context("spawn gh pr view")?;
2381 if !out.status.success() {
2382 bail!(
2383 "gh pr view {pr_url}: {}",
2384 String::from_utf8_lossy(&out.stderr).trim()
2385 );
2386 }
2387 #[derive(Deserialize)]
2388 struct Title {
2389 title: String,
2390 }
2391 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
2392 .context("parse `gh pr view --json title` output")?;
2393 Ok(parsed.title)
2394}
2395
2396async fn gh_pr_create(
2397 cwd: &Path,
2398 base: &str,
2399 head: &str,
2400 title: &str,
2401 body: &str,
2402) -> Result<String> {
2403 let out = tokio::process::Command::new("gh")
2404 .args([
2405 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
2406 ])
2407 .current_dir(cwd)
2408 .quiet()
2409 .stdin(std::process::Stdio::null())
2410 .output()
2411 .await
2412 .context("spawn gh pr create")?;
2413 if out.status.success() {
2414 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2415 } else {
2416 bail!(
2417 "gh pr create: {}",
2418 String::from_utf8_lossy(&out.stderr).trim()
2419 )
2420 }
2421}
2422
2423async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
2427 let out = tokio::process::Command::new("gh")
2428 .args([
2429 "pr",
2430 "merge",
2431 pr_url,
2432 "--auto",
2433 "--squash",
2434 "--delete-branch",
2435 ])
2436 .current_dir(cwd)
2437 .quiet()
2438 .stdin(std::process::Stdio::null())
2439 .output()
2440 .await
2441 .context("spawn gh pr merge --auto")?;
2442 if out.status.success() {
2443 Ok(())
2444 } else {
2445 bail!(
2446 "gh pr merge --auto: {}",
2447 String::from_utf8_lossy(&out.stderr).trim()
2448 )
2449 }
2450}
2451
2452async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
2456 let out = tokio::process::Command::new("gh")
2457 .args(["pr", "edit", pr_url, "--title", title])
2458 .current_dir(cwd)
2459 .quiet()
2460 .stdin(std::process::Stdio::null())
2461 .output()
2462 .await
2463 .context("spawn gh pr edit")?;
2464 if out.status.success() {
2465 Ok(())
2466 } else {
2467 bail!(
2468 "gh pr edit --title: {}",
2469 String::from_utf8_lossy(&out.stderr).trim()
2470 )
2471 }
2472}
2473
2474#[cfg(test)]
2475mod tests {
2476 use super::*;
2477 use crate::notices::Severity;
2478
2479 #[test]
2480 fn github_facing_bump_text_is_english() {
2481 let (title, body) =
2482 release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
2483 assert!(crate::github_text::check(&title, &body).is_empty());
2484 assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
2485 assert_eq!(title, "chore: release v0.37.0 (minor bump)");
2486 assert!(
2487 body.contains("## Background") && body.contains("## Change"),
2488 "{body}"
2489 );
2490 let p = decision_prompt("s", "i", "d", &[], "0.36.5");
2491 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
2492 }
2493 use crate::config::Config;
2494 use crate::land::PrLifecycle;
2495
2496 #[tokio::test]
2502 async fn a_disabled_config_does_nothing() {
2503 let config = Config {
2504 merge: crate::config::Merge {
2505 release_bump: false,
2506 ..crate::config::Merge::default()
2507 },
2508 ..Config::default()
2509 };
2510 let mut state = RunState::new(
2511 PathBuf::from("/no/such/repo"),
2512 "main".to_owned(),
2513 "0000000000000000000000000000000000000000".to_owned(),
2514 "irrelevant".to_owned(),
2515 config,
2516 );
2517 after_merge(&mut state, "https://example.invalid/pull/1")
2518 .await
2519 .expect("a disabled config must return Ok without touching anything");
2520 assert!(
2521 state.events.is_empty(),
2522 "nothing should happen at all, not even a logged event"
2523 );
2524 }
2525
2526 async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
2528 let dir = tempfile::tempdir().unwrap();
2529 let origin = dir.path().join("origin.git");
2530 let repo = dir.path().join("repo");
2531 let o = origin.to_string_lossy().into_owned();
2532 git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
2533 .await
2534 .unwrap();
2535 tokio::fs::create_dir_all(&repo).await.unwrap();
2536 git::git(&repo, &["init", "-b", "main"]).await.unwrap();
2537 git::git(&repo, &["config", "user.name", "test"])
2538 .await
2539 .unwrap();
2540 git::git(&repo, &["config", "user.email", "test@example.com"])
2541 .await
2542 .unwrap();
2543 for (name, body) in files {
2544 tokio::fs::write(repo.join(name), body).await.unwrap();
2545 }
2546 git::git(&repo, &["add", "-A"]).await.unwrap();
2547 git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
2548 git::git(&repo, &["remote", "add", "origin", &o])
2549 .await
2550 .unwrap();
2551 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
2552 (dir, repo)
2553 }
2554
2555 async fn no_pr(_: String) -> Result<bool> {
2556 Ok(false)
2557 }
2558
2559 async fn with_branch(ahead: bool) -> (tempfile::TempDir, PathBuf) {
2562 let (d, repo) = origin_with(&[("f", "x\n")]).await;
2563 git::git(&repo, &["branch", "b"]).await.unwrap();
2564 if ahead {
2565 git::git(&repo, &["checkout", "-q", "b"]).await.unwrap();
2566 git::git(&repo, &["commit", "--allow-empty", "-m", "wip"])
2567 .await
2568 .unwrap();
2569 git::git(&repo, &["checkout", "-q", "main"]).await.unwrap();
2570 }
2571 (d, repo)
2572 }
2573
2574 async fn attempt(repo: &Path, open_pr: bool, fail_after_push: Option<bool>) -> Result<()> {
2575 let wt = repo.parent().unwrap().join("bump");
2576 release_attempt(
2577 repo,
2578 "origin",
2579 "main",
2580 &wt,
2581 "b",
2582 |_| async move { Ok(open_pr) },
2583 |w| async move {
2584 if fail_after_push == Some(true) {
2585 git::push(&w, "origin", "b").await?;
2586 }
2587 if fail_after_push.is_some() {
2588 bail!("cargo build failed");
2589 }
2590 Ok(())
2591 },
2592 )
2593 .await
2594 }
2595
2596 #[tokio::test]
2597 async fn a_stale_ancestor_branch_is_deleted_and_the_attempt_proceeds() {
2598 let (_d, repo) = with_branch(false).await;
2599 attempt(&repo, false, None).await.unwrap();
2600 assert!(git::branch_exists(&repo, "b").await.unwrap());
2602 }
2603
2604 #[tokio::test]
2605 async fn a_stale_branch_holding_a_leftover_worktree_is_reclaimed() {
2606 let (_d, repo) = with_branch(false).await;
2607 let old = repo.parent().unwrap().join("old");
2608 git::git(&repo, &["worktree", "add", &old.to_string_lossy(), "b"])
2609 .await
2610 .unwrap();
2611 attempt(&repo, false, None).await.unwrap();
2612 assert!(!old.exists());
2613 }
2614
2615 #[tokio::test]
2616 async fn a_stale_branch_with_an_unmerged_commit_is_kept_with_a_reason() {
2617 let (_d, repo) = with_branch(true).await;
2618 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2619 assert!(e.contains("`b`") || e.contains("branch b"), "{e}");
2620 assert!(e.contains("1 commit(s) not in origin/main"), "{e}");
2621 assert!(git::branch_exists(&repo, "b").await.unwrap());
2622 }
2623
2624 #[tokio::test]
2625 async fn a_stale_branch_on_the_remote_is_kept() {
2626 let (_d, repo) = with_branch(false).await;
2627 git::git(&repo, &["push", "origin", "b"]).await.unwrap();
2628 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2629 assert!(e.contains("branch b") && e.contains("is on origin"), "{e}");
2630 assert!(git::branch_exists(&repo, "b").await.unwrap());
2631 }
2632
2633 #[tokio::test]
2634 async fn a_stale_branch_with_an_open_pull_request_is_kept() {
2635 let (_d, repo) = with_branch(false).await;
2636 let e = attempt(&repo, true, None).await.unwrap_err().to_string();
2637 assert!(e.contains("open pull request"), "{e}");
2638 assert!(git::branch_exists(&repo, "b").await.unwrap());
2639 }
2640
2641 #[tokio::test]
2642 async fn an_unanswerable_pull_request_check_keeps_the_branch() {
2643 let (_d, repo) = with_branch(false).await;
2644 let wt = repo.parent().unwrap().join("bump");
2645 let e = release_attempt(
2646 &repo,
2647 "origin",
2648 "main",
2649 &wt,
2650 "b",
2651 |_| async { bail!("offline") },
2652 |_| async { Ok(()) },
2653 )
2654 .await
2655 .unwrap_err()
2656 .to_string();
2657 assert!(
2658 e.contains("could not check for an open pull request"),
2659 "{e}"
2660 );
2661 assert!(git::branch_exists(&repo, "b").await.unwrap());
2662 }
2663
2664 #[tokio::test]
2665 async fn a_failed_attempt_removes_its_own_worktree_and_branch() {
2666 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2667 let wt = repo.parent().unwrap().join("bump");
2668 let e = attempt(&repo, false, Some(false)).await.unwrap_err();
2669 assert!(format!("{e:#}").contains("cargo build failed"));
2670 assert!(!wt.exists());
2671 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2672 }
2673
2674 #[tokio::test]
2675 async fn a_pushed_branch_survives_a_failed_attempt_and_the_reason_says_so() {
2676 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2677 let e = attempt(&repo, false, Some(true)).await.unwrap_err();
2678 let e = format!("{e:#}");
2679 assert!(
2680 e.contains("cargo build failed") && e.contains("pushed to origin"),
2681 "{e}"
2682 );
2683 assert!(git::branch_exists(&repo, "b").await.unwrap());
2684 }
2685
2686 #[tokio::test]
2687 async fn a_branch_left_by_a_half_done_worktree_add_is_removed() {
2688 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2691 let blocker = repo.parent().unwrap().join("blocker");
2692 tokio::fs::write(&blocker, "file").await.unwrap();
2693 let wt = blocker.join("bump");
2694 let r = release_attempt(
2695 &repo,
2696 "origin",
2697 "main",
2698 &wt,
2699 "b",
2700 |_| async { bail!("offline") },
2701 |_| async { Ok(()) },
2702 )
2703 .await;
2704 assert!(r.is_err());
2705 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2706 }
2707
2708 #[tokio::test]
2709 async fn the_retry_is_taken_at_most_once() {
2710 let (_d, repo) = with_branch(false).await;
2713 let blocker = repo.parent().unwrap().join("blocker");
2714 tokio::fs::write(&blocker, "file").await.unwrap();
2715 let wt = blocker.join("bump");
2716 let r = release_attempt(&repo, "origin", "main", &wt, "b", no_pr, |_| async {
2717 Ok(())
2718 })
2719 .await;
2720 assert!(r.is_err());
2721 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2722 }
2723
2724 #[tokio::test]
2725 async fn git_worktree_failure_reaches_release_notice_without_debug_exit_codes() {
2726 let (dir, repo) = origin_with(&[("f", "x\n")]).await;
2727 let worktree = dir.path().join("occupied");
2728 tokio::fs::create_dir(&worktree).await.unwrap();
2729 let existing = worktree.join("keep");
2730 tokio::fs::write(&existing, "operator data").await.unwrap();
2731 let err = release_attempt::<(), _, _, _, _>(
2732 &repo,
2733 "origin",
2734 "main",
2735 &worktree,
2736 "release-test",
2737 no_pr,
2738 |_| async { panic!("worktree creation must fail before release edits") },
2739 )
2740 .await
2741 .unwrap_err();
2742 let (version, reason) = notice_for_failure(&Progress::default(), &err);
2743 assert!(
2744 reason.contains("create the release-bump worktree"),
2745 "{reason}"
2746 );
2747 assert!(reason.contains("exit code 128"), "{reason}");
2748 let store = Notices::at(dir.path().join("notifications"));
2749 let mut state = winner_state(&repo, "main");
2750 state.status = RunStatus::Merged;
2751 report_problem_in(&mut state, &store, None, version.as_deref(), &reason, false).await;
2752 let notices = store.list();
2753 assert_eq!(notices.len(), 1);
2754 let notice = ¬ices[0];
2755 assert!(
2756 notice.message.contains("exit code 128"),
2757 "{}",
2758 notice.message
2759 );
2760 assert!(!notice.message.contains("Some(") && !notice.message.contains("None"));
2761 assert_eq!(notice.severity, Severity::Error);
2762 assert_eq!(state.status, RunStatus::Merged);
2763 assert!(state.needs_attention());
2764 assert!(!git::branch_exists(&repo, "release-test").await.unwrap());
2765 assert_eq!(
2766 tokio::fs::read_to_string(existing).await.unwrap(),
2767 "operator data"
2768 );
2769 }
2770
2771 #[tokio::test]
2772 async fn base_has_cargo_toml_tells_rust_from_non_rust() {
2773 let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
2774 assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
2775 let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
2776 assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
2777 assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
2779 }
2780
2781 #[tokio::test]
2782 async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
2783 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2784 let home = tempfile::tempdir().unwrap();
2785 let mut state = RunState::new(
2786 repo.clone(),
2787 "main".to_owned(),
2788 "0000000000000000000000000000000000000000".to_owned(),
2789 "task".to_owned(),
2790 Config::default(),
2791 );
2792 state.candidates.push(crate::run::Candidate {
2793 index: 0,
2794 label: 'A',
2795 agent: "x".to_owned(),
2796 branch: "main".to_owned(),
2797 worktree: repo.clone(),
2798 summary: String::new(),
2799 stat: String::new(),
2800 files: 1,
2801 commits: 1,
2802 empty: false,
2803 failed: None,
2804 verified_noop: None,
2805 duration_ms: 0,
2806 folded: false,
2807 });
2808 state.tally = Some(
2809 serde_json::from_value(serde_json::json!({
2810 "first_choice": {}, "borda": {}, "winner": "A",
2811 "unanimous_initial": true, "deliberated": false,
2812 "changed_votes": 0, "unanimous_final": true,
2813 }))
2814 .unwrap(),
2815 );
2816 after_merge_at(
2817 &mut state,
2818 "https://example.invalid/pull/1",
2819 Some(home.path()),
2820 None,
2821 )
2822 .await
2823 .expect("a non-Rust repository is not an error");
2824 let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
2825 assert_eq!(bumps.len(), 1, "{:?}", state.events);
2826 assert_eq!(
2827 bumps[0].message,
2828 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
2829 );
2830 assert!(
2831 std::fs::read_dir(home.path()).unwrap().next().is_none(),
2832 "no marker and no lock may be created"
2833 );
2834 }
2835
2836 fn winner_state(repo: &Path, base: &str) -> RunState {
2837 let mut state = RunState::new(
2838 repo.to_path_buf(),
2839 base.to_owned(),
2840 "0000000000000000000000000000000000000000".to_owned(),
2841 "task".to_owned(),
2842 Config::default(),
2843 );
2844 state.candidates.push(crate::run::Candidate {
2845 index: 0,
2846 label: 'A',
2847 agent: "x".to_owned(),
2848 branch: "main".to_owned(),
2849 worktree: repo.to_path_buf(),
2850 summary: String::new(),
2851 stat: String::new(),
2852 files: 1,
2853 commits: 1,
2854 empty: false,
2855 failed: None,
2856 verified_noop: None,
2857 duration_ms: 0,
2858 folded: false,
2859 });
2860 state.tally = Some(
2861 serde_json::from_value(serde_json::json!({
2862 "first_choice": {}, "borda": {}, "winner": "A",
2863 "unanimous_initial": true, "deliberated": false,
2864 "changed_votes": 0, "unanimous_final": true,
2865 }))
2866 .unwrap(),
2867 );
2868 state
2869 }
2870
2871 #[tokio::test]
2872 async fn a_real_failure_without_a_pr_raises_one_notice_and_a_retry_folds_into_it() {
2873 let (_d, repo) =
2874 origin_with(&[("Cargo.toml", "[package]\nname=\"x\"\nversion=\"0.1.0\"\n")]).await;
2875 let home = tempfile::tempdir().unwrap();
2876 let store = Notices::at(home.path().join("notifications"));
2877 let mut state = winner_state(&repo, "nope");
2879 let url = "https://example.invalid/pull/1";
2880 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2881 .await
2882 .expect_err("an unresolvable base is a failure");
2883 let listed = store.list();
2884 assert_eq!(listed.len(), 1, "{listed:?}");
2885 assert!(
2886 listed[0].message.contains("did not run"),
2887 "{}",
2888 listed[0].message
2889 );
2890 assert!(
2891 listed[0].message.contains("Cargo.toml"),
2892 "{}",
2893 listed[0].message
2894 );
2895 assert!(state.events.iter().any(|e| e.node == "bump"));
2896 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2897 .await
2898 .expect_err("still failing");
2899 let listed = store.list();
2900 assert_eq!(listed.len(), 1, "{listed:?}");
2901 assert_eq!(listed[0].count, 2);
2902 }
2903
2904 #[tokio::test]
2905 async fn not_eligible_cases_raise_no_notice() {
2906 let home = tempfile::tempdir().unwrap();
2907 let store = Notices::at(home.path().join("notifications"));
2908 let url = "https://example.invalid/pull/1";
2909 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2911 let mut state = winner_state(&repo, "main");
2912 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2913 .await
2914 .unwrap();
2915 let mut state = winner_state(&repo, "nope");
2917 state.config.merge.release_bump = false;
2918 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2919 .await
2920 .unwrap();
2921 let mut state = winner_state(&repo, "nope");
2923 state.tally = None;
2924 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2925 .await
2926 .unwrap();
2927 assert!(store.list().is_empty(), "{:?}", store.list());
2928 }
2929
2930 #[test]
2931 fn the_no_pr_notice_follows_the_configured_language() {
2932 let dir = tempfile::tempdir().unwrap();
2933 let store = Notices::at(dir.path().join("notifications"));
2934 let mut state = merged_state();
2935 state.config.graph.language = "ja".to_owned();
2936 let (n, _) = surface_problem(&mut state, &store, None, Some("0.2.0"), "boom").unwrap();
2937 assert!(n.message.contains("実行されませんでした"), "{}", n.message);
2938 assert!(n.message.contains("boom") && n.message.contains("v0.2.0"));
2939 }
2940
2941 #[test]
2942 fn a_failed_escalation_points_at_the_pending_pr_without_commenting() {
2943 let dir = tempfile::tempdir().unwrap();
2944 let store = Notices::at(dir.path().join("notifications"));
2945 let mut state = merged_state();
2946 let url = "https://example.invalid/pull/9";
2947 let (n, comment) = surface_problem_in(
2948 &mut state,
2949 &store,
2950 Some(url),
2951 Some("0.3.0"),
2952 "push failed",
2953 true,
2954 )
2955 .unwrap();
2956 assert!(comment.is_none());
2957 assert!(
2958 n.message.contains(url) && n.message.contains("v0.3.0"),
2959 "{}",
2960 n.message
2961 );
2962 assert!(!n.message.contains("open the release pull request by hand"));
2963 assert!(matches!(n.link, Some(Link::Url { .. })));
2964 }
2965
2966 #[test]
2967 fn a_failure_notice_names_the_pending_pr_and_the_first_line_of_the_cause() {
2968 let err = anyhow!("outer context").context("cargo build failed\nsecond line");
2969 let with_pr = Progress {
2970 version: Some("0.2.0".into()),
2971 pending_pr: Some("https://example.invalid/pull/9".into()),
2972 };
2973 let (v, reason) = notice_for_failure(&with_pr, &err);
2974 assert_eq!(v.as_deref(), Some("0.2.0"));
2975 assert_eq!(reason, "cargo build failed");
2976 let (v, reason) = notice_for_failure(&Progress::default(), &err);
2977 assert_eq!(v, None);
2978 assert_eq!(reason, "cargo build failed");
2979 }
2980
2981 const NO_RULES: &str = "gh pr merge --auto: GraphQL: Pull request Branch does not have \
2982 required protected branch rules (enablePullRequestAutoMerge)";
2983
2984 fn merged_state() -> RunState {
2985 run::pin_test_home();
2988 let mut s = RunState::new(
2989 PathBuf::from("/no/such/repo"),
2990 "main".to_owned(),
2991 "0000000000000000000000000000000000000000".to_owned(),
2992 "task".to_owned(),
2993 Config::default(),
2994 );
2995 s.status = RunStatus::Merged;
2996 s
2997 }
2998
2999 #[cfg(unix)]
3000 fn decision_cli(id: &str, reply: &str, exit: &str) -> AgentSpec {
3001 AgentSpec {
3002 id: id.into(),
3003 kind: crate::config::AgentKind::Command,
3004 model: None,
3005 command: vec![
3006 "sh".into(),
3007 "-c".into(),
3008 concat!(
3009 "if [ \"$STATUS\" = timeout ]; then exec sleep 5; fi; ",
3010 "printf '%s' \"$REPLY\"; exit \"$STATUS\""
3011 )
3012 .into(),
3013 ],
3014 extra_args: Vec::new(),
3015 env: std::collections::BTreeMap::from([
3016 ("REPLY".into(), reply.into()),
3017 ("STATUS".into(), exit.into()),
3018 ]),
3019 prompt_delivery: None,
3020 }
3021 }
3022
3023 #[cfg(unix)]
3024 fn decision_state(agents: Vec<AgentSpec>, preferences: &[&str]) -> RunState {
3025 let mut state = merged_state();
3026 state.config.agents = agents;
3027 state.config.roles.chatter = Some(crate::config::AgentChoice::Chain(
3028 preferences.iter().map(|s| (*s).to_owned()).collect(),
3029 ));
3030 state
3031 }
3032
3033 #[cfg(unix)]
3034 const VALID_DECISION: &str = r#"{"level":"minor","reason":"Adds a feature."}"#;
3035
3036 #[cfg(unix)]
3037 #[tokio::test]
3038 async fn decision_failover_handles_each_unusable_cli_outcome() {
3039 for (reply, exit, expected) in [
3040 (
3041 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3042 "1",
3043 "rate/quota limit",
3044 ),
3045 (
3046 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached","level":"minor","reason":"Looks valid."}"#,
3047 "0",
3048 "rate/quota limit",
3049 ),
3050 (
3051 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached","level":"minor","reason":"Looks valid."}"#,
3052 "0",
3053 "rate/quota limit",
3054 ),
3055 ("", "0", "empty output"),
3056 (
3057 "",
3058 "spawn",
3059 "CLI invocation failed; inspect attempt artifacts",
3060 ),
3061 ("not JSON", "0", "invalid decision JSON or missing reason"),
3062 (VALID_DECISION, "1", "CLI failed (exit code 1)"),
3063 ("", "timeout", "timed out"),
3064 ] {
3065 let dir = tempfile::tempdir().unwrap();
3066 let mut state = decision_state(
3067 vec![
3068 decision_cli("first", reply, exit),
3069 decision_cli("second", VALID_DECISION, "0"),
3070 ],
3071 &["first", "second"],
3072 );
3073 if exit == "spawn" {
3074 state.config.agents[0].command = vec!["magi-test-no-such-decision-cli".into()];
3075 }
3076 let (spec, seat, decision) =
3077 ask_decision(&mut state, dir.path(), "decide", Duration::from_millis(200))
3078 .await
3079 .unwrap();
3080 assert_eq!(spec.id, "second");
3081 assert_eq!(decision.level, BumpLevel::Minor);
3082 assert_eq!(state.bump_decision_attempts.len(), 2);
3083 assert_eq!(state.bump_decision_attempts[0].outcome, expected);
3084 assert_eq!(state.handovers.len(), 1);
3085 assert_eq!(state.handovers[0].to, "second");
3086 assert_ne!(
3087 seat.claude_session,
3088 SeatState::new("bump", "first", state.seed).claude_session
3089 );
3090 let report = crate::report::run(&state);
3091 assert!(report.contains(expected), "{report}");
3092 assert!(report.contains("second: valid decision"), "{report}");
3093 assert!(state.release_bump.is_none());
3094 let artifacts = agent::artifacts_dir(&state.dir());
3095 if exit != "spawn" {
3096 assert!(artifacts.join("bump-decision-1.out").exists());
3097 }
3098 assert!(artifacts.join("bump-decision-2.out").exists());
3099 }
3100 }
3101
3102 #[cfg(unix)]
3103 #[tokio::test]
3104 async fn decision_chain_respects_preferences_and_stops_on_first_success() {
3105 let dir = tempfile::tempdir().unwrap();
3106 let mut state = decision_state(
3107 vec![
3108 decision_cli("first", "bad", "1"),
3109 decision_cli("second", VALID_DECISION, "0"),
3110 ],
3111 &["missing", "second", "second", "first"],
3112 );
3113 let (spec, _, _) = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(1))
3114 .await
3115 .unwrap();
3116 assert_eq!(spec.id, "second");
3117 assert_eq!(state.bump_decision_attempts.len(), 1);
3118 assert!(state.handovers.is_empty());
3119 }
3120
3121 #[cfg(unix)]
3122 #[tokio::test]
3123 async fn decision_chain_consumes_same_failure_classes_and_preserves_unset_policy() {
3124 let dir = tempfile::tempdir().unwrap();
3125 let mut state = decision_state(
3126 vec![
3127 decision_cli("first", "bad", "0"),
3128 decision_cli("second", "bad", "0"),
3129 decision_cli("third", VALID_DECISION, "0"),
3130 ],
3131 &["first", "second", "third"],
3132 );
3133 let (spec, _, _) = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(2))
3134 .await
3135 .unwrap();
3136 assert_eq!(spec.id, "third");
3137 assert_eq!(state.bump_decision_attempts.len(), 3);
3138 assert_eq!(state.handovers.len(), 2);
3139 state.config.roles.chatter = None;
3141 let err = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(2))
3142 .await
3143 .unwrap_err();
3144 assert!(err.to_string().contains("exhausted 1 eligible agent(s)"));
3145 assert_eq!(state.bump_decision_attempts.len(), 4);
3146 }
3147
3148 #[cfg(unix)]
3149 #[tokio::test]
3150 async fn decision_reason_rewrite_failure_preserves_the_selected_level() {
3151 let dir = tempfile::tempdir().unwrap();
3152 for reply in ["invalid", r#"{"level":"patch","reason":"Fixes a defect."}"#] {
3153 let mut state = merged_state();
3154 state.config.graph.github_text_guard = true;
3155 state.config.graph.github_language = "en".into();
3156 let spec = decision_cli("chosen", reply, "0");
3157 let mut seat = SeatState::new("bump", &spec.id, state.seed);
3158 let mut decision =
3159 parse_decision(r#"{"level":"minor","reason":"新しい機能を追加しました。"}"#)
3160 .unwrap();
3161 guard_reason(
3162 &mut state,
3163 dir.path(),
3164 "decide",
3165 &spec,
3166 &mut seat,
3167 &mut decision,
3168 )
3169 .await;
3170 assert_eq!(decision.level, BumpLevel::Minor);
3171 assert_eq!(decision.reason, "The merged change requires a release.");
3172 assert!(state.handovers.is_empty());
3173 assert!(state.bump_decision_attempts.is_empty());
3174 }
3175 }
3176
3177 #[cfg(unix)]
3178 #[tokio::test]
3179 async fn decision_exhaustion_is_bounded_reported_and_leaves_no_release_outcome() {
3180 let dir = tempfile::tempdir().unwrap();
3181 let mut state = decision_state(
3182 vec![
3183 decision_cli("first", "bad", "0"),
3184 decision_cli("second", "", "0"),
3185 ],
3186 &["first", "first", "second", "second"],
3187 );
3188 let err = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(1))
3189 .await
3190 .unwrap_err()
3191 .to_string();
3192 assert!(err.contains("exhausted 2 eligible agent(s)"), "{err}");
3193 assert!(err.contains("first: invalid decision JSON"), "{err}");
3194 assert!(err.contains("second: empty output"), "{err}");
3195 assert!(err.contains("[roles] chatter"), "{err}");
3196 assert_eq!(state.bump_decision_attempts.len(), 2);
3197 assert_eq!(state.handovers.len(), 1);
3198 assert!(state.release_bump.is_none());
3199 let report = crate::report::run(&state);
3200 assert!(report.contains("first: invalid decision JSON"));
3201 assert!(report.contains("second: empty output"));
3202 let restored: RunState =
3203 serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
3204 assert_eq!(restored.bump_decision_attempts.len(), 2);
3205 }
3206
3207 #[test]
3208 fn the_known_automerge_refusal_names_branch_protection() {
3209 assert!(automerge_hint(NO_RULES).contains("branch protection with required"));
3210 let other = automerge_hint("gh: network unreachable");
3211 assert!(!other.contains("branch protection"), "{other}");
3212 let body = automerge_failure_comment(NO_RULES);
3213 assert!(body.contains("enablePullRequestAutoMerge"), "{body}");
3214 assert!(body.contains("Action required"), "{body}");
3215 }
3216
3217 const CLEAN: &str = "gh pr merge --auto: GraphQL: Pull request Pull request is in clean \
3218 status (enablePullRequestAutoMerge)";
3219
3220 #[test]
3221 fn clean_status_refusal_is_matched_narrowly() {
3222 assert!(is_clean_status_refusal(CLEAN));
3223 assert!(!is_clean_status_refusal(NO_RULES));
3224 assert!(!is_clean_status_refusal("gh: network unreachable"));
3225 assert!(!is_clean_status_refusal("Pull request is in clean status"));
3226 assert!(automerge_hint(CLEAN).contains("already green"));
3227 }
3228
3229 #[test]
3230 fn the_direct_merge_argv_matches_the_land_flags() {
3231 let a = bump_merge_argv("https://github.com/o/r/pull/9", "chore: release v1.0.0");
3232 let l = land::merge_argv(9, "chore: release v1.0.0");
3233 assert_eq!(a[..2], l[..2]);
3234 assert_eq!(a[3..], l[3..]);
3235 assert_eq!(a[2], "https://github.com/o/r/pull/9");
3236 }
3237
3238 #[test]
3239 fn a_direct_merge_is_judged_by_the_forge_not_the_exit_code() {
3240 let argv = bump_merge_argv("u", "t");
3241 let merged = |o: &AutomergeOutcome| matches!(o, AutomergeOutcome::MergedDirectly { .. });
3242 assert!(merged(&resolve_direct_merge(CLEAN, &argv, true, "", None)));
3243 let detached = "not on any branch";
3244 assert!(merged(&resolve_direct_merge(
3245 CLEAN,
3246 &argv,
3247 false,
3248 detached,
3249 Some(PrLifecycle::Merged)
3250 )));
3251 for after in [Some(PrLifecycle::Open), None] {
3252 let o = resolve_direct_merge(CLEAN, &argv, false, "boom", after);
3253 match o {
3254 AutomergeOutcome::Failed { reason } => {
3255 assert!(
3256 reason.contains("clean status") && reason.contains("boom"),
3257 "{reason}"
3258 )
3259 }
3260 other => panic!("expected Failed, got {other:?}"),
3261 }
3262 }
3263 }
3264
3265 #[test]
3266 fn a_local_mode_bump_reads_back_and_old_records_are_not_local() {
3267 let old: run::ReleaseBump =
3268 serde_json::from_str(r#"{"pr_url":"u","automerge_enabled":true}"#).unwrap();
3269 assert!(!old.local);
3270 let mut state = merged_state();
3271 state.release_bump = Some(run::ReleaseBump {
3272 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
3273 version: Some("1.0.0".to_owned()),
3274 local: true,
3275 ..run::ReleaseBump::default()
3276 });
3277 assert!(!state.needs_attention());
3279 let text = crate::report::run(&state);
3280 assert!(text.contains("release.mode = local"), "{text}");
3281 assert!(!text.contains("FAILED"), "{text}");
3282 }
3283
3284 #[test]
3285 fn a_directly_merged_bump_is_not_reported_as_pending_or_failed() {
3286 let mut state = merged_state();
3287 state.release_bump = Some(run::ReleaseBump {
3288 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
3289 version: Some("1.0.0".to_owned()),
3290 merged_directly: true,
3291 ..run::ReleaseBump::default()
3292 });
3293 assert!(!state.needs_attention());
3294 let text = crate::report::run(&state);
3295 assert!(text.contains("merged directly"), "{text}");
3296 assert!(!text.contains("FAILED"), "{text}");
3297 }
3298
3299 #[test]
3300 fn an_automerge_failure_is_recorded_shown_and_filed_and_survives_settling() {
3301 let dir = tempfile::tempdir().unwrap();
3302 let store = Notices::at(dir.path().join("notifications"));
3303 let questions = ask::Questions::at(dir.path().join("questions"));
3304 let mut state = merged_state();
3305 let url = "https://github.com/o/r/pull/35";
3306
3307 let (n, comment) =
3308 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
3309
3310 let comment = comment.expect("a PR was opened, so it gets a comment");
3312 assert!(comment.contains("branch protection"), "{comment}");
3313
3314 assert_eq!(state.status, RunStatus::Merged);
3316 assert!(state.needs_attention());
3317 let text = crate::report::run(&state);
3318 assert!(text.contains("release bump"), "{text}");
3319 assert!(text.contains("FAILED"), "{text}");
3320 assert!(text.contains(url), "{text}");
3321 assert!(text.contains("action required"), "{text}");
3322 assert!(crate::report::line(&state).contains("release needs a human"));
3323
3324 assert_eq!(n.severity, Severity::Error);
3326 assert_eq!(
3327 n.link,
3328 Some(Link::Url {
3329 url: url.to_owned()
3330 })
3331 );
3332 assert_eq!(store.list().len(), 1);
3333 assert!(questions.open_for(&state.id).is_empty());
3334
3335 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
3337 let listed = store.list();
3338 assert_eq!(listed.len(), 1);
3339 assert_eq!(listed[0].count, 2);
3340 }
3341
3342 #[test]
3343 fn a_bump_that_never_ran_is_surfaced_without_a_pr_comment() {
3344 let dir = tempfile::tempdir().unwrap();
3345 let store = Notices::at(dir.path().join("notifications"));
3346 let mut state = merged_state();
3347 let (n, comment) = surface_problem(&mut state, &store, None, None, "no agent").unwrap();
3348 assert!(comment.is_none());
3349 assert!(matches!(n.link, Some(Link::Run { .. })));
3350 assert!(state.needs_attention());
3351 }
3352
3353 #[test]
3354 fn version_parses_and_bumps_each_digit() {
3355 let v = Version::parse("0.4.0").unwrap();
3356 assert_eq!(
3357 v,
3358 Version {
3359 major: 0,
3360 minor: 4,
3361 patch: 0
3362 }
3363 );
3364
3365 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
3366 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
3367 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
3368 }
3369
3370 #[test]
3371 fn version_tolerates_a_prerelease_suffix_on_patch() {
3372 let v = Version::parse("1.2.3-rc1").unwrap();
3373 assert_eq!(
3374 v,
3375 Version {
3376 major: 1,
3377 minor: 2,
3378 patch: 3
3379 }
3380 );
3381 }
3382
3383 #[test]
3384 fn version_rejects_garbage() {
3385 assert!(Version::parse("not-a-version").is_err());
3386 assert!(Version::parse("1.2").is_err());
3387 }
3388
3389 #[test]
3390 fn decision_parses_each_level() {
3391 for (json, level) in [
3392 (
3393 r#"{"level":"major","reason":"drops a config key"}"#,
3394 BumpLevel::Major,
3395 ),
3396 (
3397 r#"{"level":"minor","reason":"adds a new flag"}"#,
3398 BumpLevel::Minor,
3399 ),
3400 (
3401 r#"{"level":"patch","reason":"fixes a race"}"#,
3402 BumpLevel::Patch,
3403 ),
3404 ] {
3405 let decision = parse_decision(json).unwrap();
3406 assert_eq!(decision.level, level);
3407 assert!(!decision.reason.is_empty());
3408 }
3409 }
3410
3411 #[test]
3412 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
3413 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
3414 let decision = parse_decision(text).unwrap();
3415 assert_eq!(decision.level, BumpLevel::Minor);
3416 assert_eq!(decision.reason, "new HTTP route");
3417 }
3418
3419 #[test]
3420 fn a_broken_reply_is_an_error_not_a_default() {
3421 assert!(parse_decision("I decline to answer.").is_err());
3422 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
3423 assert!(
3424 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
3425 "an empty reason must not pass either"
3426 );
3427 assert!(
3428 parse_decision(r#"{"level":"patch"}"#).is_err(),
3429 "a reply with no reason at all must not pass"
3430 );
3431 }
3432
3433 #[test]
3434 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
3435 let prompt = decision_prompt(
3436 "feat: add a phone endpoint",
3437 "add POST /api/widgets",
3438 "1 file changed, 10 insertions(+)",
3439 &["src/web.rs".to_owned()],
3440 "0.8.0",
3441 );
3442 assert!(prompt.contains("0.8.0"), "the current version is stated");
3443 assert!(
3444 prompt.contains("below `1.0.0`")
3445 && prompt.contains("`minor` is the digit that carries a breaking change"),
3446 "the 0.x rule must be explicit: {prompt}"
3447 );
3448 assert!(
3449 prompt.contains("choose the larger"),
3450 "the tie-break toward the bigger digit must be explicit: {prompt}"
3451 );
3452 }
3453
3454 #[test]
3455 fn release_only_diffs_are_recognised() {
3456 assert!(is_release_only(&["Cargo.toml".to_owned()]));
3457 assert!(is_release_only(&[
3458 "Cargo.toml".to_owned(),
3459 "Cargo.lock".to_owned()
3460 ]));
3461 assert!(!is_release_only(&[]));
3462 assert!(!is_release_only(&[
3463 "Cargo.toml".to_owned(),
3464 "src/main.rs".to_owned()
3465 ]));
3466 }
3467
3468 #[test]
3469 fn cargo_version_rewrite_touches_only_the_package_table() {
3470 let toml = "\
3471[package]\n\
3472# a comment mentioning version on purpose\n\
3473name = \"magi-cli\"\n\
3474version = \"0.8.0\"\n\
3475edition = \"2024\"\n\
3476\n\
3477[dependencies]\n\
3478foo = { version = \"1.2.3\" }\n";
3479 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
3480 assert!(out.contains("version = \"0.9.0\""));
3481 assert!(
3482 out.contains("foo = { version = \"1.2.3\" }"),
3483 "a dependency's own version pin must survive: {out}"
3484 );
3485 assert!(
3486 out.contains("# a comment mentioning version on purpose"),
3487 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
3488 );
3489 assert_eq!(
3490 out.lines().count(),
3491 toml.lines().count(),
3492 "the rewrite replaces one line, it does not add or remove any"
3493 );
3494 }
3495
3496 #[test]
3497 fn cargo_version_rewrite_fails_without_a_package_table() {
3498 let toml = "[dependencies]\nfoo = \"1\"\n";
3499 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
3500 }
3501
3502 #[test]
3508 fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
3509 let toml = "\
3510[workspace]\n\
3511members = [\"crates/a\", \"crates/b\"]\n\
3512\n\
3513[workspace.package]\n\
3514version = \"0.45.18\"\n\
3515edition = \"2024\"\n\
3516\n\
3517[workspace.dependencies]\n\
3518foo = { version = \"1.2.3\" }\n";
3519 let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
3520 assert!(out.contains("version = \"0.45.19\""));
3521 assert!(
3522 out.contains("foo = { version = \"1.2.3\" }"),
3523 "a workspace dependency's own version pin must survive: {out}"
3524 );
3525 assert_eq!(
3526 out.lines().count(),
3527 toml.lines().count(),
3528 "the rewrite replaces one line, it does not add or remove any"
3529 );
3530 }
3531
3532 #[test]
3536 fn cargo_version_rewrite_bumps_an_internal_workspace_dependency_pin() {
3537 let toml = "\
3538[workspace]\n\
3539members = [\"crates/kanade-shared\"]\n\
3540\n\
3541[workspace.package]\n\
3542version = \"0.48.2\"\n\
3543\n\
3544[workspace.dependencies]\n\
3545kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n";
3546 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3547 assert!(out.contains("[workspace.package]\nversion = \"0.48.3\"\n"));
3548 assert!(
3549 out.contains(
3550 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3551 ),
3552 "the internal pin must move with the workspace version: {out}"
3553 );
3554 }
3555
3556 #[test]
3558 fn cargo_version_rewrite_bumps_an_internal_pin_with_version_before_path() {
3559 let toml = "\
3560[workspace.package]\n\
3561version = \"1.0.0\"\n\
3562\n\
3563[workspace.dependencies]\n\
3564inner = { version = \"1.0.0\", path = \"crates/inner\" }\n";
3565 let out = rewrite_cargo_version(toml, "1.0.1").unwrap();
3566 assert!(out.contains("inner = { version = \"1.0.1\", path = \"crates/inner\" }"));
3567 }
3568
3569 #[test]
3572 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_table_form() {
3573 let toml = "\
3574[workspace.package]\n\
3575version = \"2.3.0\"\n\
3576\n\
3577[workspace.dependencies.inner]\n\
3578path = \"crates/inner\"\n\
3579version = \"2.3.0\"\n";
3580 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3581 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3582 assert!(out.contains(
3583 "[workspace.dependencies.inner]\npath = \"crates/inner\"\nversion = \"2.4.0\"\n"
3584 ));
3585 }
3586
3587 #[test]
3592 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_key_form() {
3593 let toml = "\
3594[workspace.package]\n\
3595version = \"2.3.0\"\n\
3596\n\
3597[workspace.dependencies]\n\
3598inner.path = \"crates/inner\"\n\
3599inner.version = \"2.3.0\"\n";
3600 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3601 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3602 assert!(out.contains("inner.path = \"crates/inner\"\ninner.version = \"2.4.0\"\n"));
3603 }
3604
3605 #[test]
3608 fn cargo_version_rewrite_leaves_a_path_only_workspace_dependency_untouched() {
3609 let toml = "\
3610[workspace.package]\n\
3611version = \"0.1.0\"\n\
3612\n\
3613[workspace.dependencies]\n\
3614internal-only = { path = \"crates/internal-only\" }\n";
3615 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3616 assert!(out.contains("internal-only = { path = \"crates/internal-only\" }"));
3617 }
3618
3619 #[test]
3623 fn cargo_version_rewrite_leaves_an_external_dependency_untouched() {
3624 let toml = "\
3625[workspace.package]\n\
3626version = \"0.1.0\"\n\
3627\n\
3628[workspace.dependencies]\n\
3629serde = { version = \"1\", features = [\"derive\"] }\n\
3630inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3631 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3632 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3633 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3634 }
3635
3636 #[test]
3640 fn cargo_version_rewrite_without_workspace_dependencies_table_still_bumps_package() {
3641 let toml = "[workspace.package]\nversion = \"0.9.0\"\nedition = \"2024\"\n";
3642 let out = rewrite_cargo_version(toml, "0.10.0").unwrap();
3643 assert_eq!(
3644 out,
3645 "[workspace.package]\nversion = \"0.10.0\"\nedition = \"2024\"\n"
3646 );
3647 }
3648
3649 #[test]
3652 fn cargo_version_rewrite_handles_a_kanade_shaped_workspace_dependencies_table() {
3653 let toml = "\
3654[workspace.package]\n\
3655version = \"0.48.2\"\n\
3656\n\
3657[workspace.dependencies]\n\
3658anyhow = { version = \"1\" }\n\
3659serde = { version = \"1\", features = [\"derive\"] }\n\
3660kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n\
3661kanade-core = { path = \"crates/kanade-core\", version = \"0.48.2\" }\n\
3662kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }\n";
3663 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3664 assert!(out.contains("anyhow = { version = \"1\" }"));
3665 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3666 assert!(
3667 out.contains(
3668 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3669 )
3670 );
3671 assert!(
3672 out.contains("kanade-core = { path = \"crates/kanade-core\", version = \"0.48.3\" }")
3673 );
3674 assert!(out.contains("kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }"));
3675 }
3676
3677 #[test]
3681 fn cargo_version_rewrite_bails_on_an_unrepresentable_inline_table() {
3682 let toml = "\
3683[workspace.package]\n\
3684version = \"0.1.0\"\n\
3685\n\
3686[workspace.dependencies]\n\
3687inner = { path = \"crates/inner\",\n\
3688 version = \"0.1.0\" }\n";
3689 assert!(rewrite_cargo_version(toml, "0.2.0").is_err());
3690 }
3691
3692 #[test]
3695 fn cargo_version_rewrite_recognises_a_commented_workspace_dependencies_header() {
3696 let toml = "\
3697[workspace.package]\n\
3698version = \"0.1.0\"\n\
3699\n\
3700[workspace.dependencies] # internal pins\n\
3701inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3702 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3703 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3704 }
3705
3706 #[test]
3709 fn cargo_version_rewrite_bumps_a_quoted_dependency_key() {
3710 let toml = "\
3711[workspace.package]\n\
3712version = \"0.1.0\"\n\
3713\n\
3714[workspace.dependencies]\n\
3715\"inner\" = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3716 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3717 assert!(out.contains("\"inner\" = { path = \"crates/inner\", version = \"0.2.0\" }"));
3718 }
3719
3720 #[test]
3724 fn cargo_version_rewrite_bumps_a_literal_string_version_pin() {
3725 let toml = "\
3726[workspace.package]\n\
3727version = \"0.1.0\"\n\
3728\n\
3729[workspace.dependencies]\n\
3730inner = { path = 'crates/inner', version = '0.1.0' }\n";
3731 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3732 assert!(out.contains("inner = { path = 'crates/inner', version = '0.2.0' }"));
3733 }
3734
3735 #[test]
3741 fn cargo_version_rewrite_does_not_require_the_whole_file_to_parse() {
3742 let toml = "\
3743[workspace.package]\n\
3744version = \"0.1.0\"\n\
3745\n\
3746[workspace.dependencies]\n\
3747inner = { path = \"crates/inner\", version = \"0.1.0\" }\n\
3748\n\
3749[workspace.package]\n\
3750edition = \"2024\"\n";
3751 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3752 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3753 assert!(
3754 toml::from_str::<toml::Value>(toml).is_err(),
3755 "the fixture itself must be invalid as a whole file, or this test proves nothing"
3756 );
3757 }
3758
3759 #[test]
3760 fn current_version_prefers_the_package_table_when_both_exist() {
3761 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
3762 assert_eq!(current_version(toml).unwrap(), "0.8.0");
3763 }
3764
3765 #[test]
3768 fn current_version_falls_back_to_workspace_package_without_a_package_table() {
3769 let toml = "\
3770[workspace]\n\
3771members = [\"crates/a\", \"crates/b\"]\n\
3772\n\
3773[workspace.package]\n\
3774version = \"0.45.18\"\n";
3775 assert_eq!(current_version(toml).unwrap(), "0.45.18");
3776 }
3777
3778 #[test]
3779 fn coalesce_proceeds_with_nothing_pending() {
3780 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
3781 }
3782
3783 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
3786 PendingBump {
3787 target_version: target_version.to_owned(),
3788 level,
3789 branch: format!("chore/release-v{target_version}"),
3790 pr_url: "https://example.invalid/pull/9".to_owned(),
3791 }
3792 }
3793
3794 #[test]
3795 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
3796 let pending = test_pending("0.9.0", BumpLevel::Minor);
3797 assert_eq!(
3798 coalesce(Some(&pending), "0.8.0").unwrap(),
3799 Coalesce::Skip {
3800 target_version: "0.9.0".to_owned()
3801 }
3802 );
3803 }
3804
3805 #[test]
3806 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
3807 let pending = test_pending("0.9.0", BumpLevel::Minor);
3808 assert_eq!(
3810 coalesce(Some(&pending), "0.9.0").unwrap(),
3811 Coalesce::Proceed
3812 );
3813 assert_eq!(
3815 coalesce(Some(&pending), "1.0.0").unwrap(),
3816 Coalesce::Proceed
3817 );
3818 }
3819
3820 #[test]
3821 fn pending_action_escalates_only_for_a_more_severe_decision() {
3822 assert_eq!(
3823 pending_action(BumpLevel::Patch, BumpLevel::Patch),
3824 PendingAction::AlreadyCovered
3825 );
3826 assert_eq!(
3827 pending_action(BumpLevel::Patch, BumpLevel::Minor),
3828 PendingAction::Escalate
3829 );
3830 assert_eq!(
3831 pending_action(BumpLevel::Patch, BumpLevel::Major),
3832 PendingAction::Escalate
3833 );
3834 assert_eq!(
3835 pending_action(BumpLevel::Minor, BumpLevel::Patch),
3836 PendingAction::AlreadyCovered
3837 );
3838 assert_eq!(
3839 pending_action(BumpLevel::Major, BumpLevel::Minor),
3840 PendingAction::AlreadyCovered
3841 );
3842 assert_eq!(
3843 pending_action(BumpLevel::Major, BumpLevel::Major),
3844 PendingAction::AlreadyCovered
3845 );
3846 }
3847
3848 #[test]
3849 fn pr_state_parsing_reads_open_and_not_open() {
3850 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
3851 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
3852 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
3853 }
3854
3855 #[test]
3856 fn a_lock_is_exclusive_until_dropped() {
3857 let dir = tempfile::tempdir().unwrap();
3858 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3859 let first = MarkerLock::acquire(&marker)
3860 .unwrap()
3861 .expect("first attempt takes the lock");
3862 assert!(
3863 MarkerLock::acquire(&marker).unwrap().is_none(),
3864 "a second attempt must be refused while the first holds it"
3865 );
3866 drop(first);
3867 assert!(
3868 MarkerLock::acquire(&marker).unwrap().is_some(),
3869 "dropping the guard releases the lock for the next attempt"
3870 );
3871 }
3872
3873 #[test]
3874 fn a_stale_lock_is_reclaimed() {
3875 let dir = tempfile::tempdir().unwrap();
3876 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3877 let lock_path = marker.with_extension("lock");
3878 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
3879 std::fs::write(&lock_path, b"").unwrap();
3880 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
3881 std::fs::OpenOptions::new()
3882 .write(true)
3883 .open(&lock_path)
3884 .unwrap()
3885 .set_modified(old)
3886 .unwrap();
3887 assert!(
3888 MarkerLock::acquire(&marker).unwrap().is_some(),
3889 "a lock older than the stale window must be reclaimed rather than block forever"
3890 );
3891 }
3892
3893 #[tokio::test]
3894 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
3895 let dir = tempfile::tempdir().unwrap();
3896 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3897 let held = MarkerLock::acquire(&marker)
3898 .unwrap()
3899 .expect("seed the contention");
3900 let releaser = tokio::spawn(async move {
3901 tokio::time::sleep(Duration::from_millis(20)).await;
3902 drop(held);
3903 });
3904 let waited =
3905 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
3906 .await
3907 .unwrap();
3908 assert!(
3909 waited.is_some(),
3910 "a merge landing behind another's still-running decision must not be dropped - it \
3911 must wait for that decision to finish and then judge against what it left behind"
3912 );
3913 releaser.await.unwrap();
3914 }
3915
3916 #[tokio::test]
3917 async fn a_lock_held_past_the_ceiling_gives_up() {
3918 let dir = tempfile::tempdir().unwrap();
3919 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3920 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
3921 let waited =
3922 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
3923 .await
3924 .unwrap();
3925 assert!(
3926 waited.is_none(),
3927 "a lock genuinely held past the ceiling must eventually give up rather than wait \
3928 forever"
3929 );
3930 }
3931
3932 #[test]
3933 fn level_between_reads_off_the_differing_digit() {
3934 assert_eq!(
3935 level_between(
3936 Version::parse("0.8.0").unwrap(),
3937 Version::parse("1.0.0").unwrap()
3938 ),
3939 Some(BumpLevel::Major)
3940 );
3941 assert_eq!(
3942 level_between(
3943 Version::parse("0.8.0").unwrap(),
3944 Version::parse("0.9.0").unwrap()
3945 ),
3946 Some(BumpLevel::Minor)
3947 );
3948 assert_eq!(
3949 level_between(
3950 Version::parse("0.8.0").unwrap(),
3951 Version::parse("0.8.1").unwrap()
3952 ),
3953 Some(BumpLevel::Patch)
3954 );
3955 assert_eq!(
3956 level_between(
3957 Version::parse("0.8.0").unwrap(),
3958 Version::parse("0.8.0").unwrap()
3959 ),
3960 None
3961 );
3962 }
3963
3964 #[test]
3965 fn open_release_pr_is_found_among_unrelated_pull_requests() {
3966 let json = r#"[
3967 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
3968 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
3969 ]"#;
3970 let found = parse_open_release_pr(json).unwrap();
3971 assert_eq!(
3972 found,
3973 Some((
3974 "chore/release-v0.9.0".to_owned(),
3975 "https://example.invalid/pull/2".to_owned()
3976 ))
3977 );
3978 }
3979
3980 #[test]
3981 fn no_open_release_pr_reads_as_none_not_an_error() {
3982 let json =
3983 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
3984 assert_eq!(parse_open_release_pr(json).unwrap(), None);
3985 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
3986 }
3987
3988 #[test]
3989 fn marker_round_trips_through_disk() {
3990 let dir = tempfile::tempdir().unwrap();
3991 let path = marker_path(dir.path(), Path::new("/repos/magi"));
3992 assert!(read_marker(&path).is_none());
3993
3994 let marker = test_pending("0.9.0", BumpLevel::Patch);
3995 write_marker(&path, &marker).unwrap();
3996 let read_back = read_marker(&path).unwrap();
3997 assert_eq!(read_back.target_version, "0.9.0");
3998 assert_eq!(read_back.level, BumpLevel::Patch);
3999 assert_eq!(read_back.pr_url, marker.pr_url);
4000
4001 clear_marker(&path);
4002 assert!(read_marker(&path).is_none());
4003 }
4004
4005 #[test]
4006 fn different_repos_get_different_marker_files() {
4007 let dir = tempfile::tempdir().unwrap();
4008 let a = marker_path(dir.path(), Path::new("/repos/a"));
4009 let b = marker_path(dir.path(), Path::new("/repos/b"));
4010 assert_ne!(a, b);
4011 }
4012
4013 #[test]
4017 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
4018 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
4019 assert!(
4020 is_release_only(&files),
4021 "the bump pull request's own diff must read as release-only"
4022 );
4023 }
4024
4025 #[test]
4026 fn should_release_bump_reads_only_a_merged_status() {
4027 assert!(should_release_bump(RunStatus::Merged));
4028 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
4029 assert!(!should_release_bump(other));
4030 }
4031 }
4032
4033 #[test]
4037 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
4038 let pr = land::PrState {
4039 url: "https://github.com/o/r/pull/1".to_owned(),
4040 number: 1,
4041 state: PrLifecycle::Merged,
4042 checks: land::Checks::Green,
4043 failing: Vec::new(),
4044 review_comments: Vec::new(),
4045 blocking: land::Blocking::No,
4046 };
4047 assert_eq!(
4048 land::decide(&pr, 0, 4, Duration::ZERO),
4049 land::Step::Done { merged: true }
4050 );
4051 assert!(should_release_bump(RunStatus::Merged));
4052 }
4053
4054 #[test]
4059 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
4060 let pr = land::PrState {
4061 url: "https://github.com/o/r/pull/2".to_owned(),
4062 number: 2,
4063 state: PrLifecycle::Open,
4064 checks: land::Checks::Green,
4065 failing: Vec::new(),
4066 review_comments: Vec::new(),
4067 blocking: land::Blocking::No,
4068 };
4069 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
4070 assert!(should_release_bump(RunStatus::Merged));
4073 }
4074
4075 #[test]
4078 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
4079 let argv = land::merge_argv(3, "feat: something");
4080 let outcome = land::merged_after_all(
4081 &argv,
4082 "could not determine current branch: not on any branch",
4083 Some(PrLifecycle::Merged),
4084 );
4085 assert!(outcome.is_some(), "the forge's confirmation must win");
4086 assert!(should_release_bump(RunStatus::Merged));
4087
4088 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
4091 assert!(land::merged_after_all(&argv, "network error", None).is_none());
4092 }
4093
4094 #[test]
4096 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
4097 let pr = land::PrState {
4098 url: "https://github.com/o/r/pull/4".to_owned(),
4099 number: 4,
4100 state: PrLifecycle::Closed,
4101 checks: land::Checks::Green,
4102 failing: Vec::new(),
4103 review_comments: Vec::new(),
4104 blocking: land::Blocking::No,
4105 };
4106 assert_eq!(
4107 land::decide(&pr, 0, 4, Duration::ZERO),
4108 land::Step::Done { merged: false }
4109 );
4110 assert!(!should_release_bump(RunStatus::Blocked));
4111 }
4112}