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};
30#[cfg(test)]
31use crate::ask;
32use crate::config::AgentSpec;
33use crate::git;
34use crate::land;
35use crate::notices::{Link, Notice, Notices};
36use crate::proc::Quiet as _;
37use crate::release_manifest;
38use crate::run::{self, RunState, RunStatus};
39use crate::verdict;
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct DecisionAttempt {
44 pub agent: String,
46 pub outcome: String,
48}
49
50async fn ask_decision(
53 state: &mut RunState,
54 repo: &Path,
55 prompt: &str,
56 timeout: Duration,
57) -> Result<(AgentSpec, SeatState, BumpDecision)> {
58 let chain = agent::pick_chain(
59 &state.config.agents,
60 state.config.roles.chatter.as_ref(),
61 &agent::installed,
62 "chatter (release-bump decision)",
63 )?;
64 let attempt_timeout = timeout / u32::try_from(chain.len()).unwrap_or(u32::MAX);
67 let artifacts = agent::artifacts_dir(&state.dir());
68 let cache = state.config.cache_dir();
69 let mut failures = Vec::new();
70 for (n, spec) in chain.iter().enumerate() {
71 let mut seat = crate::graph::handover_seat("bump", &spec.id, state.next_seat_seed());
72 let attempt = state.bump_decision_attempts.len() + 1;
75 let stem = format!("bump-decision-{attempt}");
76 state.event(
77 "bump",
78 format!("decision attempt {attempt}: trying {} ({stem})", spec.id),
79 );
80 let outcome = async {
82 let spec = agent::for_github_text(spec, &state.config.graph.github_language, repo)?;
83 agent::invoke(
84 &spec,
85 &mut seat,
86 &Invocation {
87 coordination: state.config.coordination.as_ref(),
88 fetched_base: None,
89 cwd: repo,
90 prompt,
91 timeout: attempt_timeout,
92 allow_write: false,
93 unsandboxed: false,
94 sessions: false,
95 artifacts: &artifacts,
96 stem: &stem,
97 run: &state.id,
98 node: "bump",
99 cache_dir: cache.as_deref(),
100 attachments: &[],
101 writable: &[],
102 },
103 )
104 .await
105 }
106 .await;
107 let parsed = if agent::chain_advances(&outcome) {
108 None
109 } else {
110 outcome
111 .as_ref()
112 .ok()
113 .and_then(|out| parse_decision(&out.text).ok())
114 };
115 let (class, reason) = match &outcome {
118 Ok(out) if out.quota_exhausted() => {
119 (run::FailClass::Quota, "rate/quota limit".to_owned())
120 }
121 Ok(out) if out.timed_out => (run::FailClass::Timeout, "timed out".to_owned()),
122 Ok(out) if out.exit_code != Some(0) => (
123 run::FailClass::Other("cli".into()),
124 format!(
125 "CLI failed ({})",
126 crate::notices::exit_status(out.exit_code)
127 ),
128 ),
129 Ok(out) if out.text.trim().is_empty() => (
130 run::FailClass::Other("empty".into()),
131 "empty output".to_owned(),
132 ),
133 Err(_) => (
134 run::FailClass::Other("cli".into()),
135 "CLI invocation failed; inspect attempt artifacts".to_owned(),
136 ),
137 _ => (
138 run::FailClass::Other("json".into()),
139 "invalid decision JSON or missing reason".to_owned(),
140 ),
141 };
142 let status = if parsed.is_some() {
143 "valid decision"
144 } else {
145 &reason
146 };
147 state.bump_decision_attempts.push(DecisionAttempt {
148 agent: spec.id.clone(),
149 outcome: status.to_owned(),
150 });
151 state.event(
152 "bump",
153 format!("decision attempt {attempt}: {}: {status}", spec.id),
154 );
155 if let Some(decision) = parsed {
156 return Ok((spec.clone(), seat, decision));
157 }
158 failures.push(format!("{}: {reason}", spec.id));
159 if let Some(next) = chain.get(n + 1) {
160 crate::graph::record_handover(
161 state, "bump", "bump", &spec.id, &next.id, &class, &reason,
162 );
163 }
164 }
165 bail!(
166 "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",
167 chain.len(),
168 failures.join("; ")
169 )
170}
171
172async fn guard_reason(
175 state: &mut RunState,
176 repo: &Path,
177 prompt: &str,
178 spec: &AgentSpec,
179 seat: &mut SeatState,
180 decision: &mut BumpDecision,
181) {
182 let artifacts = agent::artifacts_dir(&state.dir());
183 let mut violations =
184 crate::github_text::check_for(&state.config.graph, "", &decision.reason, None);
185 violations.retain(|v| *v != crate::github_text::Violation::SensitiveData);
187 if violations.is_empty() {
188 decision.reason = crate::scrub::scrub(&decision.reason, &crate::scrub::Identity::current());
189 }
190 if state.config.graph.github_text_guard && !violations.is_empty() {
191 state.event(
192 "github-text",
193 format!("release reason rejected: {violations:?}; requesting one rewrite"),
194 );
195 let rewrite = format!(
196 "{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: {}",
197 decision.reason
198 );
199 let retry = async {
200 let spec = agent::for_github_text(spec, &state.config.graph.github_language, repo)?;
201 agent::invoke(
202 &spec,
203 seat,
204 &Invocation {
205 coordination: state.config.coordination.as_ref(),
206 fetched_base: None,
207 cwd: repo,
208 prompt: &rewrite,
209 timeout: crate::graph::retry_budget(DECISION_TIMEOUT, true),
210 allow_write: false,
211 unsandboxed: false,
212 sessions: false,
213 artifacts: &artifacts,
214 stem: "bump-rewrite",
215 run: &state.id,
216 node: "bump",
217 cache_dir: state.config.cache_dir().as_deref(),
218 attachments: &[],
219 writable: &[],
220 },
221 )
222 .await
223 }
224 .await;
225 let reason = retry
226 .ok()
227 .filter(|o| o.usable())
228 .and_then(|o| parse_decision(&o.text).ok())
229 .filter(|d| {
230 d.level == decision.level
231 && crate::github_text::check_for(&state.config.graph, "", &d.reason, None)
232 .is_empty()
233 })
234 .map(|d| d.reason);
235 if let Some(reason) = reason {
236 decision.reason = reason;
237 state.event("github-text", "release reason rewrite passed");
238 } else {
239 decision.reason = "The merged change requires a release.".to_owned();
240 state.event(
241 "github-text",
242 "release reason rewrite unavailable or rejected; using neutral text",
243 );
244 }
245 }
246}
247
248const DECISION_TIMEOUT: Duration = Duration::from_secs(600);
256
257pub fn should_release_bump(status: RunStatus) -> bool {
273 status == RunStatus::Merged
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "lowercase")]
279pub enum BumpLevel {
280 Major,
282 Minor,
284 Patch,
286}
287
288impl BumpLevel {
289 pub fn as_str(self) -> &'static str {
291 match self {
292 Self::Major => "major",
293 Self::Minor => "minor",
294 Self::Patch => "patch",
295 }
296 }
297
298 fn severity(self) -> u8 {
303 match self {
304 Self::Patch => 0,
305 Self::Minor => 1,
306 Self::Major => 2,
307 }
308 }
309}
310
311#[derive(Debug, Clone, Deserialize)]
318pub struct BumpDecision {
319 pub level: BumpLevel,
321 pub reason: String,
324}
325
326pub fn parse_decision(text: &str) -> Result<BumpDecision> {
330 let decision: BumpDecision = verdict::extract_json(text)?;
331 if decision.reason.trim().is_empty() {
332 bail!("the bump decision carried no reason");
333 }
334 Ok(decision)
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
340pub struct Version {
341 pub major: u64,
343 pub minor: u64,
345 pub patch: u64,
347}
348
349impl Version {
350 pub fn parse(s: &str) -> Result<Self> {
355 let s = s.trim();
356 let mut parts = s.splitn(3, '.');
357 let major = parts
358 .next()
359 .with_context(|| format!("`{s}` has no major component"))?;
360 let minor = parts
361 .next()
362 .with_context(|| format!("`{s}` has no minor component"))?;
363 let patch = parts
364 .next()
365 .with_context(|| format!("`{s}` has no patch component"))?;
366 let patch_digits: String = patch.chars().take_while(char::is_ascii_digit).collect();
367 Ok(Self {
368 major: major
369 .trim()
370 .parse()
371 .with_context(|| format!("`{major}` is not a number"))?,
372 minor: minor
373 .trim()
374 .parse()
375 .with_context(|| format!("`{minor}` is not a number"))?,
376 patch: patch_digits
377 .parse()
378 .with_context(|| format!("`{patch}` has no numeric patch component"))?,
379 })
380 }
381
382 #[must_use]
385 pub fn bump(self, level: BumpLevel) -> Self {
386 match level {
387 BumpLevel::Major => Self {
388 major: self.major + 1,
389 minor: 0,
390 patch: 0,
391 },
392 BumpLevel::Minor => Self {
393 major: self.major,
394 minor: self.minor + 1,
395 patch: 0,
396 },
397 BumpLevel::Patch => Self {
398 major: self.major,
399 minor: self.minor,
400 patch: self.patch + 1,
401 },
402 }
403 }
404}
405
406impl std::fmt::Display for Version {
407 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
409 }
410}
411
412pub fn is_release_only(files: &[String]) -> bool {
421 !files.is_empty() && files.iter().all(|f| f == "Cargo.toml" || f == "Cargo.lock")
422}
423
424fn rewrite_table_version(toml: &str, table: &str, new_version: &str) -> Result<String> {
435 let mut out = String::with_capacity(toml.len() + 8);
436 let mut in_table = false;
437 let mut done = false;
438 for line in toml.split_inclusive('\n') {
439 let trimmed = line.trim();
440 if trimmed.starts_with('[') {
441 in_table = trimmed == table;
442 }
443 if !done && in_table && trimmed.split('=').next().map(str::trim) == Some("version") {
444 let newline = if line.ends_with("\r\n") { "\r\n" } else { "\n" };
445 let _ = write!(out, "version = \"{new_version}\"{newline}");
446 done = true;
447 continue;
448 }
449 out.push_str(line);
450 }
451 if !done {
452 bail!("no `version` field found under `{table}`");
453 }
454 Ok(out)
455}
456
457fn strip_trailing_comment(s: &str) -> &str {
463 let mut in_string = false;
464 let mut quote = '"';
465 for (i, c) in s.char_indices() {
466 if in_string {
467 if c == quote {
468 in_string = false;
469 }
470 } else if c == '"' || c == '\'' {
471 in_string = true;
472 quote = c;
473 } else if c == '#' {
474 return s[..i].trim_end();
475 }
476 }
477 s
478}
479
480fn unquote_key(key: &str) -> &str {
486 for quote in ['"', '\''] {
487 if let Some(inner) = key.strip_prefix(quote).and_then(|k| k.strip_suffix(quote)) {
488 return inner;
489 }
490 }
491 key
492}
493
494fn extract_workspace_dependencies_fragment(toml: &str) -> String {
506 let mut fragment = String::new();
507 let mut capturing = false;
508 for line in toml.split_inclusive('\n') {
509 let header = strip_trailing_comment(line.trim());
510 if header.starts_with('[') {
511 if header == "[workspace.dependencies]" {
512 capturing = true;
513 fragment.push_str("[dependencies]\n");
514 } else if let Some(name) = header
515 .strip_prefix("[workspace.dependencies.")
516 .and_then(|rest| rest.strip_suffix(']'))
517 {
518 capturing = true;
519 let _ = writeln!(fragment, "[dependencies.{name}]");
520 } else {
521 capturing = false;
522 }
523 continue;
524 }
525 if capturing {
526 fragment.push_str(line);
527 }
528 }
529 fragment
530}
531
532fn internal_pin_names(toml: &str) -> Result<Vec<String>> {
550 let fragment = extract_workspace_dependencies_fragment(toml);
551 if fragment.trim().is_empty() {
552 return Ok(Vec::new());
553 }
554 let value: toml::Value =
555 toml::from_str(&fragment).context("failed to parse `[workspace.dependencies]` as TOML")?;
556 let mut names: Vec<String> = value
557 .get("dependencies")
558 .and_then(|d| d.as_table())
559 .into_iter()
560 .flatten()
561 .filter(|(_, dep)| {
562 dep.as_table()
563 .is_some_and(|t| t.contains_key("path") && t.contains_key("version"))
564 })
565 .map(|(name, _)| name.clone())
566 .collect();
567 names.sort();
568 Ok(names)
569}
570
571fn rewrite_quoted_field(line: &str, key: &str, new_value: &str) -> Option<String> {
581 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-';
582 let mut search_from = 0;
583 while let Some(rel) = line[search_from..].find(key) {
584 let key_start = search_from + rel;
585 let before_ok = key_start == 0 || !is_ident(line[..key_start].chars().next_back().unwrap());
586 let eq_pos = key_start
587 + key.len()
588 + (line[key_start + key.len()..].len()
589 - line[key_start + key.len()..].trim_start().len());
590 if before_ok && line[eq_pos..].starts_with('=') {
591 let after_eq = &line[eq_pos + 1..];
592 let ws_len = after_eq.len() - after_eq.trim_start().len();
593 let quote_pos = eq_pos + 1 + ws_len;
594 if let Some(quote_char) = line[quote_pos..]
595 .chars()
596 .next()
597 .filter(|c| *c == '"' || *c == '\'')
598 {
599 let value_begin = quote_pos + quote_char.len_utf8();
600 if let Some(end_rel) = line[value_begin..].find(quote_char) {
601 let value_end = value_begin + end_rel;
602 let mut out = String::with_capacity(line.len());
603 out.push_str(&line[..value_begin]);
604 out.push_str(new_value);
605 out.push_str(&line[value_end..]);
606 return Some(out);
607 }
608 }
609 }
610 search_from = key_start + key.len();
611 }
612 None
613}
614
615fn rewrite_workspace_dependency_pins(
630 toml: &str,
631 names: &[String],
632 new_version: &str,
633) -> Result<String> {
634 if names.is_empty() {
635 return Ok(toml.to_owned());
636 }
637 let mut out = String::with_capacity(toml.len() + names.len() * 8);
638 let mut in_flat_table = false;
639 let mut in_named_table: Option<String> = None;
640 let mut rewritten: std::collections::HashSet<String> = std::collections::HashSet::new();
641
642 for line in toml.split_inclusive('\n') {
643 let trimmed = line.trim();
644 if trimmed.starts_with('[') {
645 let header = strip_trailing_comment(trimmed);
646 in_flat_table = header == "[workspace.dependencies]";
647 in_named_table = header
648 .strip_prefix("[workspace.dependencies.")
649 .and_then(|rest| rest.strip_suffix(']'))
650 .map(str::to_owned);
651 out.push_str(line);
652 continue;
653 }
654
655 let key = trimmed.split('=').next().map(str::trim).map(unquote_key);
656
657 if in_flat_table {
658 if let Some(name) = key.and_then(|k| names.iter().find(|n| n.as_str() == k)) {
660 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
661 rewritten.insert(name.clone());
662 out.push_str(&rewritten_line);
663 continue;
664 }
665 } else if let Some(name) = key.and_then(|k| {
666 k.strip_suffix(".version")
667 .and_then(|prefix| names.iter().find(|n| n.as_str() == prefix))
668 }) {
669 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
674 rewritten.insert(name.clone());
675 out.push_str(&rewritten_line);
676 continue;
677 }
678 }
679 } else if let Some(table_name) = &in_named_table {
680 if key == Some("version") && names.iter().any(|n| n == table_name) {
681 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
682 rewritten.insert(table_name.clone());
683 out.push_str(&rewritten_line);
684 continue;
685 }
686 }
687 }
688
689 out.push_str(line);
690 }
691
692 for name in names {
693 if !rewritten.contains(name) {
694 bail!(
695 "could not rewrite the `[workspace.dependencies]` version pin \
696 for `{name}` - its `version` field was not found in a shape \
697 this rewrite understands"
698 );
699 }
700 }
701 Ok(out)
702}
703
704fn verify_pins_rewritten(toml: &str, names: &[String], new_version: &str) -> Result<()> {
712 let fragment = extract_workspace_dependencies_fragment(toml);
713 let value: toml::Value = toml::from_str(&fragment)
714 .context("rewritten `[workspace.dependencies]` failed to parse")?;
715 let deps = value.get("dependencies").and_then(|d| d.as_table());
716 for name in names {
717 let actual = deps
718 .and_then(|d| d.get(name))
719 .and_then(|dep| dep.as_table())
720 .and_then(|t| t.get("version"))
721 .and_then(|v| v.as_str());
722 if actual != Some(new_version) {
723 bail!(
724 "the `[workspace.dependencies]` version pin for `{name}` did \
725 not end up at `{new_version}` after the rewrite"
726 );
727 }
728 }
729 Ok(())
730}
731
732pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
754 let rewritten = rewrite_table_version(toml, "[package]", new_version)
755 .or_else(|_| rewrite_table_version(toml, "[workspace.package]", new_version))
756 .context("no `version` field found under `[package]` or `[workspace.package]`")?;
757
758 let pin_names = internal_pin_names(&rewritten)?;
759 if pin_names.is_empty() {
760 return Ok(rewritten);
761 }
762
763 let rewritten = rewrite_workspace_dependency_pins(&rewritten, &pin_names, new_version)?;
764 verify_pins_rewritten(&rewritten, &pin_names, new_version)?;
765 Ok(rewritten)
766}
767
768fn version_in_table(toml: &str, table: &str) -> Option<String> {
770 let mut in_table = false;
771 for line in toml.lines() {
772 let trimmed = line.trim();
773 if trimmed.starts_with('[') {
774 in_table = trimmed == table;
775 continue;
776 }
777 if !in_table {
778 continue;
779 }
780 let mut parts = trimmed.splitn(2, '=');
781 let key = parts.next().map(str::trim);
782 let Some(value) = parts.next() else {
783 continue;
784 };
785 if key == Some("version") {
786 return Some(value.trim().trim_matches('"').to_owned());
787 }
788 }
789 None
790}
791
792pub(crate) fn current_version(toml: &str) -> Result<String> {
797 version_in_table(toml, "[package]")
798 .or_else(|| version_in_table(toml, "[workspace.package]"))
799 .context("no `version` field found under `[package]` or `[workspace.package]`")
800}
801
802async fn branch_version(repo: &Path, remote: &str, branch: &str, primary: &str) -> Option<String> {
806 let from_name = branch.strip_prefix("chore/release-v").map(str::to_owned);
807 let tracking = format!("refs/remotes/{remote}/{branch}");
810 let spec = format!("+refs/heads/{branch}:{tracking}");
811 let fetched = git::git_raw(repo, &["fetch", "--quiet", remote, &spec]).await;
812 if fetched.is_ok_and(|o| o.code == Some(0))
813 && let Ok(out) = git::git_raw(repo, &["show", &format!("{tracking}:{primary}")]).await
814 && out.code == Some(0)
815 && let Ok(v) = current_version(&out.stdout)
816 {
817 return Some(v);
818 }
819 from_name
820}
821
822pub fn decision_prompt(
832 subject: &str,
833 instruction: &str,
834 diffstat: &str,
835 files: &[String],
836 current_version: &str,
837) -> String {
838 let mut s = format!(
839 "A pull request just merged into the base branch. Decide which digit \
840 of this project's `major.minor.patch` version this change earns, so \
841 a release bump can be opened for exactly it.\n\n\
842 Current version: {current_version}\n\n\
843 # Merge subject\n\n{subject}\n\n\
844 # The task that produced it\n\n{instruction}\n\n\
845 # Files changed ({} total)\n\n",
846 files.len()
847 );
848 const MAX_FILES: usize = 50;
849 for f in files.iter().take(MAX_FILES) {
850 let _ = writeln!(s, "- {f}");
851 }
852 if files.len() > MAX_FILES {
853 let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
854 }
855 let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
856
857 s.push_str(
858 "\n# How to decide\n\n\
859 This project is below version `1.0.0`. At that stage **`minor` is \
860 the digit that carries a breaking change** - do not spend `major` \
861 below `1.0.0`.\n\n\
862 A change is breaking, and earns `minor`, when it changes any of: \
863 the public API reachable from `src/lib.rs`, a CLI subcommand or \
864 flag, an HTTP API route or response shape, a configuration key, or \
865 the on-disk shape of persisted state.\n\n\
866 A user-visible new capability that breaks none of the above also \
867 earns `minor`.\n\n\
868 A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
869 **When it is not obvious which digit applies, choose the larger \
870 one.** An oversized bump costs nothing; a breaking change shipped as \
871 `patch` breaks every downstream update that pins a range.\n\n\
872 # Output\n\n\
873 Reply with exactly one fenced JSON object and nothing that matters \
874 outside it:\n\n\
875 ```json\n\
876 {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
877 ```\n",
878 );
879 let _ = write!(
881 s,
882 "\n{}\n\nThe `reason` goes into a GitHub pull request body, so write it \
883 in English.\n",
884 crate::prompt::GITHUB_ENGLISH_HEADING
885 );
886 s
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize)]
905pub struct PendingBump {
906 pub target_version: String,
908 pub level: BumpLevel,
911 pub branch: String,
914 pub pr_url: String,
917}
918
919pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
923 let key = repo.to_string_lossy();
924 home.join("bump")
925 .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
926}
927
928pub fn read_marker(path: &Path) -> Option<PendingBump> {
932 let body = std::fs::read_to_string(path).ok()?;
933 serde_json::from_str(&body).ok()
934}
935
936pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
940 if let Some(parent) = path.parent() {
941 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
942 }
943 let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
944 let tmp = path.with_extension("json.tmp");
945 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
946 std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
947 Ok(())
948}
949
950pub fn clear_marker(path: &Path) {
953 let _ = std::fs::remove_file(path);
954}
955
956#[derive(Debug, Clone, PartialEq, Eq)]
960pub enum Coalesce {
961 Proceed,
964 Skip {
966 target_version: String,
968 },
969}
970
971pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
973 let Some(pending) = pending else {
974 return Ok(Coalesce::Proceed);
975 };
976 let current = Version::parse(current_version)?;
977 let target = Version::parse(&pending.target_version)?;
978 if current >= target {
979 return Ok(Coalesce::Proceed);
980 }
981 Ok(Coalesce::Skip {
982 target_version: pending.target_version.clone(),
983 })
984}
985
986#[derive(Debug, Clone, Copy, PartialEq, Eq)]
988pub enum PendingAction {
989 AlreadyCovered,
992 Escalate,
995}
996
997pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
1006 if decision_level.severity() > pending_level.severity() {
1007 PendingAction::Escalate
1008 } else {
1009 PendingAction::AlreadyCovered
1010 }
1011}
1012
1013fn parse_pr_state(json: &str) -> Result<bool> {
1015 #[derive(Deserialize)]
1016 struct State {
1017 state: String,
1018 }
1019 let parsed: State =
1020 serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
1021 Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
1022}
1023
1024async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
1037 let out = tokio::process::Command::new("gh")
1038 .args(["pr", "view", pr_url, "--json", "state"])
1039 .current_dir(repo)
1040 .quiet()
1041 .stdin(std::process::Stdio::null())
1042 .output()
1043 .await
1044 .context("spawn gh pr view")?;
1045 if !out.status.success() {
1046 bail!(
1047 "gh pr view {pr_url}: {}",
1048 String::from_utf8_lossy(&out.stderr).trim()
1049 );
1050 }
1051 parse_pr_state(&String::from_utf8_lossy(&out.stdout))
1052}
1053
1054const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
1062
1063struct MarkerLock {
1075 path: PathBuf,
1076}
1077
1078impl MarkerLock {
1079 #[cfg(test)]
1083 fn acquire(marker: &Path) -> Result<Option<Self>> {
1084 Self::acquire_with(marker, crate::lock_file::Retry::new(None))
1085 }
1086
1087 fn acquire_with(marker: &Path, retry: crate::lock_file::Retry) -> Result<Option<Self>> {
1088 let path = marker.with_extension("lock");
1089 if let Some(parent) = path.parent() {
1090 std::fs::create_dir_all(parent)
1091 .with_context(|| format!("create {}", parent.display()))?;
1092 }
1093 if Self::try_create(&path, retry)? {
1094 return Ok(Some(Self { path }));
1095 }
1096 if Self::is_stale(&path) {
1097 let _ = std::fs::remove_file(&path);
1098 if Self::try_create(&path, retry)? {
1099 return Ok(Some(Self { path }));
1100 }
1101 }
1102 Ok(None)
1103 }
1104
1105 fn try_create(path: &Path, retry: crate::lock_file::Retry) -> Result<bool> {
1106 match retry.create(path) {
1107 Ok(_) => Ok(true),
1108 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1109 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
1110 }
1111 }
1112
1113 fn is_stale(path: &Path) -> bool {
1114 std::fs::metadata(path)
1115 .and_then(|m| m.modified())
1116 .ok()
1117 .and_then(|m| m.elapsed().ok())
1118 .is_some_and(|age| age >= LOCK_STALE_AFTER)
1119 }
1120}
1121
1122impl Drop for MarkerLock {
1123 fn drop(&mut self) {
1124 let _ = std::fs::remove_file(&self.path);
1125 }
1126}
1127
1128const LOCK_POLL: Duration = Duration::from_secs(5);
1130
1131const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
1144
1145async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
1148 wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
1149}
1150
1151async fn wait_for_marker_lock_with(
1155 marker: &Path,
1156 poll: Duration,
1157 ceiling: Duration,
1158) -> Result<Option<MarkerLock>> {
1159 let deadline = std::time::Instant::now() + ceiling;
1160 let retry = crate::lock_file::Retry::new(Some(deadline));
1161 loop {
1162 let marker = marker.to_owned();
1164 let acquired =
1165 tokio::task::spawn_blocking(move || MarkerLock::acquire_with(&marker, retry))
1166 .await
1167 .context("join marker lock acquisition")??;
1168 if let Some(lock) = acquired {
1169 return Ok(Some(lock));
1170 }
1171 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1172 if remaining.is_zero() {
1173 return Ok(None);
1174 }
1175 tokio::time::sleep(poll.min(remaining)).await;
1176 }
1177}
1178
1179fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
1187 if to.major != from.major {
1188 Some(BumpLevel::Major)
1189 } else if to.minor != from.minor {
1190 Some(BumpLevel::Minor)
1191 } else if to.patch != from.patch {
1192 Some(BumpLevel::Patch)
1193 } else {
1194 None
1195 }
1196}
1197
1198fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
1201 #[derive(Deserialize)]
1202 struct Pr {
1203 url: String,
1204 #[serde(rename = "headRefName")]
1205 head_ref_name: String,
1206 }
1207 let list: Vec<Pr> =
1208 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
1209 Ok(list
1210 .into_iter()
1211 .find(|p| p.head_ref_name.starts_with("chore/release-v"))
1212 .map(|p| (p.head_ref_name, p.url)))
1213}
1214
1215pub(crate) fn parse_open_release_prs(json: &str) -> Result<Vec<(String, String)>> {
1218 #[derive(Deserialize)]
1219 struct Pr {
1220 url: String,
1221 #[serde(rename = "headRefName")]
1222 head_ref_name: String,
1223 }
1224 let list: Vec<Pr> =
1225 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
1226 Ok(list
1227 .into_iter()
1228 .filter(|p| p.head_ref_name.starts_with("chore/release-v"))
1229 .map(|p| (p.head_ref_name, p.url))
1230 .collect())
1231}
1232
1233pub(crate) async fn list_open_release_prs(repo: &Path) -> Result<Vec<(String, String)>> {
1235 let out = tokio::process::Command::new("gh")
1236 .args([
1237 "pr",
1238 "list",
1239 "--state",
1240 "open",
1241 "--limit",
1242 "100",
1243 "--json",
1244 "url,headRefName",
1245 ])
1246 .current_dir(repo)
1247 .env_remove("GH_REPO")
1248 .quiet()
1249 .stdin(std::process::Stdio::null())
1250 .output()
1251 .await
1252 .context("spawn gh pr list")?;
1253 if !out.status.success() {
1254 bail!(
1255 "gh pr list: {}",
1256 String::from_utf8_lossy(&out.stderr).trim()
1257 );
1258 }
1259 parse_open_release_prs(&String::from_utf8_lossy(&out.stdout))
1260}
1261
1262async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
1277 let out = tokio::process::Command::new("gh")
1278 .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
1279 .current_dir(repo)
1280 .quiet()
1281 .stdin(std::process::Stdio::null())
1282 .output()
1283 .await
1284 .context("spawn gh pr list")?;
1285 if !out.status.success() {
1286 bail!(
1287 "gh pr list: {}",
1288 String::from_utf8_lossy(&out.stderr).trim()
1289 );
1290 }
1291 parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
1292}
1293
1294pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
1314 after_merge_at(state, pr_url, None, None).await
1315}
1316
1317#[derive(Debug, Default)]
1319struct Progress {
1320 version: Option<String>,
1322 pending_pr: Option<String>,
1324 skip: Option<Skip>,
1326}
1327
1328#[derive(Debug)]
1331struct Skip {
1332 reason: String,
1334 action: Option<String>,
1337}
1338
1339fn record_skip(state: &mut RunState, store: Option<&Notices>, skip: &Skip) {
1341 let record = state.release_bump.get_or_insert_with(Default::default);
1342 record.skipped = Some(skip.reason.clone());
1343 let Some(action) = &skip.action else {
1344 return;
1345 };
1346 record.action_required = Some(action.clone());
1347 let notice = Notice::error(
1348 &format!("release-bump:{}", state.id),
1349 format!(
1350 "Run {} merged, but its release bump was skipped: {action}.",
1351 state.id
1352 ),
1353 )
1354 .link(Link::Run {
1355 id: state.id.clone(),
1356 });
1357 let store = store.cloned().unwrap_or_else(Notices::open);
1358 if let Err(e) = store.raise_with(notice, &state.config.notify) {
1359 state.event("bump", format!("could not raise a notice: {e:#}"));
1360 }
1361 state.event("bump", format!("needs attention: {action}"));
1362}
1363
1364fn notice_for_failure(progress: &Progress, err: &anyhow::Error) -> (Option<String>, String) {
1370 let chain = format!("{err:#}");
1371 let first = chain.lines().next().unwrap_or_default().trim();
1372 (progress.version.clone(), first.to_owned())
1373}
1374
1375#[cfg(test)]
1381async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
1382 base_has_file(repo, remote, base, release_manifest::DEFAULT_PRIMARY).await
1383}
1384
1385async fn base_has_file(repo: &Path, remote: &str, base: &str, path: &str) -> Result<bool> {
1387 let out = git::git(
1388 repo,
1389 &[
1390 "ls-tree",
1391 "--name-only",
1392 &format!("{remote}/{base}"),
1393 "--",
1394 path,
1395 ],
1396 )
1397 .await
1398 .with_context(|| format!("look for {path} on the base branch"))?;
1399 Ok(!out.trim().is_empty())
1400}
1401
1402async fn change_base(dir: &Path, remote: &str, base: &str, branch: &str) -> Option<String> {
1406 let tip = git::rev_parse(dir, branch).await.ok()?;
1407 let mb = git::git(dir, &["merge-base", &format!("{remote}/{base}"), branch])
1408 .await
1409 .ok()?;
1410 let mb = mb.trim().to_owned();
1411 (!mb.is_empty() && mb != tip).then_some(mb)
1412}
1413
1414async fn primary_version_at(dir: &Path, rev: &str, path: &str) -> Option<String> {
1417 let out = git::git_raw(dir, &["show", &format!("{rev}:{path}")])
1418 .await
1419 .ok()?;
1420 if out.code != Some(0) {
1421 return None;
1422 }
1423 current_version(&out.stdout).ok()
1424}
1425
1426async fn after_merge_at(
1430 state: &mut RunState,
1431 pr_url: &str,
1432 home: Option<&Path>,
1433 store: Option<&Notices>,
1434) -> Result<()> {
1435 let mut progress = Progress::default();
1436 let result = after_merge_inner(state, pr_url, home, &mut progress).await;
1437 if let Some(skip) = &progress.skip {
1438 record_skip(state, store, skip);
1439 }
1440 if let Err(e) = &result {
1441 let (version, reason) = notice_for_failure(&progress, e);
1442 let pending = progress.pending_pr.as_deref();
1443 let store = store.cloned().unwrap_or_else(Notices::open);
1444 report_problem_in(
1445 state,
1446 &store,
1447 pending,
1448 version.as_deref(),
1449 &reason,
1450 pending.is_some(),
1451 )
1452 .await;
1453 }
1454 result
1455}
1456
1457async fn after_merge_inner(
1458 state: &mut RunState,
1459 pr_url: &str,
1460 home: Option<&Path>,
1461 progress: &mut Progress,
1462) -> Result<()> {
1463 if !state.config.merge.release_bump {
1464 return Ok(());
1465 }
1466 let Some(winner) = state.winner().cloned() else {
1467 return Ok(());
1468 };
1469 let repo = state.repo.clone();
1470 let base = state.base_branch.clone();
1471 let remote = state.config.merge.remote.clone();
1472
1473 let files = git::changed_files(&winner.worktree, &base, &winner.branch)
1474 .await
1475 .unwrap_or_default();
1476 let declared = state.config.release.version_files.clone();
1477 let custom = release_manifest::is_custom(&declared);
1478 let primary = release_manifest::primary_path(&declared).to_owned();
1479 if release_manifest::release_only(&files, &declared) {
1480 let mb = change_base(&winner.worktree, &remote, &base, &winner.branch).await;
1484 let unchanged = custom
1485 && match mb {
1486 Some(mb) => matches!(
1487 (
1488 primary_version_at(&winner.worktree, &mb, &primary).await,
1489 primary_version_at(&winner.worktree, &winner.branch, &primary).await,
1490 ),
1491 (Some(before), Some(after)) if before == after
1492 ),
1493 None => false,
1494 };
1495 if !unchanged {
1496 state.event(
1497 "bump",
1498 "the merged change touches only the release manifest; not treating it as a trigger",
1499 );
1500 progress.skip = Some(Skip {
1501 reason: "the merged change touches only the release files; not a trigger"
1502 .to_owned(),
1503 action: None,
1504 });
1505 return Ok(());
1506 }
1507 }
1508
1509 git::fetch(&repo, &remote, &base).await.ok();
1512 if !base_has_file(&repo, &remote, &base, &primary).await? {
1513 state.event(
1514 "bump",
1515 if custom {
1516 format!(
1517 "release bump: no {primary} on the base branch (from [release] version_files); skipping"
1518 )
1519 } else {
1520 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
1521 .to_owned()
1522 },
1523 );
1524 let releases_by_workflow =
1527 base_has_file(&repo, &remote, &base, ".github/workflows/auto-tag.yml")
1528 .await
1529 .unwrap_or(false);
1530 let ja = crate::lang::is_japanese(&state.config.graph.language);
1531 let action = releases_by_workflow.then(|| {
1532 if ja {
1533 format!(
1534 "base ブランチに {primary} がありません。auto-tag.yml があるリポジトリです。magi.toml の [release] version_files に版の正本 (auto-tag.yml が読む Cargo.toml) を指定するか、リリースを手で行ってください"
1535 )
1536 } else {
1537 format!(
1538 "the base branch has no {primary} but does have .github/workflows/auto-tag.yml; set [release] version_files in magi.toml to the version of record auto-tag.yml reads, or release by hand"
1539 )
1540 }
1541 });
1542 progress.skip = Some(Skip {
1543 reason: format!("no {primary} on the base branch; no release bump was made"),
1544 action,
1545 });
1546 return Ok(());
1547 }
1548
1549 let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
1550 let Some(_lock) = wait_for_marker_lock(&marker).await? else {
1557 state.event(
1558 "bump",
1559 "another release bump decision held the lock past the wait ceiling; skipping this round",
1560 );
1561 return Ok(());
1562 };
1563
1564 git::fetch(&repo, &remote, &base).await.ok();
1565 let mut versions = Vec::new();
1566 for f in release_manifest::effective(&declared) {
1567 let text = git::git(&repo, &["show", &format!("{remote}/{base}:{}", f.path)])
1568 .await
1569 .with_context(|| format!("read {} from the base branch", f.path))?;
1570 let v = release_manifest::read_version(f.kind, &text)
1571 .with_context(|| format!("read the version of {}", f.path))?;
1572 versions.push((f.path, v));
1573 }
1574 let base_version = match release_manifest::agree(&versions) {
1575 Ok(v) => v,
1576 Err(e) => {
1577 let reason = format!("{e:#}");
1578 state.event("bump", format!("release bump skipped: {reason}"));
1579 progress.skip = Some(Skip {
1580 action: Some(format!("{reason}; align them by hand, then release")),
1581 reason,
1582 });
1583 return Ok(());
1584 }
1585 };
1586 if custom && !state.config.release.is_local() {
1589 let mb = change_base(&winner.worktree, &remote, &base, &winner.branch)
1595 .await
1596 .unwrap_or_default();
1597 if !mb.is_empty()
1598 && let (Some(before), Some(after)) = (
1599 primary_version_at(&winner.worktree, &mb, &primary).await,
1600 primary_version_at(&winner.worktree, &winner.branch, &primary).await,
1601 )
1602 && before != after
1603 {
1604 state.event(
1605 "bump",
1606 format!(
1607 "the merged change already moved {primary} from v{before} to v{after}; leaving the release to the repository's auto-tag"
1608 ),
1609 );
1610 progress.skip = Some(Skip {
1611 reason: format!(
1612 "the change itself raised {primary} from v{before} to v{after}; the repository's auto-tag releases it"
1613 ),
1614 action: None,
1615 });
1616 return Ok(());
1617 }
1618 }
1619
1620 let mut pending = read_marker(&marker);
1621 if let Some(p) = &pending {
1622 match coalesce(Some(p), &base_version)? {
1623 Coalesce::Proceed => {
1624 clear_marker(&marker);
1627 pending = None;
1628 }
1629 Coalesce::Skip { target_version } => {
1630 if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
1631 state.event(
1632 "bump",
1633 format!(
1634 "the pending release bump to v{target_version} ({}) is no longer \
1635 open; treating it as abandoned",
1636 p.pr_url
1637 ),
1638 );
1639 clear_marker(&marker);
1640 pending = None;
1641 }
1642 }
1647 }
1648 }
1649
1650 if pending.is_none() {
1651 if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
1656 && let Some(target) = branch_version(&repo, &remote, &branch, &primary)
1657 .await
1658 .and_then(|v| Version::parse(&v).ok())
1659 {
1660 let base_parsed = Version::parse(&base_version)?;
1661 if target > base_parsed
1662 && let Some(level) = level_between(base_parsed, target)
1663 {
1664 let adopted = PendingBump {
1665 target_version: target.to_string(),
1666 level,
1667 branch,
1668 pr_url: url,
1669 };
1670 let _ = write_marker(&marker, &adopted);
1673 pending = Some(adopted);
1674 }
1675 }
1676 }
1677
1678 progress.pending_pr = pending.as_ref().map(|p| p.pr_url.clone());
1679 let title = pr_title(&repo, pr_url).await.unwrap_or_default();
1680 let subject = land::merge_subject(
1681 crate::graph::landing_title(state, &title),
1682 &crate::graph::landing_subject_source(state),
1683 );
1684 let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
1685 .await
1686 .unwrap_or_default();
1687 let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
1688
1689 let (spec, mut seat, mut decision) =
1690 ask_decision(state, &repo, &prompt, DECISION_TIMEOUT).await?;
1691 guard_reason(state, &repo, &prompt, &spec, &mut seat, &mut decision).await;
1692
1693 if let Some(p) = pending {
1694 return match pending_action(p.level, decision.level) {
1695 PendingAction::AlreadyCovered => {
1696 state.event(
1697 "bump",
1698 format!(
1699 "a release bump to v{} ({}) already covers at least a {} change; not \
1700 opening another",
1701 p.target_version,
1702 p.pr_url,
1703 decision.level.as_str()
1704 ),
1705 );
1706 Ok(())
1707 }
1708 PendingAction::Escalate => {
1709 progress.version = Some(
1710 Version::parse(&base_version)?
1711 .bump(decision.level)
1712 .to_string(),
1713 );
1714 escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
1715 }
1716 };
1717 }
1718
1719 let next = Version::parse(&base_version)?
1720 .bump(decision.level)
1721 .to_string();
1722 progress.version = Some(next.clone());
1723 let branch = format!("chore/release-v{next}");
1724 let worktree = state.dir().join("bump");
1725 let (shared, branch_ref, next_ref, decision_ref) = (&mut *state, &branch, &next, &decision);
1726 let (pr_url_opened, outcome) =
1727 release_attempt(
1728 &repo,
1729 &remote,
1730 &base,
1731 &worktree,
1732 &branch,
1733 |head| {
1734 let repo = repo.clone();
1735 async move { gh_open_pr_for_head(&repo, &head).await }
1736 },
1737 |wt| async move {
1738 open_bump_pr(shared, &wt, branch_ref, next_ref, decision_ref, pr_url).await
1739 },
1740 )
1741 .await?;
1742 let local = outcome == AutomergeOutcome::LocalGate;
1743 let (automerge_warning, merged_detail) = match outcome {
1744 AutomergeOutcome::Enabled | AutomergeOutcome::LocalGate => (None, None),
1745 AutomergeOutcome::MergedDirectly { detail } => (None, Some(detail)),
1746 AutomergeOutcome::Failed { reason } => (Some(reason), None),
1747 };
1748
1749 let marker_write = write_marker(
1758 &marker,
1759 &PendingBump {
1760 target_version: next.clone(),
1761 level: decision.level,
1762 branch,
1763 pr_url: pr_url_opened.clone(),
1764 },
1765 );
1766 state.event(
1767 "bump",
1768 format!(
1769 "opened a {} release bump to v{next} ({}): {pr_url_opened}",
1770 decision.level.as_str(),
1771 decision.reason
1772 ),
1773 );
1774 if let Err(e) = marker_write {
1775 state.event(
1776 "bump",
1777 format!(
1778 "could not record the pending release bump marker for v{next}: {e:#}; a later \
1779 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
1780 the forge either"
1781 ),
1782 );
1783 }
1784 state.release_bump = Some(run::ReleaseBump {
1785 pr_url: Some(pr_url_opened.clone()),
1786 version: Some(next.clone()),
1787 automerge_enabled: automerge_warning.is_none() && merged_detail.is_none() && !local,
1788 merged_directly: merged_detail.is_some(),
1789 local,
1790 ..run::ReleaseBump::default()
1791 });
1792 if let Some(detail) = merged_detail {
1793 clear_marker(&marker);
1796 state.event("bump", format!("merged v{next} directly: {detail}"));
1797 }
1798 if let Some(warning) = automerge_warning {
1799 state.event(
1800 "bump",
1801 format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
1802 );
1803 report_problem(state, Some(&pr_url_opened), Some(&next), &warning).await;
1804 }
1805 Ok(())
1806}
1807
1808pub const NOTICE_NODE: &str = "release-bump";
1813
1814fn automerge_hint(reason: &str) -> &'static str {
1821 let r = reason.to_lowercase();
1822 if is_clean_status_refusal(reason) {
1823 "merge the release pull request by hand; CI is already green"
1824 } else if r.contains("enablepullrequestautomerge") || r.contains("protected branch rules") {
1825 "merge the release pull request by hand, and enable branch protection with required \
1826 status checks on the base branch so automerge can work next time"
1827 } else {
1828 "merge the release pull request by hand"
1829 }
1830}
1831
1832fn automerge_failure_comment(reason: &str) -> String {
1834 format!(
1835 "magi could not enable automerge on this pull request: {reason}\n\n\
1836 Action required: {}. Until then the release does not happen.",
1837 automerge_hint(reason)
1838 )
1839}
1840
1841#[cfg(test)]
1849fn surface_problem(
1850 state: &mut RunState,
1851 store: &Notices,
1852 pr_url: Option<&str>,
1853 version: Option<&str>,
1854 reason: &str,
1855) -> Result<(Notice, Option<String>)> {
1856 surface_problem_in(state, store, pr_url, version, reason, false)
1857}
1858
1859fn surface_problem_in(
1863 state: &mut RunState,
1864 store: &Notices,
1865 pr_url: Option<&str>,
1866 version: Option<&str>,
1867 reason: &str,
1868 pending: bool,
1869) -> Result<(Notice, Option<String>)> {
1870 let action = if let (true, Some(url)) = (pending, pr_url) {
1871 let ja = crate::lang::is_japanese(&state.config.graph.language);
1872 let first = reason.lines().next().filter(|l| !l.is_empty());
1873 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1874 if ja {
1875 let target = version.map(|v| format!(" v{v}")).unwrap_or_default();
1876 format!(
1877 "既存のリリース PR {url} を{target}へ更新できませんでした{cause}。PR を手で更新してください"
1878 )
1879 } else {
1880 let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1881 format!(
1882 "the pending release pull request {url} could not be updated{target}{cause}; update it by hand"
1883 )
1884 }
1885 } else if pr_url.is_some() {
1886 automerge_hint(reason).to_owned()
1887 } else {
1888 let ja = crate::lang::is_japanese(&state.config.graph.language);
1889 let first = reason.lines().next().filter(|l| !l.is_empty());
1890 if ja {
1891 let target = version.map(|v| format!(" (v{v})")).unwrap_or_default();
1892 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1893 format!(
1894 "リリースバンプ{target}は実行されませんでした{cause}。リリース PR を手で開いてください"
1895 )
1896 } else {
1897 let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1898 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1899 format!(
1900 "the release bump{target} did not run{cause}; open the release pull request by hand"
1901 )
1902 }
1903 };
1904 let record = state.release_bump.get_or_insert_with(Default::default);
1905 record.pr_url = pr_url.map(str::to_owned).or(record.pr_url.take());
1906 record.version = version.map(str::to_owned).or(record.version.take());
1907 record.automerge_enabled = false;
1908 record.problem = Some(reason.to_owned());
1909 record.action_required = Some(action.clone());
1910
1911 let mut notice = Notice::error(
1912 &format!("release-bump:{}", state.id),
1913 format!(
1914 "Run {} merged, but its release step failed: {action}.",
1915 state.id
1916 ),
1917 );
1918 notice = match pr_url {
1919 Some(url) => notice.link(Link::Url {
1920 url: url.to_owned(),
1921 }),
1922 None => notice.link(Link::Run {
1923 id: state.id.clone(),
1924 }),
1925 };
1926 let notice = store
1927 .raise_with(
1928 notice
1929 .about([state.id.clone()])
1930 .for_run(Some(state.id.clone())),
1931 &state.config.notify,
1932 )
1933 .context("raise the release-bump notification")?;
1934 state.event("bump", format!("needs attention: {action}"));
1935 let comment = pr_url
1936 .filter(|_| !pending)
1937 .map(|_| automerge_failure_comment(reason));
1938 Ok((notice, comment))
1939}
1940
1941pub async fn report_problem(
1946 state: &mut RunState,
1947 pr_url: Option<&str>,
1948 version: Option<&str>,
1949 reason: &str,
1950) {
1951 report_problem_in(state, &Notices::open(), pr_url, version, reason, false).await;
1952}
1953
1954async fn report_problem_in(
1956 state: &mut RunState,
1957 store: &Notices,
1958 pr_url: Option<&str>,
1959 version: Option<&str>,
1960 reason: &str,
1961 pending: bool,
1962) {
1963 match surface_problem_in(state, store, pr_url, version, reason, pending) {
1964 Ok((_notice, comment)) => {
1965 if let (Some(url), Some(body)) = (pr_url, comment)
1966 && let Err(e) = gh_pr_comment(state, url, &body).await
1967 {
1968 state.event("bump", format!("could not comment on {url}: {e:#}"));
1969 }
1970 }
1971 Err(e) => state.event("bump", format!("could not raise a notice: {e:#}")),
1972 }
1973}
1974
1975pub(crate) async fn gh_pr_comment(state: &mut RunState, pr_url: &str, body: &str) -> Result<()> {
1976 let (_, body) = crate::github_text::prepare(state, "", body);
1977 let cwd = &state.repo;
1978 let out = tokio::process::Command::new("gh")
1979 .args(["pr", "comment", pr_url, "--body", &body])
1980 .current_dir(cwd)
1981 .quiet()
1982 .stdin(std::process::Stdio::null())
1983 .output()
1984 .await
1985 .context("spawn gh pr comment")?;
1986 if out.status.success() {
1987 Ok(())
1988 } else {
1989 bail!(
1990 "gh pr comment: {}",
1991 String::from_utf8_lossy(&out.stderr).trim()
1992 )
1993 }
1994}
1995
1996async fn escalate_pending(
2005 state: &mut RunState,
2006 repo: &Path,
2007 remote: &str,
2008 pending: &PendingBump,
2009 decision: &BumpDecision,
2010 base_version: &str,
2011 marker: &Path,
2012) -> Result<()> {
2013 let next = Version::parse(base_version)?
2014 .bump(decision.level)
2015 .to_string();
2016 let worktree = state.dir().join("bump");
2017 git::worktree_remove(repo, &worktree).await.ok();
2018 let checked_out = git::git_raw(
2019 repo,
2020 &[
2021 "worktree",
2022 "add",
2023 "--force",
2024 &worktree.to_string_lossy(),
2025 &pending.branch,
2026 ],
2027 )
2028 .await?;
2029 if !checked_out.ok() {
2030 bail!(
2031 "checking out the pending release branch {} failed: {}",
2032 pending.branch,
2033 checked_out.stderr
2034 );
2035 }
2036
2037 let pushed: Result<()> = async {
2042 write_release_files(state, &worktree, &next).await?;
2043 let committed = git::commit_all(
2044 &worktree,
2045 &format!(
2046 "chore: release v{next} (supersedes v{})",
2047 pending.target_version
2048 ),
2049 )
2050 .await
2051 .context("commit the escalated version bump")?;
2052 if !committed {
2053 bail!("escalating the version bump left nothing to commit");
2054 }
2055 let pushed = git::push(&worktree, remote, &pending.branch).await?;
2056 if !pushed.ok() {
2057 bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
2058 }
2059 Ok(())
2060 }
2061 .await;
2062 if let Err(e) = pushed {
2063 git::worktree_remove(repo, &worktree).await.ok();
2064 return Err(e);
2065 }
2066
2067 let (title, _) = crate::github_text::prepare(
2071 state,
2072 &format!("chore: release v{next} ({} bump)", decision.level.as_str()),
2073 "",
2074 );
2075 let title_warning = match gh_pr_edit_title(&worktree, &pending.pr_url, &title).await {
2076 Ok(()) => None,
2077 Err(e) => Some(e.to_string()),
2078 };
2079 git::worktree_remove(repo, &worktree).await.ok();
2080
2081 let marker_write = write_marker(
2082 marker,
2083 &PendingBump {
2084 target_version: next.clone(),
2085 level: decision.level,
2086 branch: pending.branch.clone(),
2087 pr_url: pending.pr_url.clone(),
2088 },
2089 );
2090 state.event(
2091 "bump",
2092 format!(
2093 "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
2094 pending.target_version,
2095 decision.level.as_str(),
2096 decision.reason,
2097 pending.pr_url
2098 ),
2099 );
2100 if let Err(e) = marker_write {
2101 state.event(
2102 "bump",
2103 format!(
2104 "could not update the pending release bump marker to v{next}: {e:#}; a later \
2105 merge may misjudge whether it is already covered"
2106 ),
2107 );
2108 }
2109 if let Some(warning) = title_warning {
2110 state.event(
2111 "bump",
2112 format!(
2113 "pushed v{next} to {} but could not update its title: {warning}; the squashed \
2114 subject may still read the superseded version",
2115 pending.pr_url
2116 ),
2117 );
2118 }
2119 Ok(())
2120}
2121
2122async fn gh_open_pr_for_head(repo: &Path, branch: &str) -> Result<bool> {
2125 let out = tokio::process::Command::new("gh")
2126 .args([
2127 "pr", "list", "--head", branch, "--state", "open", "--json", "url",
2128 ])
2129 .current_dir(repo)
2130 .quiet()
2131 .stdin(std::process::Stdio::null())
2132 .output()
2133 .await
2134 .context("spawn gh pr list")?;
2135 if !out.status.success() {
2136 bail!(
2137 "gh pr list --head {branch}: {}",
2138 String::from_utf8_lossy(&out.stderr).trim()
2139 );
2140 }
2141 let prs: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout)
2142 .with_context(|| format!("parse the pull requests headed by {branch}"))?;
2143 Ok(!prs.is_empty())
2144}
2145
2146async fn release_attempt<T, F, Fut, P, PFut>(
2155 repo: &Path,
2156 remote: &str,
2157 base: &str,
2158 worktree: &Path,
2159 branch: &str,
2160 open_pr: P,
2161 fill: F,
2162) -> Result<T>
2163where
2164 F: FnOnce(PathBuf) -> Fut,
2165 Fut: std::future::Future<Output = Result<T>>,
2166 P: Fn(String) -> PFut,
2167 PFut: std::future::Future<Output = Result<bool>>,
2168{
2169 let start = format!("{remote}/{base}");
2170 git::worktree_remove(repo, worktree).await.ok();
2171 let mut retried = false;
2172 if git::branch_exists(repo, branch).await? {
2173 reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await?;
2174 retried = true;
2175 }
2176 loop {
2177 if let Err(e) = git::git(repo, &["branch", branch, &start]).await {
2182 let e = e.context("create the release branch");
2183 if !git::branch_exists(repo, branch).await.unwrap_or(false) {
2184 return Err(e);
2185 }
2186 if retried {
2188 return Err(anyhow!(
2189 "{e:#}; left {branch} in place: it is not one this attempt created"
2190 ));
2191 }
2192 retried = true;
2193 if let Err(r) = reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await {
2194 return Err(anyhow!("{r:#} (after: {e:#})"));
2195 }
2196 continue;
2197 }
2198 if let Some(parent) = worktree.parent() {
2199 tokio::fs::create_dir_all(parent).await.ok();
2200 }
2201 let path = worktree.to_string_lossy();
2202 if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
2203 let e = e.context("create the release-bump worktree");
2204 return Err(discard_attempt(repo, remote, worktree, branch, e).await);
2205 }
2206 break;
2207 }
2208 match fill(worktree.to_path_buf()).await {
2209 Ok(v) => {
2210 git::worktree_remove(repo, worktree).await.ok();
2213 Ok(v)
2214 }
2215 Err(e) => Err(discard_attempt(repo, remote, worktree, branch, e).await),
2216 }
2217}
2218
2219async fn discard_attempt(
2221 repo: &Path,
2222 remote: &str,
2223 worktree: &Path,
2224 branch: &str,
2225 cause: anyhow::Error,
2226) -> anyhow::Error {
2227 git::worktree_remove(repo, worktree).await.ok();
2228 if !git::branch_exists(repo, branch).await.unwrap_or(false) {
2229 return cause;
2230 }
2231 let note = match git::remote_has_branch(repo, remote, branch).await {
2234 Ok(false) => match git::branch_delete(repo, branch).await {
2235 Ok(true) => return cause,
2236 _ => format!("could not delete the local branch {branch}"),
2237 },
2238 Ok(true) => format!("left {branch} in place: it was pushed to {remote}"),
2239 Err(e) => format!(
2240 "left {branch} in place: could not tell whether it was pushed to {remote} ({e:#})"
2241 ),
2242 };
2243 anyhow!("{cause:#}; {note}")
2244}
2245
2246async fn reclaim_stale_branch<P, PFut>(
2251 repo: &Path,
2252 remote: &str,
2253 start: &str,
2254 branch: &str,
2255 open_pr: &P,
2256) -> Result<()>
2257where
2258 P: Fn(String) -> PFut,
2259 PFut: std::future::Future<Output = Result<bool>>,
2260{
2261 match git::remote_has_branch(repo, remote, branch).await {
2262 Ok(false) => {}
2263 Ok(true) => bail!("the branch {branch} already exists and is on {remote}; left alone"),
2264 Err(e) => bail!(
2265 "the branch {branch} already exists and could not check whether {remote} has it ({e:#}); left alone"
2266 ),
2267 }
2268 match open_pr(branch.to_string()).await {
2269 Ok(false) => {}
2270 Ok(true) => {
2271 bail!("the branch {branch} already exists and has an open pull request; left alone")
2272 }
2273 Err(e) => bail!(
2274 "the branch {branch} already exists and could not check for an open pull request \
2275 ({e:#}); left alone"
2276 ),
2277 }
2278 match git::commits_ahead(repo, start, branch).await {
2279 Ok(0) => {}
2280 Ok(n) => bail!(
2281 "the branch {branch} already exists with {n} commit(s) not in {start}; left alone"
2282 ),
2283 Err(e) => bail!(
2284 "the branch {branch} already exists and could not compare it with {start} ({e:#}); \
2285 left alone"
2286 ),
2287 }
2288 if let Some(held) = git::worktree_holding(repo, branch).await? {
2289 let same = |a: &Path, b: &Path| match (a.canonicalize(), b.canonicalize()) {
2290 (Ok(a), Ok(b)) => a == b,
2291 _ => a == b,
2292 };
2293 if same(&held, repo) {
2294 bail!(
2295 "the branch {branch} already exists and is checked out in the main checkout; left alone"
2296 );
2297 }
2298 git::worktree_remove(repo, &held).await.ok();
2299 }
2300 if !git::branch_delete(repo, branch).await? {
2301 bail!("the branch {branch} already exists and could not be deleted; left alone");
2302 }
2303 Ok(())
2304}
2305
2306async fn open_bump_pr(
2312 state: &mut RunState,
2313 worktree: &Path,
2314 branch: &str,
2315 next_version: &str,
2316 decision: &BumpDecision,
2317 source_pr_url: &str,
2318) -> Result<(String, AutomergeOutcome)> {
2319 write_release_files(state, worktree, next_version).await?;
2320
2321 let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
2322 .await
2323 .context("commit the version bump")?;
2324 if !committed {
2325 bail!("the version bump left nothing to commit");
2326 }
2327
2328 let remote = state.config.merge.remote.clone();
2329 let pushed = git::push(worktree, &remote, branch).await?;
2330 if !pushed.ok() {
2331 bail!("pushing {branch} failed: {}", pushed.stderr);
2332 }
2333
2334 let (title, body) = release_pr(
2335 decision.level.as_str(),
2336 &decision.reason,
2337 next_version,
2338 &state.id,
2339 source_pr_url,
2340 );
2341 let (title, body) = crate::github_text::prepare(state, &title, &body);
2342 let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
2343 if state.config.release.is_local() {
2347 crate::release_watch::register(&crate::run::home(), &state.repo, &url, &state.id);
2350 return Ok((url, AutomergeOutcome::LocalGate));
2351 }
2352 let outcome = match gh_enable_automerge(worktree, &url).await {
2353 Ok(()) => AutomergeOutcome::Enabled,
2354 Err(e) => {
2355 let reason = e.to_string();
2356 if is_clean_status_refusal(&reason) {
2357 gh_merge_directly(worktree, &url, &title, reason).await
2358 } else {
2359 AutomergeOutcome::Failed { reason }
2360 }
2361 }
2362 };
2363 Ok((url, outcome))
2364}
2365
2366#[derive(Debug, Clone, PartialEq, Eq)]
2368enum AutomergeOutcome {
2369 Enabled,
2371 MergedDirectly { detail: String },
2373 LocalGate,
2375 Failed { reason: String },
2377}
2378
2379fn is_clean_status_refusal(reason: &str) -> bool {
2385 let r = reason.to_lowercase();
2386 r.contains("is in clean status") && r.contains("enablepullrequestautomerge")
2387}
2388
2389pub(crate) fn bump_merge_argv(pr_url: &str, subject: &str) -> Vec<String> {
2392 [
2393 "pr",
2394 "merge",
2395 pr_url,
2396 "--squash",
2397 "--delete-branch",
2398 "--subject",
2399 subject,
2400 ]
2401 .map(str::to_owned)
2402 .to_vec()
2403}
2404
2405fn resolve_direct_merge(
2409 refusal: &str,
2410 argv: &[String],
2411 merge_ok: bool,
2412 stderr: &str,
2413 after: Option<land::PrLifecycle>,
2414) -> AutomergeOutcome {
2415 if merge_ok {
2416 return AutomergeOutcome::MergedDirectly {
2417 detail: format!("automerge was refused ({refusal}); gh {}", argv.join(" ")),
2418 };
2419 }
2420 match land::merged_after_all(argv, stderr, after) {
2421 Some(m) => AutomergeOutcome::MergedDirectly { detail: m.detail },
2422 None => AutomergeOutcome::Failed {
2423 reason: format!("{refusal}; merging directly failed too: {}", stderr.trim()),
2424 },
2425 }
2426}
2427
2428async fn gh_merge_directly(
2432 cwd: &Path,
2433 pr_url: &str,
2434 subject: &str,
2435 refusal: String,
2436) -> AutomergeOutcome {
2437 let argv = bump_merge_argv(pr_url, subject);
2438 let out = match tokio::process::Command::new("gh")
2439 .args(&argv)
2440 .current_dir(cwd)
2441 .quiet()
2442 .stdin(std::process::Stdio::null())
2443 .output()
2444 .await
2445 {
2446 Ok(o) => o,
2447 Err(e) => {
2448 return AutomergeOutcome::Failed {
2449 reason: format!("{refusal}; could not spawn gh to merge directly: {e}"),
2450 };
2451 }
2452 };
2453 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
2454 let after = if out.status.success() {
2457 None
2458 } else {
2459 land::lifecycle(cwd, pr_url).await.ok()
2460 };
2461 resolve_direct_merge(&refusal, &argv, out.status.success(), &stderr, after)
2462}
2463
2464async fn write_release_files(state: &RunState, worktree: &Path, version: &str) -> Result<()> {
2468 let declared = &state.config.release.version_files;
2469 if release_manifest::is_custom(declared) {
2470 return release_manifest::apply(worktree, declared, version);
2471 }
2472 let cargo_toml_path = worktree.join("Cargo.toml");
2473 let toml = tokio::fs::read_to_string(&cargo_toml_path)
2474 .await
2475 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
2476 let rewritten = rewrite_cargo_version(&toml, version)?;
2477 tokio::fs::write(&cargo_toml_path, rewritten)
2478 .await
2479 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
2480 sync_lockfile(worktree, state.config.cache_dir().as_deref()).await
2481}
2482
2483async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
2491 let mut cmd = tokio::process::Command::new("cargo");
2492 cmd.arg("build").current_dir(worktree).quiet();
2493 if let Some(dir) = cache_dir {
2494 cmd.env("CARGO_TARGET_DIR", dir);
2495 }
2496 let out = cmd
2497 .stdin(std::process::Stdio::null())
2498 .output()
2499 .await
2500 .context("spawn cargo build")?;
2501 if !out.status.success() {
2502 bail!(
2503 "cargo build failed while syncing Cargo.lock: {}",
2504 String::from_utf8_lossy(&out.stderr).trim()
2505 );
2506 }
2507 Ok(())
2508}
2509
2510fn release_pr(
2515 level: &str,
2516 reason: &str,
2517 next_version: &str,
2518 run_id: &str,
2519 source_pr_url: &str,
2520) -> (String, String) {
2521 let title = format!("chore: release v{next_version} ({level} bump)");
2522 let body = format!(
2523 "## Background\n\n\
2524 A change that was just merged is a `{level}` change, so the crate needs a new \
2525 release: {reason}\n\n\
2526 Triggered by magi run `{run_id}`, which landed {source}.\n\n\
2527 ## Change\n\n\
2528 Raises the package version to `v{next_version}` in `Cargo.toml`, with \
2529 `Cargo.lock` following it. Nothing else changes.\n\n\
2530 ## Risk\n\n\
2531 Version-bump-only, so there is nothing here for a reviewer to find. Merging \
2532 it starts the release pipeline (auto-tag, then the release workflow).",
2533 source = source_pr_url,
2534 );
2535 (title, body)
2536}
2537
2538async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
2540 let out = tokio::process::Command::new("gh")
2541 .args(["pr", "view", pr_url, "--json", "title"])
2542 .current_dir(repo)
2543 .quiet()
2544 .stdin(std::process::Stdio::null())
2545 .output()
2546 .await
2547 .context("spawn gh pr view")?;
2548 if !out.status.success() {
2549 bail!(
2550 "gh pr view {pr_url}: {}",
2551 String::from_utf8_lossy(&out.stderr).trim()
2552 );
2553 }
2554 #[derive(Deserialize)]
2555 struct Title {
2556 title: String,
2557 }
2558 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
2559 .context("parse `gh pr view --json title` output")?;
2560 Ok(parsed.title)
2561}
2562
2563async fn gh_pr_create(
2564 cwd: &Path,
2565 base: &str,
2566 head: &str,
2567 title: &str,
2568 body: &str,
2569) -> Result<String> {
2570 let out = tokio::process::Command::new("gh")
2571 .args([
2572 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
2573 ])
2574 .current_dir(cwd)
2575 .quiet()
2576 .stdin(std::process::Stdio::null())
2577 .output()
2578 .await
2579 .context("spawn gh pr create")?;
2580 if out.status.success() {
2581 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2582 } else {
2583 bail!(
2584 "gh pr create: {}",
2585 String::from_utf8_lossy(&out.stderr).trim()
2586 )
2587 }
2588}
2589
2590async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
2594 let out = tokio::process::Command::new("gh")
2595 .args([
2596 "pr",
2597 "merge",
2598 pr_url,
2599 "--auto",
2600 "--squash",
2601 "--delete-branch",
2602 ])
2603 .current_dir(cwd)
2604 .quiet()
2605 .stdin(std::process::Stdio::null())
2606 .output()
2607 .await
2608 .context("spawn gh pr merge --auto")?;
2609 if out.status.success() {
2610 Ok(())
2611 } else {
2612 bail!(
2613 "gh pr merge --auto: {}",
2614 String::from_utf8_lossy(&out.stderr).trim()
2615 )
2616 }
2617}
2618
2619async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
2623 let out = tokio::process::Command::new("gh")
2624 .args(["pr", "edit", pr_url, "--title", title])
2625 .current_dir(cwd)
2626 .quiet()
2627 .stdin(std::process::Stdio::null())
2628 .output()
2629 .await
2630 .context("spawn gh pr edit")?;
2631 if out.status.success() {
2632 Ok(())
2633 } else {
2634 bail!(
2635 "gh pr edit --title: {}",
2636 String::from_utf8_lossy(&out.stderr).trim()
2637 )
2638 }
2639}
2640
2641#[cfg(test)]
2642mod tests {
2643 use super::*;
2644 use crate::notices::Severity;
2645
2646 #[test]
2647 fn github_facing_bump_text_is_english() {
2648 let (title, body) =
2649 release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
2650 assert!(crate::github_text::check(&title, &body).is_empty());
2651 assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
2652 assert_eq!(title, "chore: release v0.37.0 (minor bump)");
2653 assert!(
2654 body.contains("## Background") && body.contains("## Change"),
2655 "{body}"
2656 );
2657 let p = decision_prompt("s", "i", "d", &[], "0.36.5");
2658 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
2659 }
2660 use crate::config::Config;
2661 use crate::land::PrLifecycle;
2662
2663 #[tokio::test]
2669 async fn a_disabled_config_does_nothing() {
2670 let config = Config {
2671 merge: crate::config::Merge {
2672 release_bump: false,
2673 ..crate::config::Merge::default()
2674 },
2675 ..Config::default()
2676 };
2677 let mut state = RunState::new(
2678 PathBuf::from("/no/such/repo"),
2679 "main".to_owned(),
2680 "0000000000000000000000000000000000000000".to_owned(),
2681 "irrelevant".to_owned(),
2682 config,
2683 );
2684 after_merge(&mut state, "https://example.invalid/pull/1")
2685 .await
2686 .expect("a disabled config must return Ok without touching anything");
2687 assert!(
2688 state.events.is_empty(),
2689 "nothing should happen at all, not even a logged event"
2690 );
2691 }
2692
2693 async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
2695 let dir = tempfile::tempdir().unwrap();
2696 let origin = dir.path().join("origin.git");
2697 let repo = dir.path().join("repo");
2698 let o = origin.to_string_lossy().into_owned();
2699 git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
2700 .await
2701 .unwrap();
2702 tokio::fs::create_dir_all(&repo).await.unwrap();
2703 git::git(&repo, &["init", "-b", "main"]).await.unwrap();
2704 git::git(&repo, &["config", "user.name", "test"])
2705 .await
2706 .unwrap();
2707 git::git(&repo, &["config", "user.email", "test@example.com"])
2708 .await
2709 .unwrap();
2710 for (name, body) in files {
2711 tokio::fs::write(repo.join(name), body).await.unwrap();
2712 }
2713 git::git(&repo, &["add", "-A"]).await.unwrap();
2714 git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
2715 git::git(&repo, &["remote", "add", "origin", &o])
2716 .await
2717 .unwrap();
2718 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
2719 (dir, repo)
2720 }
2721
2722 async fn no_pr(_: String) -> Result<bool> {
2723 Ok(false)
2724 }
2725
2726 async fn with_branch(ahead: bool) -> (tempfile::TempDir, PathBuf) {
2729 let (d, repo) = origin_with(&[("f", "x\n")]).await;
2730 git::git(&repo, &["branch", "b"]).await.unwrap();
2731 if ahead {
2732 git::git(&repo, &["checkout", "-q", "b"]).await.unwrap();
2733 git::git(&repo, &["commit", "--allow-empty", "-m", "wip"])
2734 .await
2735 .unwrap();
2736 git::git(&repo, &["checkout", "-q", "main"]).await.unwrap();
2737 }
2738 (d, repo)
2739 }
2740
2741 async fn attempt(repo: &Path, open_pr: bool, fail_after_push: Option<bool>) -> Result<()> {
2742 let wt = repo.parent().unwrap().join("bump");
2743 release_attempt(
2744 repo,
2745 "origin",
2746 "main",
2747 &wt,
2748 "b",
2749 |_| async move { Ok(open_pr) },
2750 |w| async move {
2751 if fail_after_push == Some(true) {
2752 git::push(&w, "origin", "b").await?;
2753 }
2754 if fail_after_push.is_some() {
2755 bail!("cargo build failed");
2756 }
2757 Ok(())
2758 },
2759 )
2760 .await
2761 }
2762
2763 #[tokio::test]
2764 async fn a_stale_ancestor_branch_is_deleted_and_the_attempt_proceeds() {
2765 let (_d, repo) = with_branch(false).await;
2766 attempt(&repo, false, None).await.unwrap();
2767 assert!(git::branch_exists(&repo, "b").await.unwrap());
2769 }
2770
2771 #[tokio::test]
2772 async fn a_stale_branch_holding_a_leftover_worktree_is_reclaimed() {
2773 let (_d, repo) = with_branch(false).await;
2774 let old = repo.parent().unwrap().join("old");
2775 git::git(&repo, &["worktree", "add", &old.to_string_lossy(), "b"])
2776 .await
2777 .unwrap();
2778 attempt(&repo, false, None).await.unwrap();
2779 assert!(!old.exists());
2780 }
2781
2782 #[tokio::test]
2783 async fn a_stale_branch_with_an_unmerged_commit_is_kept_with_a_reason() {
2784 let (_d, repo) = with_branch(true).await;
2785 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2786 assert!(e.contains("`b`") || e.contains("branch b"), "{e}");
2787 assert!(e.contains("1 commit(s) not in origin/main"), "{e}");
2788 assert!(git::branch_exists(&repo, "b").await.unwrap());
2789 }
2790
2791 #[tokio::test]
2792 async fn a_stale_branch_on_the_remote_is_kept() {
2793 let (_d, repo) = with_branch(false).await;
2794 git::git(&repo, &["push", "origin", "b"]).await.unwrap();
2795 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2796 assert!(e.contains("branch b") && e.contains("is on origin"), "{e}");
2797 assert!(git::branch_exists(&repo, "b").await.unwrap());
2798 }
2799
2800 #[tokio::test]
2801 async fn a_stale_branch_with_an_open_pull_request_is_kept() {
2802 let (_d, repo) = with_branch(false).await;
2803 let e = attempt(&repo, true, None).await.unwrap_err().to_string();
2804 assert!(e.contains("open pull request"), "{e}");
2805 assert!(git::branch_exists(&repo, "b").await.unwrap());
2806 }
2807
2808 #[tokio::test]
2809 async fn an_unanswerable_pull_request_check_keeps_the_branch() {
2810 let (_d, repo) = with_branch(false).await;
2811 let wt = repo.parent().unwrap().join("bump");
2812 let e = release_attempt(
2813 &repo,
2814 "origin",
2815 "main",
2816 &wt,
2817 "b",
2818 |_| async { bail!("offline") },
2819 |_| async { Ok(()) },
2820 )
2821 .await
2822 .unwrap_err()
2823 .to_string();
2824 assert!(
2825 e.contains("could not check for an open pull request"),
2826 "{e}"
2827 );
2828 assert!(git::branch_exists(&repo, "b").await.unwrap());
2829 }
2830
2831 #[tokio::test]
2832 async fn a_failed_attempt_removes_its_own_worktree_and_branch() {
2833 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2834 let wt = repo.parent().unwrap().join("bump");
2835 let e = attempt(&repo, false, Some(false)).await.unwrap_err();
2836 assert!(format!("{e:#}").contains("cargo build failed"));
2837 assert!(!wt.exists());
2838 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2839 }
2840
2841 #[tokio::test]
2842 async fn a_pushed_branch_survives_a_failed_attempt_and_the_reason_says_so() {
2843 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2844 let e = attempt(&repo, false, Some(true)).await.unwrap_err();
2845 let e = format!("{e:#}");
2846 assert!(
2847 e.contains("cargo build failed") && e.contains("pushed to origin"),
2848 "{e}"
2849 );
2850 assert!(git::branch_exists(&repo, "b").await.unwrap());
2851 }
2852
2853 #[tokio::test]
2854 async fn a_branch_left_by_a_half_done_worktree_add_is_removed() {
2855 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2858 let blocker = repo.parent().unwrap().join("blocker");
2859 tokio::fs::write(&blocker, "file").await.unwrap();
2860 let wt = blocker.join("bump");
2861 let r = release_attempt(
2862 &repo,
2863 "origin",
2864 "main",
2865 &wt,
2866 "b",
2867 |_| async { bail!("offline") },
2868 |_| async { Ok(()) },
2869 )
2870 .await;
2871 assert!(r.is_err());
2872 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2873 }
2874
2875 #[tokio::test]
2876 async fn the_retry_is_taken_at_most_once() {
2877 let (_d, repo) = with_branch(false).await;
2880 let blocker = repo.parent().unwrap().join("blocker");
2881 tokio::fs::write(&blocker, "file").await.unwrap();
2882 let wt = blocker.join("bump");
2883 let r = release_attempt(&repo, "origin", "main", &wt, "b", no_pr, |_| async {
2884 Ok(())
2885 })
2886 .await;
2887 assert!(r.is_err());
2888 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2889 }
2890
2891 #[tokio::test]
2892 async fn git_worktree_failure_reaches_release_notice_without_debug_exit_codes() {
2893 let (dir, repo) = origin_with(&[("f", "x\n")]).await;
2894 let worktree = dir.path().join("occupied");
2895 tokio::fs::create_dir(&worktree).await.unwrap();
2896 let existing = worktree.join("keep");
2897 tokio::fs::write(&existing, "operator data").await.unwrap();
2898 let err = release_attempt::<(), _, _, _, _>(
2899 &repo,
2900 "origin",
2901 "main",
2902 &worktree,
2903 "release-test",
2904 no_pr,
2905 |_| async { panic!("worktree creation must fail before release edits") },
2906 )
2907 .await
2908 .unwrap_err();
2909 let (version, reason) = notice_for_failure(&Progress::default(), &err);
2910 assert!(
2911 reason.contains("create the release-bump worktree"),
2912 "{reason}"
2913 );
2914 assert!(reason.contains("exit code 128"), "{reason}");
2915 let store = Notices::at(dir.path().join("notifications"));
2916 let mut state = winner_state(&repo, "main");
2917 state.status = RunStatus::Merged;
2918 report_problem_in(&mut state, &store, None, version.as_deref(), &reason, false).await;
2919 let notices = store.list();
2920 assert_eq!(notices.len(), 1);
2921 let notice = ¬ices[0];
2922 assert!(
2923 notice.message.contains("exit code 128"),
2924 "{}",
2925 notice.message
2926 );
2927 assert!(!notice.message.contains("Some(") && !notice.message.contains("None"));
2928 assert_eq!(notice.severity, Severity::Error);
2929 assert_eq!(state.status, RunStatus::Merged);
2930 assert!(state.needs_attention());
2931 assert!(!git::branch_exists(&repo, "release-test").await.unwrap());
2932 assert_eq!(
2933 tokio::fs::read_to_string(existing).await.unwrap(),
2934 "operator data"
2935 );
2936 }
2937
2938 #[tokio::test]
2939 async fn base_has_cargo_toml_tells_rust_from_non_rust() {
2940 let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
2941 assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
2942 let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
2943 assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
2944 assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
2946 }
2947
2948 #[tokio::test]
2949 async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
2950 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2951 let home = tempfile::tempdir().unwrap();
2952 let mut state = RunState::new(
2953 repo.clone(),
2954 "main".to_owned(),
2955 "0000000000000000000000000000000000000000".to_owned(),
2956 "task".to_owned(),
2957 Config::default(),
2958 );
2959 state.candidates.push(crate::run::Candidate {
2960 index: 0,
2961 label: 'A',
2962 agent: "x".to_owned(),
2963 branch: "main".to_owned(),
2964 worktree: repo.clone(),
2965 summary: String::new(),
2966 stat: String::new(),
2967 files: 1,
2968 commits: 1,
2969 empty: false,
2970 failed: None,
2971 verified_noop: None,
2972 duration_ms: 0,
2973 folded: false,
2974 });
2975 state.tally = Some(
2976 serde_json::from_value(serde_json::json!({
2977 "first_choice": {}, "borda": {}, "winner": "A",
2978 "unanimous_initial": true, "deliberated": false,
2979 "changed_votes": 0, "unanimous_final": true,
2980 }))
2981 .unwrap(),
2982 );
2983 after_merge_at(
2984 &mut state,
2985 "https://example.invalid/pull/1",
2986 Some(home.path()),
2987 None,
2988 )
2989 .await
2990 .expect("a non-Rust repository is not an error");
2991 let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
2992 assert_eq!(bumps.len(), 1, "{:?}", state.events);
2993 assert_eq!(
2994 bumps[0].message,
2995 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
2996 );
2997 assert!(
2998 std::fs::read_dir(home.path()).unwrap().next().is_none(),
2999 "no marker and no lock may be created"
3000 );
3001 }
3002
3003 fn winner_state(repo: &Path, base: &str) -> RunState {
3004 let mut state = RunState::new(
3005 repo.to_path_buf(),
3006 base.to_owned(),
3007 "0000000000000000000000000000000000000000".to_owned(),
3008 "task".to_owned(),
3009 Config::default(),
3010 );
3011 state.candidates.push(crate::run::Candidate {
3012 index: 0,
3013 label: 'A',
3014 agent: "x".to_owned(),
3015 branch: "main".to_owned(),
3016 worktree: repo.to_path_buf(),
3017 summary: String::new(),
3018 stat: String::new(),
3019 files: 1,
3020 commits: 1,
3021 empty: false,
3022 failed: None,
3023 verified_noop: None,
3024 duration_ms: 0,
3025 folded: false,
3026 });
3027 state.tally = Some(
3028 serde_json::from_value(serde_json::json!({
3029 "first_choice": {}, "borda": {}, "winner": "A",
3030 "unanimous_initial": true, "deliberated": false,
3031 "changed_votes": 0, "unanimous_final": true,
3032 }))
3033 .unwrap(),
3034 );
3035 state
3036 }
3037
3038 #[tokio::test]
3039 async fn a_real_failure_without_a_pr_raises_one_notice_and_a_retry_folds_into_it() {
3040 let (_d, repo) =
3041 origin_with(&[("Cargo.toml", "[package]\nname=\"x\"\nversion=\"0.1.0\"\n")]).await;
3042 let home = tempfile::tempdir().unwrap();
3043 let store = Notices::at(home.path().join("notifications"));
3044 let mut state = winner_state(&repo, "nope");
3046 let url = "https://example.invalid/pull/1";
3047 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3048 .await
3049 .expect_err("an unresolvable base is a failure");
3050 let listed = store.list();
3051 assert_eq!(listed.len(), 1, "{listed:?}");
3052 assert!(
3053 listed[0].message.contains("did not run"),
3054 "{}",
3055 listed[0].message
3056 );
3057 assert!(
3058 listed[0].message.contains("Cargo.toml"),
3059 "{}",
3060 listed[0].message
3061 );
3062 assert!(state.events.iter().any(|e| e.node == "bump"));
3063 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3064 .await
3065 .expect_err("still failing");
3066 let listed = store.list();
3067 assert_eq!(listed.len(), 1, "{listed:?}");
3068 assert_eq!(listed[0].count, 2);
3069 }
3070
3071 #[tokio::test]
3072 async fn not_eligible_cases_raise_no_notice() {
3073 let home = tempfile::tempdir().unwrap();
3074 let store = Notices::at(home.path().join("notifications"));
3075 let url = "https://example.invalid/pull/1";
3076 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
3078 let mut state = winner_state(&repo, "main");
3079 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3080 .await
3081 .unwrap();
3082 let mut state = winner_state(&repo, "nope");
3084 state.config.merge.release_bump = false;
3085 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3086 .await
3087 .unwrap();
3088 let mut state = winner_state(&repo, "nope");
3090 state.tally = None;
3091 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3092 .await
3093 .unwrap();
3094 assert!(store.list().is_empty(), "{:?}", store.list());
3095 }
3096
3097 #[test]
3098 fn the_no_pr_notice_follows_the_configured_language() {
3099 let dir = tempfile::tempdir().unwrap();
3100 let store = Notices::at(dir.path().join("notifications"));
3101 let mut state = merged_state();
3102 state.config.graph.language = "ja".to_owned();
3103 let (n, _) = surface_problem(&mut state, &store, None, Some("0.2.0"), "boom").unwrap();
3104 assert!(n.message.contains("実行されませんでした"), "{}", n.message);
3105 assert!(n.message.contains("boom") && n.message.contains("v0.2.0"));
3106 }
3107
3108 #[test]
3109 fn a_failed_escalation_points_at_the_pending_pr_without_commenting() {
3110 let dir = tempfile::tempdir().unwrap();
3111 let store = Notices::at(dir.path().join("notifications"));
3112 let mut state = merged_state();
3113 let url = "https://example.invalid/pull/9";
3114 let (n, comment) = surface_problem_in(
3115 &mut state,
3116 &store,
3117 Some(url),
3118 Some("0.3.0"),
3119 "push failed",
3120 true,
3121 )
3122 .unwrap();
3123 assert!(comment.is_none());
3124 assert!(
3125 n.message.contains(url) && n.message.contains("v0.3.0"),
3126 "{}",
3127 n.message
3128 );
3129 assert!(!n.message.contains("open the release pull request by hand"));
3130 assert!(matches!(n.link, Some(Link::Url { .. })));
3131 }
3132
3133 #[test]
3134 fn a_failure_notice_names_the_pending_pr_and_the_first_line_of_the_cause() {
3135 let err = anyhow!("outer context").context("cargo build failed\nsecond line");
3136 let with_pr = Progress {
3137 version: Some("0.2.0".into()),
3138 pending_pr: Some("https://example.invalid/pull/9".into()),
3139 skip: None,
3140 };
3141 let (v, reason) = notice_for_failure(&with_pr, &err);
3142 assert_eq!(v.as_deref(), Some("0.2.0"));
3143 assert_eq!(reason, "cargo build failed");
3144 let (v, reason) = notice_for_failure(&Progress::default(), &err);
3145 assert_eq!(v, None);
3146 assert_eq!(reason, "cargo build failed");
3147 }
3148
3149 const NO_RULES: &str = "gh pr merge --auto: GraphQL: Pull request Branch does not have \
3150 required protected branch rules (enablePullRequestAutoMerge)";
3151
3152 fn merged_state() -> RunState {
3153 run::pin_test_home();
3156 let mut s = RunState::new(
3157 PathBuf::from("/no/such/repo"),
3158 "main".to_owned(),
3159 "0000000000000000000000000000000000000000".to_owned(),
3160 "task".to_owned(),
3161 Config::default(),
3162 );
3163 s.status = RunStatus::Merged;
3164 s
3165 }
3166
3167 #[cfg(unix)]
3168 fn decision_cli(id: &str, reply: &str, exit: &str) -> AgentSpec {
3169 AgentSpec {
3170 id: id.into(),
3171 kind: crate::config::AgentKind::Command,
3172 model: None,
3173 command: vec![
3174 "sh".into(),
3175 "-c".into(),
3176 concat!(
3177 "if [ \"$STATUS\" = timeout ]; then exec sleep 5; fi; ",
3178 "printf '%s' \"$REPLY\"; exit \"$STATUS\""
3179 )
3180 .into(),
3181 ],
3182 extra_args: Vec::new(),
3183 env: std::collections::BTreeMap::from([
3184 ("REPLY".into(), reply.into()),
3185 ("STATUS".into(), exit.into()),
3186 ]),
3187 prompt_delivery: None,
3188 max_turn_secs: None,
3189 }
3190 }
3191
3192 #[cfg(unix)]
3193 fn decision_state(agents: Vec<AgentSpec>, preferences: &[&str]) -> RunState {
3194 let mut state = merged_state();
3195 state.config.agents = agents;
3196 state.config.roles.chatter = Some(crate::config::AgentChoice::Chain(
3197 preferences.iter().map(|s| (*s).to_owned()).collect(),
3198 ));
3199 state
3200 }
3201
3202 #[cfg(unix)]
3203 const VALID_DECISION: &str = r#"{"level":"minor","reason":"Adds a feature."}"#;
3204
3205 #[cfg(unix)]
3206 #[tokio::test]
3207 async fn decision_failover_handles_each_unusable_cli_outcome() {
3208 for (reply, exit, expected) in [
3209 (
3210 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3211 "1",
3212 "rate/quota limit",
3213 ),
3214 (
3215 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached","level":"minor","reason":"Looks valid."}"#,
3216 "0",
3217 "rate/quota limit",
3218 ),
3219 (
3220 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached","level":"minor","reason":"Looks valid."}"#,
3221 "0",
3222 "rate/quota limit",
3223 ),
3224 ("", "0", "empty output"),
3225 (
3226 "",
3227 "spawn",
3228 "CLI invocation failed; inspect attempt artifacts",
3229 ),
3230 ("not JSON", "0", "invalid decision JSON or missing reason"),
3231 (VALID_DECISION, "1", "CLI failed (exit code 1)"),
3232 ("", "timeout", "timed out"),
3233 ] {
3234 let dir = tempfile::tempdir().unwrap();
3235 let mut state = decision_state(
3236 vec![
3237 decision_cli("first", reply, exit),
3238 decision_cli("second", VALID_DECISION, "0"),
3239 ],
3240 &["first", "second"],
3241 );
3242 if exit == "spawn" {
3243 state.config.agents[0].command = vec!["magi-test-no-such-decision-cli".into()];
3244 }
3245 let (spec, seat, decision) =
3246 ask_decision(&mut state, dir.path(), "decide", Duration::from_millis(200))
3247 .await
3248 .unwrap();
3249 assert_eq!(spec.id, "second");
3250 assert_eq!(decision.level, BumpLevel::Minor);
3251 assert_eq!(state.bump_decision_attempts.len(), 2);
3252 assert_eq!(state.bump_decision_attempts[0].outcome, expected);
3253 assert_eq!(state.handovers.len(), 1);
3254 assert_eq!(state.handovers[0].to, "second");
3255 assert_ne!(
3256 seat.claude_session,
3257 SeatState::new("bump", "first", state.seed).claude_session
3258 );
3259 let report = crate::report::run(&state);
3260 assert!(report.contains(expected), "{report}");
3261 assert!(report.contains("second: valid decision"), "{report}");
3262 assert!(state.release_bump.is_none());
3263 let artifacts = agent::artifacts_dir(&state.dir());
3264 if exit != "spawn" {
3265 assert!(artifacts.join("bump-decision-1.out").exists());
3266 }
3267 assert!(artifacts.join("bump-decision-2.out").exists());
3268 }
3269 }
3270
3271 #[cfg(unix)]
3272 #[tokio::test]
3273 async fn decision_chain_respects_preferences_and_stops_on_first_success() {
3274 let dir = tempfile::tempdir().unwrap();
3275 let mut state = decision_state(
3276 vec![
3277 decision_cli("first", "bad", "1"),
3278 decision_cli("second", VALID_DECISION, "0"),
3279 ],
3280 &["missing", "second", "second", "first"],
3281 );
3282 let (spec, _, _) = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(1))
3283 .await
3284 .unwrap();
3285 assert_eq!(spec.id, "second");
3286 assert_eq!(state.bump_decision_attempts.len(), 1);
3287 assert!(state.handovers.is_empty());
3288 }
3289
3290 #[cfg(unix)]
3291 #[tokio::test]
3292 async fn decision_chain_consumes_same_failure_classes_and_preserves_unset_policy() {
3293 let dir = tempfile::tempdir().unwrap();
3294 let mut state = decision_state(
3295 vec![
3296 decision_cli("first", "bad", "0"),
3297 decision_cli("second", "bad", "0"),
3298 decision_cli("third", VALID_DECISION, "0"),
3299 ],
3300 &["first", "second", "third"],
3301 );
3302 let (spec, _, _) = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(2))
3303 .await
3304 .unwrap();
3305 assert_eq!(spec.id, "third");
3306 assert_eq!(state.bump_decision_attempts.len(), 3);
3307 assert_eq!(state.handovers.len(), 2);
3308 state.config.roles.chatter = None;
3310 let err = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(2))
3311 .await
3312 .unwrap_err();
3313 assert!(err.to_string().contains("exhausted 1 eligible agent(s)"));
3314 assert_eq!(state.bump_decision_attempts.len(), 4);
3315 }
3316
3317 #[cfg(unix)]
3318 #[tokio::test]
3319 async fn decision_reason_rewrite_failure_preserves_the_selected_level() {
3320 let dir = tempfile::tempdir().unwrap();
3321 for reply in ["invalid", r#"{"level":"patch","reason":"Fixes a defect."}"#] {
3322 let mut state = merged_state();
3323 state.config.graph.github_text_guard = true;
3324 state.config.graph.github_language = "en".into();
3325 let spec = decision_cli("chosen", reply, "0");
3326 let mut seat = SeatState::new("bump", &spec.id, state.seed);
3327 let mut decision =
3328 parse_decision(r#"{"level":"minor","reason":"新しい機能を追加しました。"}"#)
3329 .unwrap();
3330 guard_reason(
3331 &mut state,
3332 dir.path(),
3333 "decide",
3334 &spec,
3335 &mut seat,
3336 &mut decision,
3337 )
3338 .await;
3339 assert_eq!(decision.level, BumpLevel::Minor);
3340 assert_eq!(decision.reason, "The merged change requires a release.");
3341 assert!(state.handovers.is_empty());
3342 assert!(state.bump_decision_attempts.is_empty());
3343 }
3344 }
3345
3346 #[cfg(unix)]
3347 #[tokio::test]
3348 async fn decision_exhaustion_is_bounded_reported_and_leaves_no_release_outcome() {
3349 let dir = tempfile::tempdir().unwrap();
3350 let mut state = decision_state(
3351 vec![
3352 decision_cli("first", "bad", "0"),
3353 decision_cli("second", "", "0"),
3354 ],
3355 &["first", "first", "second", "second"],
3356 );
3357 let err = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(1))
3358 .await
3359 .unwrap_err()
3360 .to_string();
3361 assert!(err.contains("exhausted 2 eligible agent(s)"), "{err}");
3362 assert!(err.contains("first: invalid decision JSON"), "{err}");
3363 assert!(err.contains("second: empty output"), "{err}");
3364 assert!(err.contains("[roles] chatter"), "{err}");
3365 assert_eq!(state.bump_decision_attempts.len(), 2);
3366 assert_eq!(state.handovers.len(), 1);
3367 assert!(state.release_bump.is_none());
3368 let report = crate::report::run(&state);
3369 assert!(report.contains("first: invalid decision JSON"));
3370 assert!(report.contains("second: empty output"));
3371 let restored: RunState =
3372 serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
3373 assert_eq!(restored.bump_decision_attempts.len(), 2);
3374 }
3375
3376 #[test]
3377 fn the_known_automerge_refusal_names_branch_protection() {
3378 assert!(automerge_hint(NO_RULES).contains("branch protection with required"));
3379 let other = automerge_hint("gh: network unreachable");
3380 assert!(!other.contains("branch protection"), "{other}");
3381 let body = automerge_failure_comment(NO_RULES);
3382 assert!(body.contains("enablePullRequestAutoMerge"), "{body}");
3383 assert!(body.contains("Action required"), "{body}");
3384 }
3385
3386 const CLEAN: &str = "gh pr merge --auto: GraphQL: Pull request Pull request is in clean \
3387 status (enablePullRequestAutoMerge)";
3388
3389 #[test]
3390 fn clean_status_refusal_is_matched_narrowly() {
3391 assert!(is_clean_status_refusal(CLEAN));
3392 assert!(!is_clean_status_refusal(NO_RULES));
3393 assert!(!is_clean_status_refusal("gh: network unreachable"));
3394 assert!(!is_clean_status_refusal("Pull request is in clean status"));
3395 assert!(automerge_hint(CLEAN).contains("already green"));
3396 }
3397
3398 #[test]
3399 fn the_direct_merge_argv_matches_the_land_flags() {
3400 let a = bump_merge_argv("https://github.com/o/r/pull/9", "chore: release v1.0.0");
3401 let l = land::merge_argv(9, "chore: release v1.0.0");
3402 assert_eq!(a[..2], l[..2]);
3403 assert_eq!(a[3..], l[3..]);
3404 assert_eq!(a[2], "https://github.com/o/r/pull/9");
3405 }
3406
3407 #[test]
3408 fn a_direct_merge_is_judged_by_the_forge_not_the_exit_code() {
3409 let argv = bump_merge_argv("u", "t");
3410 let merged = |o: &AutomergeOutcome| matches!(o, AutomergeOutcome::MergedDirectly { .. });
3411 assert!(merged(&resolve_direct_merge(CLEAN, &argv, true, "", None)));
3412 let detached = "not on any branch";
3413 assert!(merged(&resolve_direct_merge(
3414 CLEAN,
3415 &argv,
3416 false,
3417 detached,
3418 Some(PrLifecycle::Merged)
3419 )));
3420 for after in [Some(PrLifecycle::Open), None] {
3421 let o = resolve_direct_merge(CLEAN, &argv, false, "boom", after);
3422 match o {
3423 AutomergeOutcome::Failed { reason } => {
3424 assert!(
3425 reason.contains("clean status") && reason.contains("boom"),
3426 "{reason}"
3427 )
3428 }
3429 other => panic!("expected Failed, got {other:?}"),
3430 }
3431 }
3432 }
3433
3434 #[test]
3435 fn a_local_mode_bump_reads_back_and_old_records_are_not_local() {
3436 let old: run::ReleaseBump =
3437 serde_json::from_str(r#"{"pr_url":"u","automerge_enabled":true}"#).unwrap();
3438 assert!(!old.local);
3439 let mut state = merged_state();
3440 state.release_bump = Some(run::ReleaseBump {
3441 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
3442 version: Some("1.0.0".to_owned()),
3443 local: true,
3444 ..run::ReleaseBump::default()
3445 });
3446 assert!(!state.needs_attention());
3448 let text = crate::report::run(&state);
3449 assert!(text.contains("release.mode = local"), "{text}");
3450 assert!(!text.contains("FAILED"), "{text}");
3451 }
3452
3453 #[test]
3454 fn a_directly_merged_bump_is_not_reported_as_pending_or_failed() {
3455 let mut state = merged_state();
3456 state.release_bump = Some(run::ReleaseBump {
3457 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
3458 version: Some("1.0.0".to_owned()),
3459 merged_directly: true,
3460 ..run::ReleaseBump::default()
3461 });
3462 assert!(!state.needs_attention());
3463 let text = crate::report::run(&state);
3464 assert!(text.contains("merged directly"), "{text}");
3465 assert!(!text.contains("FAILED"), "{text}");
3466 }
3467
3468 #[test]
3469 fn an_automerge_failure_is_recorded_shown_and_filed_and_survives_settling() {
3470 let dir = tempfile::tempdir().unwrap();
3471 let store = Notices::at(dir.path().join("notifications"));
3472 let questions = ask::Questions::at(dir.path().join("questions"));
3473 let mut state = merged_state();
3474 let url = "https://github.com/o/r/pull/35";
3475
3476 let (n, comment) =
3477 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
3478
3479 let comment = comment.expect("a PR was opened, so it gets a comment");
3481 assert!(comment.contains("branch protection"), "{comment}");
3482
3483 assert_eq!(state.status, RunStatus::Merged);
3485 assert!(state.needs_attention());
3486 let text = crate::report::run(&state);
3487 assert!(text.contains("release bump"), "{text}");
3488 assert!(text.contains("FAILED"), "{text}");
3489 assert!(text.contains(url), "{text}");
3490 assert!(text.contains("action required"), "{text}");
3491 assert!(crate::report::line(&state).contains("release needs a human"));
3492
3493 assert_eq!(n.severity, Severity::Error);
3495 assert_eq!(
3496 n.link,
3497 Some(Link::Url {
3498 url: url.to_owned()
3499 })
3500 );
3501 assert_eq!(store.list().len(), 1);
3502 assert!(questions.open_for(&state.id).is_empty());
3503
3504 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
3506 let listed = store.list();
3507 assert_eq!(listed.len(), 1);
3508 assert_eq!(listed[0].count, 2);
3509 }
3510
3511 #[test]
3512 fn a_bump_that_never_ran_is_surfaced_without_a_pr_comment() {
3513 let dir = tempfile::tempdir().unwrap();
3514 let store = Notices::at(dir.path().join("notifications"));
3515 let mut state = merged_state();
3516 let (n, comment) = surface_problem(&mut state, &store, None, None, "no agent").unwrap();
3517 assert!(comment.is_none());
3518 assert!(matches!(n.link, Some(Link::Run { .. })));
3519 assert!(state.needs_attention());
3520 }
3521
3522 #[test]
3523 fn version_parses_and_bumps_each_digit() {
3524 let v = Version::parse("0.4.0").unwrap();
3525 assert_eq!(
3526 v,
3527 Version {
3528 major: 0,
3529 minor: 4,
3530 patch: 0
3531 }
3532 );
3533
3534 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
3535 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
3536 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
3537 }
3538
3539 #[test]
3540 fn version_tolerates_a_prerelease_suffix_on_patch() {
3541 let v = Version::parse("1.2.3-rc1").unwrap();
3542 assert_eq!(
3543 v,
3544 Version {
3545 major: 1,
3546 minor: 2,
3547 patch: 3
3548 }
3549 );
3550 }
3551
3552 #[test]
3553 fn version_rejects_garbage() {
3554 assert!(Version::parse("not-a-version").is_err());
3555 assert!(Version::parse("1.2").is_err());
3556 }
3557
3558 #[test]
3559 fn decision_parses_each_level() {
3560 for (json, level) in [
3561 (
3562 r#"{"level":"major","reason":"drops a config key"}"#,
3563 BumpLevel::Major,
3564 ),
3565 (
3566 r#"{"level":"minor","reason":"adds a new flag"}"#,
3567 BumpLevel::Minor,
3568 ),
3569 (
3570 r#"{"level":"patch","reason":"fixes a race"}"#,
3571 BumpLevel::Patch,
3572 ),
3573 ] {
3574 let decision = parse_decision(json).unwrap();
3575 assert_eq!(decision.level, level);
3576 assert!(!decision.reason.is_empty());
3577 }
3578 }
3579
3580 #[test]
3581 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
3582 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
3583 let decision = parse_decision(text).unwrap();
3584 assert_eq!(decision.level, BumpLevel::Minor);
3585 assert_eq!(decision.reason, "new HTTP route");
3586 }
3587
3588 #[test]
3589 fn a_broken_reply_is_an_error_not_a_default() {
3590 assert!(parse_decision("I decline to answer.").is_err());
3591 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
3592 assert!(
3593 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
3594 "an empty reason must not pass either"
3595 );
3596 assert!(
3597 parse_decision(r#"{"level":"patch"}"#).is_err(),
3598 "a reply with no reason at all must not pass"
3599 );
3600 }
3601
3602 #[test]
3603 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
3604 let prompt = decision_prompt(
3605 "feat: add a phone endpoint",
3606 "add POST /api/widgets",
3607 "1 file changed, 10 insertions(+)",
3608 &["src/web.rs".to_owned()],
3609 "0.8.0",
3610 );
3611 assert!(prompt.contains("0.8.0"), "the current version is stated");
3612 assert!(
3613 prompt.contains("below `1.0.0`")
3614 && prompt.contains("`minor` is the digit that carries a breaking change"),
3615 "the 0.x rule must be explicit: {prompt}"
3616 );
3617 assert!(
3618 prompt.contains("choose the larger"),
3619 "the tie-break toward the bigger digit must be explicit: {prompt}"
3620 );
3621 }
3622
3623 #[test]
3624 fn release_only_diffs_are_recognised() {
3625 assert!(is_release_only(&["Cargo.toml".to_owned()]));
3626 assert!(is_release_only(&[
3627 "Cargo.toml".to_owned(),
3628 "Cargo.lock".to_owned()
3629 ]));
3630 assert!(!is_release_only(&[]));
3631 assert!(!is_release_only(&[
3632 "Cargo.toml".to_owned(),
3633 "src/main.rs".to_owned()
3634 ]));
3635 }
3636
3637 #[test]
3638 fn cargo_version_rewrite_touches_only_the_package_table() {
3639 let toml = "\
3640[package]\n\
3641# a comment mentioning version on purpose\n\
3642name = \"magi-cli\"\n\
3643version = \"0.8.0\"\n\
3644edition = \"2024\"\n\
3645\n\
3646[dependencies]\n\
3647foo = { version = \"1.2.3\" }\n";
3648 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
3649 assert!(out.contains("version = \"0.9.0\""));
3650 assert!(
3651 out.contains("foo = { version = \"1.2.3\" }"),
3652 "a dependency's own version pin must survive: {out}"
3653 );
3654 assert!(
3655 out.contains("# a comment mentioning version on purpose"),
3656 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
3657 );
3658 assert_eq!(
3659 out.lines().count(),
3660 toml.lines().count(),
3661 "the rewrite replaces one line, it does not add or remove any"
3662 );
3663 }
3664
3665 #[test]
3666 fn cargo_version_rewrite_fails_without_a_package_table() {
3667 let toml = "[dependencies]\nfoo = \"1\"\n";
3668 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
3669 }
3670
3671 #[test]
3677 fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
3678 let toml = "\
3679[workspace]\n\
3680members = [\"crates/a\", \"crates/b\"]\n\
3681\n\
3682[workspace.package]\n\
3683version = \"0.45.18\"\n\
3684edition = \"2024\"\n\
3685\n\
3686[workspace.dependencies]\n\
3687foo = { version = \"1.2.3\" }\n";
3688 let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
3689 assert!(out.contains("version = \"0.45.19\""));
3690 assert!(
3691 out.contains("foo = { version = \"1.2.3\" }"),
3692 "a workspace dependency's own version pin must survive: {out}"
3693 );
3694 assert_eq!(
3695 out.lines().count(),
3696 toml.lines().count(),
3697 "the rewrite replaces one line, it does not add or remove any"
3698 );
3699 }
3700
3701 #[test]
3705 fn cargo_version_rewrite_bumps_an_internal_workspace_dependency_pin() {
3706 let toml = "\
3707[workspace]\n\
3708members = [\"crates/kanade-shared\"]\n\
3709\n\
3710[workspace.package]\n\
3711version = \"0.48.2\"\n\
3712\n\
3713[workspace.dependencies]\n\
3714kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n";
3715 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3716 assert!(out.contains("[workspace.package]\nversion = \"0.48.3\"\n"));
3717 assert!(
3718 out.contains(
3719 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3720 ),
3721 "the internal pin must move with the workspace version: {out}"
3722 );
3723 }
3724
3725 #[test]
3727 fn cargo_version_rewrite_bumps_an_internal_pin_with_version_before_path() {
3728 let toml = "\
3729[workspace.package]\n\
3730version = \"1.0.0\"\n\
3731\n\
3732[workspace.dependencies]\n\
3733inner = { version = \"1.0.0\", path = \"crates/inner\" }\n";
3734 let out = rewrite_cargo_version(toml, "1.0.1").unwrap();
3735 assert!(out.contains("inner = { version = \"1.0.1\", path = \"crates/inner\" }"));
3736 }
3737
3738 #[test]
3741 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_table_form() {
3742 let toml = "\
3743[workspace.package]\n\
3744version = \"2.3.0\"\n\
3745\n\
3746[workspace.dependencies.inner]\n\
3747path = \"crates/inner\"\n\
3748version = \"2.3.0\"\n";
3749 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3750 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3751 assert!(out.contains(
3752 "[workspace.dependencies.inner]\npath = \"crates/inner\"\nversion = \"2.4.0\"\n"
3753 ));
3754 }
3755
3756 #[test]
3761 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_key_form() {
3762 let toml = "\
3763[workspace.package]\n\
3764version = \"2.3.0\"\n\
3765\n\
3766[workspace.dependencies]\n\
3767inner.path = \"crates/inner\"\n\
3768inner.version = \"2.3.0\"\n";
3769 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3770 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3771 assert!(out.contains("inner.path = \"crates/inner\"\ninner.version = \"2.4.0\"\n"));
3772 }
3773
3774 #[test]
3777 fn cargo_version_rewrite_leaves_a_path_only_workspace_dependency_untouched() {
3778 let toml = "\
3779[workspace.package]\n\
3780version = \"0.1.0\"\n\
3781\n\
3782[workspace.dependencies]\n\
3783internal-only = { path = \"crates/internal-only\" }\n";
3784 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3785 assert!(out.contains("internal-only = { path = \"crates/internal-only\" }"));
3786 }
3787
3788 #[test]
3792 fn cargo_version_rewrite_leaves_an_external_dependency_untouched() {
3793 let toml = "\
3794[workspace.package]\n\
3795version = \"0.1.0\"\n\
3796\n\
3797[workspace.dependencies]\n\
3798serde = { version = \"1\", features = [\"derive\"] }\n\
3799inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3800 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3801 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3802 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3803 }
3804
3805 #[test]
3809 fn cargo_version_rewrite_without_workspace_dependencies_table_still_bumps_package() {
3810 let toml = "[workspace.package]\nversion = \"0.9.0\"\nedition = \"2024\"\n";
3811 let out = rewrite_cargo_version(toml, "0.10.0").unwrap();
3812 assert_eq!(
3813 out,
3814 "[workspace.package]\nversion = \"0.10.0\"\nedition = \"2024\"\n"
3815 );
3816 }
3817
3818 #[test]
3821 fn cargo_version_rewrite_handles_a_kanade_shaped_workspace_dependencies_table() {
3822 let toml = "\
3823[workspace.package]\n\
3824version = \"0.48.2\"\n\
3825\n\
3826[workspace.dependencies]\n\
3827anyhow = { version = \"1\" }\n\
3828serde = { version = \"1\", features = [\"derive\"] }\n\
3829kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n\
3830kanade-core = { path = \"crates/kanade-core\", version = \"0.48.2\" }\n\
3831kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }\n";
3832 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3833 assert!(out.contains("anyhow = { version = \"1\" }"));
3834 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3835 assert!(
3836 out.contains(
3837 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3838 )
3839 );
3840 assert!(
3841 out.contains("kanade-core = { path = \"crates/kanade-core\", version = \"0.48.3\" }")
3842 );
3843 assert!(out.contains("kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }"));
3844 }
3845
3846 #[test]
3850 fn cargo_version_rewrite_bails_on_an_unrepresentable_inline_table() {
3851 let toml = "\
3852[workspace.package]\n\
3853version = \"0.1.0\"\n\
3854\n\
3855[workspace.dependencies]\n\
3856inner = { path = \"crates/inner\",\n\
3857 version = \"0.1.0\" }\n";
3858 assert!(rewrite_cargo_version(toml, "0.2.0").is_err());
3859 }
3860
3861 #[test]
3864 fn cargo_version_rewrite_recognises_a_commented_workspace_dependencies_header() {
3865 let toml = "\
3866[workspace.package]\n\
3867version = \"0.1.0\"\n\
3868\n\
3869[workspace.dependencies] # internal pins\n\
3870inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3871 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3872 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3873 }
3874
3875 #[test]
3878 fn cargo_version_rewrite_bumps_a_quoted_dependency_key() {
3879 let toml = "\
3880[workspace.package]\n\
3881version = \"0.1.0\"\n\
3882\n\
3883[workspace.dependencies]\n\
3884\"inner\" = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3885 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3886 assert!(out.contains("\"inner\" = { path = \"crates/inner\", version = \"0.2.0\" }"));
3887 }
3888
3889 #[test]
3893 fn cargo_version_rewrite_bumps_a_literal_string_version_pin() {
3894 let toml = "\
3895[workspace.package]\n\
3896version = \"0.1.0\"\n\
3897\n\
3898[workspace.dependencies]\n\
3899inner = { path = 'crates/inner', version = '0.1.0' }\n";
3900 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3901 assert!(out.contains("inner = { path = 'crates/inner', version = '0.2.0' }"));
3902 }
3903
3904 #[test]
3910 fn cargo_version_rewrite_does_not_require_the_whole_file_to_parse() {
3911 let toml = "\
3912[workspace.package]\n\
3913version = \"0.1.0\"\n\
3914\n\
3915[workspace.dependencies]\n\
3916inner = { path = \"crates/inner\", version = \"0.1.0\" }\n\
3917\n\
3918[workspace.package]\n\
3919edition = \"2024\"\n";
3920 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3921 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3922 assert!(
3923 toml::from_str::<toml::Value>(toml).is_err(),
3924 "the fixture itself must be invalid as a whole file, or this test proves nothing"
3925 );
3926 }
3927
3928 #[test]
3929 fn current_version_prefers_the_package_table_when_both_exist() {
3930 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
3931 assert_eq!(current_version(toml).unwrap(), "0.8.0");
3932 }
3933
3934 #[test]
3937 fn current_version_falls_back_to_workspace_package_without_a_package_table() {
3938 let toml = "\
3939[workspace]\n\
3940members = [\"crates/a\", \"crates/b\"]\n\
3941\n\
3942[workspace.package]\n\
3943version = \"0.45.18\"\n";
3944 assert_eq!(current_version(toml).unwrap(), "0.45.18");
3945 }
3946
3947 #[test]
3948 fn coalesce_proceeds_with_nothing_pending() {
3949 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
3950 }
3951
3952 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
3955 PendingBump {
3956 target_version: target_version.to_owned(),
3957 level,
3958 branch: format!("chore/release-v{target_version}"),
3959 pr_url: "https://example.invalid/pull/9".to_owned(),
3960 }
3961 }
3962
3963 #[test]
3964 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
3965 let pending = test_pending("0.9.0", BumpLevel::Minor);
3966 assert_eq!(
3967 coalesce(Some(&pending), "0.8.0").unwrap(),
3968 Coalesce::Skip {
3969 target_version: "0.9.0".to_owned()
3970 }
3971 );
3972 }
3973
3974 #[test]
3975 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
3976 let pending = test_pending("0.9.0", BumpLevel::Minor);
3977 assert_eq!(
3979 coalesce(Some(&pending), "0.9.0").unwrap(),
3980 Coalesce::Proceed
3981 );
3982 assert_eq!(
3984 coalesce(Some(&pending), "1.0.0").unwrap(),
3985 Coalesce::Proceed
3986 );
3987 }
3988
3989 #[test]
3990 fn pending_action_escalates_only_for_a_more_severe_decision() {
3991 assert_eq!(
3992 pending_action(BumpLevel::Patch, BumpLevel::Patch),
3993 PendingAction::AlreadyCovered
3994 );
3995 assert_eq!(
3996 pending_action(BumpLevel::Patch, BumpLevel::Minor),
3997 PendingAction::Escalate
3998 );
3999 assert_eq!(
4000 pending_action(BumpLevel::Patch, BumpLevel::Major),
4001 PendingAction::Escalate
4002 );
4003 assert_eq!(
4004 pending_action(BumpLevel::Minor, BumpLevel::Patch),
4005 PendingAction::AlreadyCovered
4006 );
4007 assert_eq!(
4008 pending_action(BumpLevel::Major, BumpLevel::Minor),
4009 PendingAction::AlreadyCovered
4010 );
4011 assert_eq!(
4012 pending_action(BumpLevel::Major, BumpLevel::Major),
4013 PendingAction::AlreadyCovered
4014 );
4015 }
4016
4017 #[test]
4018 fn pr_state_parsing_reads_open_and_not_open() {
4019 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
4020 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
4021 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
4022 }
4023
4024 #[test]
4025 fn a_lock_is_exclusive_until_dropped() {
4026 let dir = tempfile::tempdir().unwrap();
4027 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4028 let first = MarkerLock::acquire(&marker)
4029 .unwrap()
4030 .expect("first attempt takes the lock");
4031 assert!(
4032 MarkerLock::acquire(&marker).unwrap().is_none(),
4033 "a second attempt must be refused while the first holds it"
4034 );
4035 drop(first);
4036 assert!(
4037 MarkerLock::acquire(&marker).unwrap().is_some(),
4038 "dropping the guard releases the lock for the next attempt"
4039 );
4040 }
4041
4042 #[test]
4043 fn a_stale_lock_is_reclaimed() {
4044 let dir = tempfile::tempdir().unwrap();
4045 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4046 let lock_path = marker.with_extension("lock");
4047 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
4048 std::fs::write(&lock_path, b"").unwrap();
4049 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
4050 std::fs::OpenOptions::new()
4051 .write(true)
4052 .open(&lock_path)
4053 .unwrap()
4054 .set_modified(old)
4055 .unwrap();
4056 assert!(
4057 MarkerLock::acquire(&marker).unwrap().is_some(),
4058 "a lock older than the stale window must be reclaimed rather than block forever"
4059 );
4060 }
4061
4062 #[tokio::test]
4063 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
4064 let dir = tempfile::tempdir().unwrap();
4065 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4066 let held = MarkerLock::acquire(&marker)
4067 .unwrap()
4068 .expect("seed the contention");
4069 let releaser = tokio::spawn(async move {
4070 tokio::time::sleep(Duration::from_millis(20)).await;
4071 drop(held);
4072 });
4073 let waited =
4074 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
4075 .await
4076 .unwrap();
4077 assert!(
4078 waited.is_some(),
4079 "a merge landing behind another's still-running decision must not be dropped - it \
4080 must wait for that decision to finish and then judge against what it left behind"
4081 );
4082 releaser.await.unwrap();
4083 }
4084
4085 #[tokio::test]
4086 async fn a_lock_held_past_the_ceiling_gives_up() {
4087 let dir = tempfile::tempdir().unwrap();
4088 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4089 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
4090 let waited =
4091 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
4092 .await
4093 .unwrap();
4094 assert!(
4095 waited.is_none(),
4096 "a lock genuinely held past the ceiling must eventually give up rather than wait \
4097 forever"
4098 );
4099 }
4100
4101 #[test]
4102 fn level_between_reads_off_the_differing_digit() {
4103 assert_eq!(
4104 level_between(
4105 Version::parse("0.8.0").unwrap(),
4106 Version::parse("1.0.0").unwrap()
4107 ),
4108 Some(BumpLevel::Major)
4109 );
4110 assert_eq!(
4111 level_between(
4112 Version::parse("0.8.0").unwrap(),
4113 Version::parse("0.9.0").unwrap()
4114 ),
4115 Some(BumpLevel::Minor)
4116 );
4117 assert_eq!(
4118 level_between(
4119 Version::parse("0.8.0").unwrap(),
4120 Version::parse("0.8.1").unwrap()
4121 ),
4122 Some(BumpLevel::Patch)
4123 );
4124 assert_eq!(
4125 level_between(
4126 Version::parse("0.8.0").unwrap(),
4127 Version::parse("0.8.0").unwrap()
4128 ),
4129 None
4130 );
4131 }
4132
4133 #[test]
4134 fn open_release_pr_is_found_among_unrelated_pull_requests() {
4135 let json = r#"[
4136 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
4137 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
4138 ]"#;
4139 let found = parse_open_release_pr(json).unwrap();
4140 assert_eq!(
4141 found,
4142 Some((
4143 "chore/release-v0.9.0".to_owned(),
4144 "https://example.invalid/pull/2".to_owned()
4145 ))
4146 );
4147 }
4148
4149 #[test]
4150 fn no_open_release_pr_reads_as_none_not_an_error() {
4151 let json =
4152 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
4153 assert_eq!(parse_open_release_pr(json).unwrap(), None);
4154 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
4155 }
4156
4157 #[test]
4158 fn marker_round_trips_through_disk() {
4159 let dir = tempfile::tempdir().unwrap();
4160 let path = marker_path(dir.path(), Path::new("/repos/magi"));
4161 assert!(read_marker(&path).is_none());
4162
4163 let marker = test_pending("0.9.0", BumpLevel::Patch);
4164 write_marker(&path, &marker).unwrap();
4165 let read_back = read_marker(&path).unwrap();
4166 assert_eq!(read_back.target_version, "0.9.0");
4167 assert_eq!(read_back.level, BumpLevel::Patch);
4168 assert_eq!(read_back.pr_url, marker.pr_url);
4169
4170 clear_marker(&path);
4171 assert!(read_marker(&path).is_none());
4172 }
4173
4174 #[test]
4175 fn different_repos_get_different_marker_files() {
4176 let dir = tempfile::tempdir().unwrap();
4177 let a = marker_path(dir.path(), Path::new("/repos/a"));
4178 let b = marker_path(dir.path(), Path::new("/repos/b"));
4179 assert_ne!(a, b);
4180 }
4181
4182 #[test]
4186 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
4187 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
4188 assert!(
4189 is_release_only(&files),
4190 "the bump pull request's own diff must read as release-only"
4191 );
4192 }
4193
4194 #[test]
4195 fn should_release_bump_reads_only_a_merged_status() {
4196 assert!(should_release_bump(RunStatus::Merged));
4197 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
4198 assert!(!should_release_bump(other));
4199 }
4200 }
4201
4202 #[test]
4206 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
4207 let pr = land::PrState {
4208 url: "https://github.com/o/r/pull/1".to_owned(),
4209 number: 1,
4210 state: PrLifecycle::Merged,
4211 checks: land::Checks::Green,
4212 failing: Vec::new(),
4213 review_comments: Vec::new(),
4214 blocking: land::Blocking::No,
4215 };
4216 assert_eq!(
4217 land::decide(&pr, 0, 4, Duration::ZERO),
4218 land::Step::Done { merged: true }
4219 );
4220 assert!(should_release_bump(RunStatus::Merged));
4221 }
4222
4223 #[test]
4228 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
4229 let pr = land::PrState {
4230 url: "https://github.com/o/r/pull/2".to_owned(),
4231 number: 2,
4232 state: PrLifecycle::Open,
4233 checks: land::Checks::Green,
4234 failing: Vec::new(),
4235 review_comments: Vec::new(),
4236 blocking: land::Blocking::No,
4237 };
4238 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
4239 assert!(should_release_bump(RunStatus::Merged));
4242 }
4243
4244 #[test]
4247 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
4248 let argv = land::merge_argv(3, "feat: something");
4249 let outcome = land::merged_after_all(
4250 &argv,
4251 "could not determine current branch: not on any branch",
4252 Some(PrLifecycle::Merged),
4253 );
4254 assert!(outcome.is_some(), "the forge's confirmation must win");
4255 assert!(should_release_bump(RunStatus::Merged));
4256
4257 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
4260 assert!(land::merged_after_all(&argv, "network error", None).is_none());
4261 }
4262
4263 #[test]
4265 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
4266 let pr = land::PrState {
4267 url: "https://github.com/o/r/pull/4".to_owned(),
4268 number: 4,
4269 state: PrLifecycle::Closed,
4270 checks: land::Checks::Green,
4271 failing: Vec::new(),
4272 review_comments: Vec::new(),
4273 blocking: land::Blocking::No,
4274 };
4275 assert_eq!(
4276 land::decide(&pr, 0, 4, Duration::ZERO),
4277 land::Step::Done { merged: false }
4278 );
4279 assert!(!should_release_bump(RunStatus::Blocked));
4280 }
4281
4282 fn shun_files() -> Vec<crate::config::VersionFile> {
4283 use crate::config::{VersionFile, VersionKind};
4284 vec![
4285 VersionFile {
4286 path: "src-tauri/Cargo.toml".to_owned(),
4287 kind: VersionKind::Cargo,
4288 },
4289 VersionFile {
4290 path: "package.json".to_owned(),
4291 kind: VersionKind::Json,
4292 },
4293 VersionFile {
4294 path: "src-tauri/tauri.conf.json".to_owned(),
4295 kind: VersionKind::Json,
4296 },
4297 ]
4298 }
4299
4300 const SHUN_TOML: &str = "[package]\nname = \"shun\"\nversion = \"1.0.0\"\n";
4301 const SHUN_LOCK: &str = "[[package]]\nname = \"shun\"\nversion = \"1.0.0\"\n";
4302
4303 async fn shun_origin(package_version: &str) -> (tempfile::TempDir, PathBuf) {
4304 let pkg = format!("{{\n \"version\": \"{package_version}\"\n}}\n");
4305 let origin = origin_with(&[("README.md", "x\n")]).await;
4306 for (name, body) in [
4307 ("src-tauri/Cargo.toml", SHUN_TOML),
4308 ("src-tauri/Cargo.lock", SHUN_LOCK),
4309 ("package.json", pkg.as_str()),
4310 ("src-tauri/tauri.conf.json", "{ \"version\": \"1.0.0\" }\n"),
4311 ] {
4312 let path = origin.1.join(name);
4313 tokio::fs::create_dir_all(path.parent().unwrap())
4314 .await
4315 .unwrap();
4316 tokio::fs::write(path, body).await.unwrap();
4317 }
4318 git::git(&origin.1, &["add", "-A"]).await.unwrap();
4319 git::git(&origin.1, &["commit", "-m", "shun"])
4320 .await
4321 .unwrap();
4322 git::git(&origin.1, &["push", "origin", "main"])
4323 .await
4324 .unwrap();
4325 origin
4326 }
4327
4328 #[tokio::test]
4329 async fn a_shun_layout_bump_changes_only_the_declared_files_and_the_adjacent_lock() {
4330 let (_d, repo) = shun_origin("1.0.0").await;
4331 let mut state = winner_state(&repo, "main");
4332 state.config.release.version_files = shun_files();
4333 write_release_files(&state, &repo, "1.1.0").await.unwrap();
4334 let status = git::git(&repo, &["status", "--porcelain"]).await.unwrap();
4335 let mut paths: Vec<_> = status.lines().map(|l| l[3..].to_owned()).collect();
4336 paths.sort();
4337 assert_eq!(
4338 paths,
4339 [
4340 "package.json",
4341 "src-tauri/Cargo.lock",
4342 "src-tauri/Cargo.toml",
4343 "src-tauri/tauri.conf.json"
4344 ]
4345 );
4346 let lock = std::fs::read_to_string(repo.join("src-tauri/Cargo.lock")).unwrap();
4347 assert!(lock.contains("version = \"1.1.0\""));
4348 }
4349
4350 #[tokio::test]
4351 async fn an_unconfigured_repo_with_auto_tag_but_no_root_manifest_is_skipped_visibly() {
4352 let (_d, repo) = origin_with(&[("README.md", "x\n")]).await;
4353 let wf = repo.join(".github/workflows");
4354 tokio::fs::create_dir_all(&wf).await.unwrap();
4355 tokio::fs::write(wf.join("auto-tag.yml"), "name: t\n")
4356 .await
4357 .unwrap();
4358 git::git(&repo, &["add", "-A"]).await.unwrap();
4359 git::git(&repo, &["commit", "-m", "wf"]).await.unwrap();
4360 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
4361 let home = tempfile::tempdir().unwrap();
4362 let store = Notices::at(home.path().join("notifications"));
4363 let mut state = winner_state(&repo, "main");
4364 after_merge_at(
4365 &mut state,
4366 "https://example.invalid/pull/1",
4367 Some(home.path()),
4368 Some(&store),
4369 )
4370 .await
4371 .unwrap();
4372 let rec = state.release_bump.clone().expect("a skip is recorded");
4373 assert!(rec.skipped.is_some_and(|s| s.contains("Cargo.toml")));
4374 assert!(
4375 rec.action_required
4376 .is_some_and(|a| a.contains("version_files"))
4377 );
4378 assert!(rec.pr_url.is_none() && !rec.automerge_enabled);
4379 assert_eq!(store.list().len(), 1);
4380 assert!(crate::report::run(&state).contains("SKIPPED"));
4381 }
4382
4383 #[tokio::test]
4384 async fn a_non_rust_repo_without_auto_tag_records_a_skip_without_paging() {
4385 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
4386 let home = tempfile::tempdir().unwrap();
4387 let store = Notices::at(home.path().join("notifications"));
4388 let mut state = winner_state(&repo, "main");
4389 after_merge_at(
4390 &mut state,
4391 "https://example.invalid/pull/1",
4392 Some(home.path()),
4393 Some(&store),
4394 )
4395 .await
4396 .unwrap();
4397 let rec = state.release_bump.clone().unwrap();
4398 assert!(rec.skipped.is_some());
4399 assert!(rec.action_required.is_none());
4400 assert!(store.list().is_empty());
4401 }
4402
4403 #[tokio::test]
4404 async fn disagreeing_version_files_stop_the_bump_and_ask_the_owner() {
4405 let (_d, repo) = shun_origin("0.9.0").await;
4406 let home = tempfile::tempdir().unwrap();
4407 let store = Notices::at(home.path().join("notifications"));
4408 let mut state = winner_state(&repo, "main");
4409 state.config.release.version_files = shun_files();
4410 after_merge_at(
4411 &mut state,
4412 "https://example.invalid/pull/1",
4413 Some(home.path()),
4414 Some(&store),
4415 )
4416 .await
4417 .unwrap();
4418 let rec = state.release_bump.clone().unwrap();
4419 assert!(rec.skipped.is_some_and(|s| s.contains("disagree")));
4420 assert!(rec.action_required.is_some());
4421 assert_eq!(store.list().len(), 1);
4422 assert!(rec.pr_url.is_none());
4423 }
4424
4425 #[tokio::test]
4426 async fn a_change_that_already_raised_the_version_is_left_to_the_repos_auto_tag() {
4427 let (_d, repo) = shun_origin("1.0.0").await;
4428 git::git(&repo, &["checkout", "-b", "feat"]).await.unwrap();
4429 let toml = SHUN_TOML.replace("1.0.0", "1.0.1");
4430 tokio::fs::write(repo.join("src-tauri/Cargo.toml"), toml)
4431 .await
4432 .unwrap();
4433 tokio::fs::write(repo.join("app.ts"), "x\n").await.unwrap();
4434 git::git(&repo, &["add", "-A"]).await.unwrap();
4435 git::git(&repo, &["commit", "-m", "fix and bump"])
4436 .await
4437 .unwrap();
4438 let home = tempfile::tempdir().unwrap();
4439 let store = Notices::at(home.path().join("notifications"));
4440 let mut state = winner_state(&repo, "main");
4441 state.candidates[0].branch = "feat".to_owned();
4442 state.config.release.version_files = shun_files();
4443 after_merge_at(
4444 &mut state,
4445 "https://example.invalid/pull/1",
4446 Some(home.path()),
4447 Some(&store),
4448 )
4449 .await
4450 .unwrap();
4451 let rec = state.release_bump.clone().unwrap();
4452 assert!(
4453 rec.skipped
4454 .is_some_and(|s| s.contains("already") || s.contains("raised"))
4455 );
4456 assert!(rec.pr_url.is_none() && rec.action_required.is_none());
4457 assert!(store.list().is_empty());
4458 }
4459
4460 #[tokio::test]
4461 async fn a_stale_local_base_does_not_make_an_inherited_version_look_like_this_changes() {
4462 let (_d, repo) = shun_origin("1.0.0").await;
4463 let old = git::rev_parse(&repo, "HEAD").await.unwrap();
4464 tokio::fs::write(
4465 repo.join("src-tauri/Cargo.toml"),
4466 SHUN_TOML.replace("1.0.0", "1.0.1"),
4467 )
4468 .await
4469 .unwrap();
4470 git::git(&repo, &["commit", "-am", "bump upstream"])
4471 .await
4472 .unwrap();
4473 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
4474 git::git(&repo, &["checkout", "-b", "feat"]).await.unwrap();
4475 tokio::fs::write(repo.join("app.ts"), "x\n").await.unwrap();
4476 git::git(&repo, &["add", "-A"]).await.unwrap();
4477 git::git(&repo, &["commit", "-m", "fix"]).await.unwrap();
4478 git::git(&repo, &["update-ref", "refs/heads/main", &old])
4480 .await
4481 .unwrap();
4482 let home = tempfile::tempdir().unwrap();
4483 let store = Notices::at(home.path().join("notifications"));
4484 let mut state = winner_state(&repo, "main");
4485 state.candidates[0].branch = "feat".to_owned();
4486 state.config.release.version_files = shun_files();
4487 let _ = after_merge_at(
4488 &mut state,
4489 "https://example.invalid/pull/1",
4490 Some(home.path()),
4491 Some(&store),
4492 )
4493 .await;
4494 assert!(
4495 !state
4496 .events
4497 .iter()
4498 .any(|e| e.message.contains("already moved")),
4499 "{:?}",
4500 state.events
4501 );
4502 }
4503
4504 #[tokio::test]
4505 async fn a_dependency_only_change_on_a_stale_local_base_is_not_release_only() {
4506 let (_d, repo) = shun_origin("1.0.0").await;
4507 let old = git::rev_parse(&repo, "HEAD").await.unwrap();
4508 for (p, from) in [
4509 ("src-tauri/Cargo.toml", SHUN_TOML),
4510 ("package.json", "{\n \"version\": \"1.0.0\"\n}\n"),
4511 ("src-tauri/tauri.conf.json", "{ \"version\": \"1.0.0\" }\n"),
4512 ] {
4513 tokio::fs::write(repo.join(p), from.replace("1.0.0", "1.0.1"))
4514 .await
4515 .unwrap();
4516 }
4517 tokio::fs::write(
4518 repo.join("src-tauri/Cargo.lock"),
4519 SHUN_LOCK.replace("1.0.0", "1.0.1"),
4520 )
4521 .await
4522 .unwrap();
4523 git::git(&repo, &["commit", "-am", "bump upstream"])
4524 .await
4525 .unwrap();
4526 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
4527 git::git(&repo, &["checkout", "-b", "feat"]).await.unwrap();
4528 tokio::fs::write(
4529 repo.join("package.json"),
4530 "{\n \"version\": \"1.0.1\",\n \"dependencies\": {}\n}\n",
4531 )
4532 .await
4533 .unwrap();
4534 git::git(&repo, &["commit", "-am", "deps"]).await.unwrap();
4535 git::git(&repo, &["update-ref", "refs/heads/main", &old])
4536 .await
4537 .unwrap();
4538 let home = tempfile::tempdir().unwrap();
4539 let store = Notices::at(home.path().join("notifications"));
4540 let mut state = winner_state(&repo, "main");
4541 state.candidates[0].branch = "feat".to_owned();
4542 state.config.release.version_files = shun_files();
4543 let _ = after_merge_at(
4544 &mut state,
4545 "https://example.invalid/pull/1",
4546 Some(home.path()),
4547 Some(&store),
4548 )
4549 .await;
4550 assert!(
4551 !state
4552 .events
4553 .iter()
4554 .any(|e| e.message.contains("only the release manifest")),
4555 "{:?}",
4556 state.events
4557 );
4558 assert!(
4559 state
4560 .release_bump
4561 .as_ref()
4562 .is_none_or(|b| b.skipped.is_none())
4563 );
4564 }
4565}