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