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