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