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