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