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