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