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