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 if let Some(dir) = cache_dir {
2492 cmd.env("CARGO_TARGET_DIR", dir);
2493 }
2494 let out = cmd
2495 .stdin(std::process::Stdio::null())
2496 .output()
2497 .await
2498 .context("spawn cargo build")?;
2499 if !out.status.success() {
2500 bail!(
2501 "cargo build failed while syncing Cargo.lock: {}",
2502 String::from_utf8_lossy(&out.stderr).trim()
2503 );
2504 }
2505 Ok(())
2506}
2507
2508fn release_pr(
2513 level: &str,
2514 reason: &str,
2515 next_version: &str,
2516 run_id: &str,
2517 source_pr_url: &str,
2518) -> (String, String) {
2519 let title = format!("chore: release v{next_version} ({level} bump)");
2520 let body = format!(
2521 "## Background\n\n\
2522 A change that was just merged is a `{level}` change, so the crate needs a new \
2523 release: {reason}\n\n\
2524 Triggered by magi run `{run_id}`, which landed {source}.\n\n\
2525 ## Change\n\n\
2526 Raises the package version to `v{next_version}` in `Cargo.toml`, with \
2527 `Cargo.lock` following it. Nothing else changes.\n\n\
2528 ## Risk\n\n\
2529 Version-bump-only, so there is nothing here for a reviewer to find. Merging \
2530 it starts the release pipeline (auto-tag, then the release workflow).",
2531 source = source_pr_url,
2532 );
2533 (title, body)
2534}
2535
2536async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
2538 let out = tokio::process::Command::new("gh")
2539 .args(["pr", "view", pr_url, "--json", "title"])
2540 .current_dir(repo)
2541 .quiet()
2542 .stdin(std::process::Stdio::null())
2543 .output()
2544 .await
2545 .context("spawn gh pr view")?;
2546 if !out.status.success() {
2547 bail!(
2548 "gh pr view {pr_url}: {}",
2549 String::from_utf8_lossy(&out.stderr).trim()
2550 );
2551 }
2552 #[derive(Deserialize)]
2553 struct Title {
2554 title: String,
2555 }
2556 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
2557 .context("parse `gh pr view --json title` output")?;
2558 Ok(parsed.title)
2559}
2560
2561async fn gh_pr_create(
2562 cwd: &Path,
2563 base: &str,
2564 head: &str,
2565 title: &str,
2566 body: &str,
2567) -> Result<String> {
2568 let out = tokio::process::Command::new("gh")
2569 .args([
2570 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
2571 ])
2572 .current_dir(cwd)
2573 .quiet()
2574 .stdin(std::process::Stdio::null())
2575 .output()
2576 .await
2577 .context("spawn gh pr create")?;
2578 if out.status.success() {
2579 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2580 } else {
2581 bail!(
2582 "gh pr create: {}",
2583 String::from_utf8_lossy(&out.stderr).trim()
2584 )
2585 }
2586}
2587
2588async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
2592 let out = tokio::process::Command::new("gh")
2593 .args([
2594 "pr",
2595 "merge",
2596 pr_url,
2597 "--auto",
2598 "--squash",
2599 "--delete-branch",
2600 ])
2601 .current_dir(cwd)
2602 .quiet()
2603 .stdin(std::process::Stdio::null())
2604 .output()
2605 .await
2606 .context("spawn gh pr merge --auto")?;
2607 if out.status.success() {
2608 Ok(())
2609 } else {
2610 bail!(
2611 "gh pr merge --auto: {}",
2612 String::from_utf8_lossy(&out.stderr).trim()
2613 )
2614 }
2615}
2616
2617async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
2621 let out = tokio::process::Command::new("gh")
2622 .args(["pr", "edit", pr_url, "--title", title])
2623 .current_dir(cwd)
2624 .quiet()
2625 .stdin(std::process::Stdio::null())
2626 .output()
2627 .await
2628 .context("spawn gh pr edit")?;
2629 if out.status.success() {
2630 Ok(())
2631 } else {
2632 bail!(
2633 "gh pr edit --title: {}",
2634 String::from_utf8_lossy(&out.stderr).trim()
2635 )
2636 }
2637}
2638
2639#[cfg(test)]
2640mod tests {
2641 use super::*;
2642 use crate::notices::Severity;
2643
2644 #[test]
2645 fn github_facing_bump_text_is_english() {
2646 let (title, body) =
2647 release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
2648 assert!(crate::github_text::check(&title, &body).is_empty());
2649 assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
2650 assert_eq!(title, "chore: release v0.37.0 (minor bump)");
2651 assert!(
2652 body.contains("## Background") && body.contains("## Change"),
2653 "{body}"
2654 );
2655 let p = decision_prompt("s", "i", "d", &[], "0.36.5");
2656 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
2657 }
2658 use crate::config::Config;
2659 use crate::land::PrLifecycle;
2660
2661 #[tokio::test]
2667 async fn a_disabled_config_does_nothing() {
2668 let config = Config {
2669 merge: crate::config::Merge {
2670 release_bump: false,
2671 ..crate::config::Merge::default()
2672 },
2673 ..Config::default()
2674 };
2675 let mut state = RunState::new(
2676 PathBuf::from("/no/such/repo"),
2677 "main".to_owned(),
2678 "0000000000000000000000000000000000000000".to_owned(),
2679 "irrelevant".to_owned(),
2680 config,
2681 );
2682 after_merge(&mut state, "https://example.invalid/pull/1")
2683 .await
2684 .expect("a disabled config must return Ok without touching anything");
2685 assert!(
2686 state.events.is_empty(),
2687 "nothing should happen at all, not even a logged event"
2688 );
2689 }
2690
2691 async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
2693 let dir = tempfile::tempdir().unwrap();
2694 let origin = dir.path().join("origin.git");
2695 let repo = dir.path().join("repo");
2696 let o = origin.to_string_lossy().into_owned();
2697 git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
2698 .await
2699 .unwrap();
2700 tokio::fs::create_dir_all(&repo).await.unwrap();
2701 git::git(&repo, &["init", "-b", "main"]).await.unwrap();
2702 git::git(&repo, &["config", "user.name", "test"])
2703 .await
2704 .unwrap();
2705 git::git(&repo, &["config", "user.email", "test@example.com"])
2706 .await
2707 .unwrap();
2708 for (name, body) in files {
2709 tokio::fs::write(repo.join(name), body).await.unwrap();
2710 }
2711 git::git(&repo, &["add", "-A"]).await.unwrap();
2712 git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
2713 git::git(&repo, &["remote", "add", "origin", &o])
2714 .await
2715 .unwrap();
2716 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
2717 (dir, repo)
2718 }
2719
2720 async fn no_pr(_: String) -> Result<bool> {
2721 Ok(false)
2722 }
2723
2724 async fn with_branch(ahead: bool) -> (tempfile::TempDir, PathBuf) {
2727 let (d, repo) = origin_with(&[("f", "x\n")]).await;
2728 git::git(&repo, &["branch", "b"]).await.unwrap();
2729 if ahead {
2730 git::git(&repo, &["checkout", "-q", "b"]).await.unwrap();
2731 git::git(&repo, &["commit", "--allow-empty", "-m", "wip"])
2732 .await
2733 .unwrap();
2734 git::git(&repo, &["checkout", "-q", "main"]).await.unwrap();
2735 }
2736 (d, repo)
2737 }
2738
2739 async fn attempt(repo: &Path, open_pr: bool, fail_after_push: Option<bool>) -> Result<()> {
2740 let wt = repo.parent().unwrap().join("bump");
2741 release_attempt(
2742 repo,
2743 "origin",
2744 "main",
2745 &wt,
2746 "b",
2747 |_| async move { Ok(open_pr) },
2748 |w| async move {
2749 if fail_after_push == Some(true) {
2750 git::push(&w, "origin", "b").await?;
2751 }
2752 if fail_after_push.is_some() {
2753 bail!("cargo build failed");
2754 }
2755 Ok(())
2756 },
2757 )
2758 .await
2759 }
2760
2761 #[tokio::test]
2762 async fn a_stale_ancestor_branch_is_deleted_and_the_attempt_proceeds() {
2763 let (_d, repo) = with_branch(false).await;
2764 attempt(&repo, false, None).await.unwrap();
2765 assert!(git::branch_exists(&repo, "b").await.unwrap());
2767 }
2768
2769 #[tokio::test]
2770 async fn a_stale_branch_holding_a_leftover_worktree_is_reclaimed() {
2771 let (_d, repo) = with_branch(false).await;
2772 let old = repo.parent().unwrap().join("old");
2773 git::git(&repo, &["worktree", "add", &old.to_string_lossy(), "b"])
2774 .await
2775 .unwrap();
2776 attempt(&repo, false, None).await.unwrap();
2777 assert!(!old.exists());
2778 }
2779
2780 #[tokio::test]
2781 async fn a_stale_branch_with_an_unmerged_commit_is_kept_with_a_reason() {
2782 let (_d, repo) = with_branch(true).await;
2783 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2784 assert!(e.contains("`b`") || e.contains("branch b"), "{e}");
2785 assert!(e.contains("1 commit(s) not in origin/main"), "{e}");
2786 assert!(git::branch_exists(&repo, "b").await.unwrap());
2787 }
2788
2789 #[tokio::test]
2790 async fn a_stale_branch_on_the_remote_is_kept() {
2791 let (_d, repo) = with_branch(false).await;
2792 git::git(&repo, &["push", "origin", "b"]).await.unwrap();
2793 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2794 assert!(e.contains("branch b") && e.contains("is on origin"), "{e}");
2795 assert!(git::branch_exists(&repo, "b").await.unwrap());
2796 }
2797
2798 #[tokio::test]
2799 async fn a_stale_branch_with_an_open_pull_request_is_kept() {
2800 let (_d, repo) = with_branch(false).await;
2801 let e = attempt(&repo, true, None).await.unwrap_err().to_string();
2802 assert!(e.contains("open pull request"), "{e}");
2803 assert!(git::branch_exists(&repo, "b").await.unwrap());
2804 }
2805
2806 #[tokio::test]
2807 async fn an_unanswerable_pull_request_check_keeps_the_branch() {
2808 let (_d, repo) = with_branch(false).await;
2809 let wt = repo.parent().unwrap().join("bump");
2810 let e = release_attempt(
2811 &repo,
2812 "origin",
2813 "main",
2814 &wt,
2815 "b",
2816 |_| async { bail!("offline") },
2817 |_| async { Ok(()) },
2818 )
2819 .await
2820 .unwrap_err()
2821 .to_string();
2822 assert!(
2823 e.contains("could not check for an open pull request"),
2824 "{e}"
2825 );
2826 assert!(git::branch_exists(&repo, "b").await.unwrap());
2827 }
2828
2829 #[tokio::test]
2830 async fn a_failed_attempt_removes_its_own_worktree_and_branch() {
2831 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2832 let wt = repo.parent().unwrap().join("bump");
2833 let e = attempt(&repo, false, Some(false)).await.unwrap_err();
2834 assert!(format!("{e:#}").contains("cargo build failed"));
2835 assert!(!wt.exists());
2836 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2837 }
2838
2839 #[tokio::test]
2840 async fn a_pushed_branch_survives_a_failed_attempt_and_the_reason_says_so() {
2841 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2842 let e = attempt(&repo, false, Some(true)).await.unwrap_err();
2843 let e = format!("{e:#}");
2844 assert!(
2845 e.contains("cargo build failed") && e.contains("pushed to origin"),
2846 "{e}"
2847 );
2848 assert!(git::branch_exists(&repo, "b").await.unwrap());
2849 }
2850
2851 #[tokio::test]
2852 async fn a_branch_left_by_a_half_done_worktree_add_is_removed() {
2853 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2856 let blocker = repo.parent().unwrap().join("blocker");
2857 tokio::fs::write(&blocker, "file").await.unwrap();
2858 let wt = blocker.join("bump");
2859 let r = release_attempt(
2860 &repo,
2861 "origin",
2862 "main",
2863 &wt,
2864 "b",
2865 |_| async { bail!("offline") },
2866 |_| async { Ok(()) },
2867 )
2868 .await;
2869 assert!(r.is_err());
2870 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2871 }
2872
2873 #[tokio::test]
2874 async fn the_retry_is_taken_at_most_once() {
2875 let (_d, repo) = with_branch(false).await;
2878 let blocker = repo.parent().unwrap().join("blocker");
2879 tokio::fs::write(&blocker, "file").await.unwrap();
2880 let wt = blocker.join("bump");
2881 let r = release_attempt(&repo, "origin", "main", &wt, "b", no_pr, |_| async {
2882 Ok(())
2883 })
2884 .await;
2885 assert!(r.is_err());
2886 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2887 }
2888
2889 #[tokio::test]
2890 async fn git_worktree_failure_reaches_release_notice_without_debug_exit_codes() {
2891 let (dir, repo) = origin_with(&[("f", "x\n")]).await;
2892 let worktree = dir.path().join("occupied");
2893 tokio::fs::create_dir(&worktree).await.unwrap();
2894 let existing = worktree.join("keep");
2895 tokio::fs::write(&existing, "operator data").await.unwrap();
2896 let err = release_attempt::<(), _, _, _, _>(
2897 &repo,
2898 "origin",
2899 "main",
2900 &worktree,
2901 "release-test",
2902 no_pr,
2903 |_| async { panic!("worktree creation must fail before release edits") },
2904 )
2905 .await
2906 .unwrap_err();
2907 let (version, reason) = notice_for_failure(&Progress::default(), &err);
2908 assert!(
2909 reason.contains("create the release-bump worktree"),
2910 "{reason}"
2911 );
2912 assert!(reason.contains("exit code 128"), "{reason}");
2913 let store = Notices::at(dir.path().join("notifications"));
2914 let mut state = winner_state(&repo, "main");
2915 state.status = RunStatus::Merged;
2916 report_problem_in(&mut state, &store, None, version.as_deref(), &reason, false).await;
2917 let notices = store.list();
2918 assert_eq!(notices.len(), 1);
2919 let notice = ¬ices[0];
2920 assert!(
2921 notice.message.contains("exit code 128"),
2922 "{}",
2923 notice.message
2924 );
2925 assert!(!notice.message.contains("Some(") && !notice.message.contains("None"));
2926 assert_eq!(notice.severity, Severity::Error);
2927 assert_eq!(state.status, RunStatus::Merged);
2928 assert!(state.needs_attention());
2929 assert!(!git::branch_exists(&repo, "release-test").await.unwrap());
2930 assert_eq!(
2931 tokio::fs::read_to_string(existing).await.unwrap(),
2932 "operator data"
2933 );
2934 }
2935
2936 #[tokio::test]
2937 async fn base_has_cargo_toml_tells_rust_from_non_rust() {
2938 let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
2939 assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
2940 let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
2941 assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
2942 assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
2944 }
2945
2946 #[tokio::test]
2947 async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
2948 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2949 let home = tempfile::tempdir().unwrap();
2950 let mut state = RunState::new(
2951 repo.clone(),
2952 "main".to_owned(),
2953 "0000000000000000000000000000000000000000".to_owned(),
2954 "task".to_owned(),
2955 Config::default(),
2956 );
2957 state.candidates.push(crate::run::Candidate {
2958 index: 0,
2959 label: 'A',
2960 agent: "x".to_owned(),
2961 branch: "main".to_owned(),
2962 worktree: repo.clone(),
2963 summary: String::new(),
2964 stat: String::new(),
2965 files: 1,
2966 commits: 1,
2967 empty: false,
2968 failed: None,
2969 verified_noop: None,
2970 duration_ms: 0,
2971 folded: false,
2972 });
2973 state.tally = Some(
2974 serde_json::from_value(serde_json::json!({
2975 "first_choice": {}, "borda": {}, "winner": "A",
2976 "unanimous_initial": true, "deliberated": false,
2977 "changed_votes": 0, "unanimous_final": true,
2978 }))
2979 .unwrap(),
2980 );
2981 after_merge_at(
2982 &mut state,
2983 "https://example.invalid/pull/1",
2984 Some(home.path()),
2985 None,
2986 )
2987 .await
2988 .expect("a non-Rust repository is not an error");
2989 let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
2990 assert_eq!(bumps.len(), 1, "{:?}", state.events);
2991 assert_eq!(
2992 bumps[0].message,
2993 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
2994 );
2995 assert!(
2996 std::fs::read_dir(home.path()).unwrap().next().is_none(),
2997 "no marker and no lock may be created"
2998 );
2999 }
3000
3001 fn winner_state(repo: &Path, base: &str) -> RunState {
3002 let mut state = RunState::new(
3003 repo.to_path_buf(),
3004 base.to_owned(),
3005 "0000000000000000000000000000000000000000".to_owned(),
3006 "task".to_owned(),
3007 Config::default(),
3008 );
3009 state.candidates.push(crate::run::Candidate {
3010 index: 0,
3011 label: 'A',
3012 agent: "x".to_owned(),
3013 branch: "main".to_owned(),
3014 worktree: repo.to_path_buf(),
3015 summary: String::new(),
3016 stat: String::new(),
3017 files: 1,
3018 commits: 1,
3019 empty: false,
3020 failed: None,
3021 verified_noop: None,
3022 duration_ms: 0,
3023 folded: false,
3024 });
3025 state.tally = Some(
3026 serde_json::from_value(serde_json::json!({
3027 "first_choice": {}, "borda": {}, "winner": "A",
3028 "unanimous_initial": true, "deliberated": false,
3029 "changed_votes": 0, "unanimous_final": true,
3030 }))
3031 .unwrap(),
3032 );
3033 state
3034 }
3035
3036 #[tokio::test]
3037 async fn a_real_failure_without_a_pr_raises_one_notice_and_a_retry_folds_into_it() {
3038 let (_d, repo) =
3039 origin_with(&[("Cargo.toml", "[package]\nname=\"x\"\nversion=\"0.1.0\"\n")]).await;
3040 let home = tempfile::tempdir().unwrap();
3041 let store = Notices::at(home.path().join("notifications"));
3042 let mut state = winner_state(&repo, "nope");
3044 let url = "https://example.invalid/pull/1";
3045 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3046 .await
3047 .expect_err("an unresolvable base is a failure");
3048 let listed = store.list();
3049 assert_eq!(listed.len(), 1, "{listed:?}");
3050 assert!(
3051 listed[0].message.contains("did not run"),
3052 "{}",
3053 listed[0].message
3054 );
3055 assert!(
3056 listed[0].message.contains("Cargo.toml"),
3057 "{}",
3058 listed[0].message
3059 );
3060 assert!(state.events.iter().any(|e| e.node == "bump"));
3061 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3062 .await
3063 .expect_err("still failing");
3064 let listed = store.list();
3065 assert_eq!(listed.len(), 1, "{listed:?}");
3066 assert_eq!(listed[0].count, 2);
3067 }
3068
3069 #[tokio::test]
3070 async fn not_eligible_cases_raise_no_notice() {
3071 let home = tempfile::tempdir().unwrap();
3072 let store = Notices::at(home.path().join("notifications"));
3073 let url = "https://example.invalid/pull/1";
3074 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
3076 let mut state = winner_state(&repo, "main");
3077 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3078 .await
3079 .unwrap();
3080 let mut state = winner_state(&repo, "nope");
3082 state.config.merge.release_bump = false;
3083 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3084 .await
3085 .unwrap();
3086 let mut state = winner_state(&repo, "nope");
3088 state.tally = None;
3089 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
3090 .await
3091 .unwrap();
3092 assert!(store.list().is_empty(), "{:?}", store.list());
3093 }
3094
3095 #[test]
3096 fn the_no_pr_notice_follows_the_configured_language() {
3097 let dir = tempfile::tempdir().unwrap();
3098 let store = Notices::at(dir.path().join("notifications"));
3099 let mut state = merged_state();
3100 state.config.graph.language = "ja".to_owned();
3101 let (n, _) = surface_problem(&mut state, &store, None, Some("0.2.0"), "boom").unwrap();
3102 assert!(n.message.contains("実行されませんでした"), "{}", n.message);
3103 assert!(n.message.contains("boom") && n.message.contains("v0.2.0"));
3104 }
3105
3106 #[test]
3107 fn a_failed_escalation_points_at_the_pending_pr_without_commenting() {
3108 let dir = tempfile::tempdir().unwrap();
3109 let store = Notices::at(dir.path().join("notifications"));
3110 let mut state = merged_state();
3111 let url = "https://example.invalid/pull/9";
3112 let (n, comment) = surface_problem_in(
3113 &mut state,
3114 &store,
3115 Some(url),
3116 Some("0.3.0"),
3117 "push failed",
3118 true,
3119 )
3120 .unwrap();
3121 assert!(comment.is_none());
3122 assert!(
3123 n.message.contains(url) && n.message.contains("v0.3.0"),
3124 "{}",
3125 n.message
3126 );
3127 assert!(!n.message.contains("open the release pull request by hand"));
3128 assert!(matches!(n.link, Some(Link::Url { .. })));
3129 }
3130
3131 #[test]
3132 fn a_failure_notice_names_the_pending_pr_and_the_first_line_of_the_cause() {
3133 let err = anyhow!("outer context").context("cargo build failed\nsecond line");
3134 let with_pr = Progress {
3135 version: Some("0.2.0".into()),
3136 pending_pr: Some("https://example.invalid/pull/9".into()),
3137 skip: None,
3138 };
3139 let (v, reason) = notice_for_failure(&with_pr, &err);
3140 assert_eq!(v.as_deref(), Some("0.2.0"));
3141 assert_eq!(reason, "cargo build failed");
3142 let (v, reason) = notice_for_failure(&Progress::default(), &err);
3143 assert_eq!(v, None);
3144 assert_eq!(reason, "cargo build failed");
3145 }
3146
3147 const NO_RULES: &str = "gh pr merge --auto: GraphQL: Pull request Branch does not have \
3148 required protected branch rules (enablePullRequestAutoMerge)";
3149
3150 fn merged_state() -> RunState {
3151 run::pin_test_home();
3154 let mut s = RunState::new(
3155 PathBuf::from("/no/such/repo"),
3156 "main".to_owned(),
3157 "0000000000000000000000000000000000000000".to_owned(),
3158 "task".to_owned(),
3159 Config::default(),
3160 );
3161 s.status = RunStatus::Merged;
3162 s
3163 }
3164
3165 #[cfg(unix)]
3166 fn decision_cli(id: &str, reply: &str, exit: &str) -> AgentSpec {
3167 AgentSpec {
3168 id: id.into(),
3169 kind: crate::config::AgentKind::Command,
3170 model: None,
3171 command: vec![
3172 "sh".into(),
3173 "-c".into(),
3174 concat!(
3175 "if [ \"$STATUS\" = timeout ]; then exec sleep 5; fi; ",
3176 "printf '%s' \"$REPLY\"; exit \"$STATUS\""
3177 )
3178 .into(),
3179 ],
3180 extra_args: Vec::new(),
3181 env: std::collections::BTreeMap::from([
3182 ("REPLY".into(), reply.into()),
3183 ("STATUS".into(), exit.into()),
3184 ]),
3185 prompt_delivery: None,
3186 max_turn_secs: None,
3187 }
3188 }
3189
3190 #[cfg(unix)]
3191 fn decision_state(agents: Vec<AgentSpec>, preferences: &[&str]) -> RunState {
3192 let mut state = merged_state();
3193 state.config.agents = agents;
3194 state.config.roles.chatter = Some(crate::config::AgentChoice::Chain(
3195 preferences.iter().map(|s| (*s).to_owned()).collect(),
3196 ));
3197 state
3198 }
3199
3200 #[cfg(unix)]
3201 const VALID_DECISION: &str = r#"{"level":"minor","reason":"Adds a feature."}"#;
3202
3203 #[cfg(unix)]
3204 #[tokio::test]
3205 async fn decision_failover_handles_each_unusable_cli_outcome() {
3206 for (reply, exit, expected) in [
3207 (
3208 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3209 "1",
3210 "rate/quota limit",
3211 ),
3212 (
3213 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached","level":"minor","reason":"Looks valid."}"#,
3214 "0",
3215 "rate/quota limit",
3216 ),
3217 (
3218 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached","level":"minor","reason":"Looks valid."}"#,
3219 "0",
3220 "rate/quota limit",
3221 ),
3222 ("", "0", "empty output"),
3223 (
3224 "",
3225 "spawn",
3226 "CLI invocation failed; inspect attempt artifacts",
3227 ),
3228 ("not JSON", "0", "invalid decision JSON or missing reason"),
3229 (VALID_DECISION, "1", "CLI failed (exit code 1)"),
3230 ("", "timeout", "timed out"),
3231 ] {
3232 let dir = tempfile::tempdir().unwrap();
3233 let mut state = decision_state(
3234 vec![
3235 decision_cli("first", reply, exit),
3236 decision_cli("second", VALID_DECISION, "0"),
3237 ],
3238 &["first", "second"],
3239 );
3240 if exit == "spawn" {
3241 state.config.agents[0].command = vec!["magi-test-no-such-decision-cli".into()];
3242 }
3243 let (spec, seat, decision) =
3244 ask_decision(&mut state, dir.path(), "decide", Duration::from_millis(200))
3245 .await
3246 .unwrap();
3247 assert_eq!(spec.id, "second");
3248 assert_eq!(decision.level, BumpLevel::Minor);
3249 assert_eq!(state.bump_decision_attempts.len(), 2);
3250 assert_eq!(state.bump_decision_attempts[0].outcome, expected);
3251 assert_eq!(state.handovers.len(), 1);
3252 assert_eq!(state.handovers[0].to, "second");
3253 assert_ne!(
3254 seat.claude_session,
3255 SeatState::new("bump", "first", state.seed).claude_session
3256 );
3257 let report = crate::report::run(&state);
3258 assert!(report.contains(expected), "{report}");
3259 assert!(report.contains("second: valid decision"), "{report}");
3260 assert!(state.release_bump.is_none());
3261 let artifacts = agent::artifacts_dir(&state.dir());
3262 if exit != "spawn" {
3263 assert!(artifacts.join("bump-decision-1.out").exists());
3264 }
3265 assert!(artifacts.join("bump-decision-2.out").exists());
3266 }
3267 }
3268
3269 #[cfg(unix)]
3270 #[tokio::test]
3271 async fn decision_chain_respects_preferences_and_stops_on_first_success() {
3272 let dir = tempfile::tempdir().unwrap();
3273 let mut state = decision_state(
3274 vec![
3275 decision_cli("first", "bad", "1"),
3276 decision_cli("second", VALID_DECISION, "0"),
3277 ],
3278 &["missing", "second", "second", "first"],
3279 );
3280 let (spec, _, _) = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(1))
3281 .await
3282 .unwrap();
3283 assert_eq!(spec.id, "second");
3284 assert_eq!(state.bump_decision_attempts.len(), 1);
3285 assert!(state.handovers.is_empty());
3286 }
3287
3288 #[cfg(unix)]
3289 #[tokio::test]
3290 async fn decision_chain_consumes_same_failure_classes_and_preserves_unset_policy() {
3291 let dir = tempfile::tempdir().unwrap();
3292 let mut state = decision_state(
3293 vec![
3294 decision_cli("first", "bad", "0"),
3295 decision_cli("second", "bad", "0"),
3296 decision_cli("third", VALID_DECISION, "0"),
3297 ],
3298 &["first", "second", "third"],
3299 );
3300 let (spec, _, _) = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(2))
3301 .await
3302 .unwrap();
3303 assert_eq!(spec.id, "third");
3304 assert_eq!(state.bump_decision_attempts.len(), 3);
3305 assert_eq!(state.handovers.len(), 2);
3306 state.config.roles.chatter = None;
3308 let err = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(2))
3309 .await
3310 .unwrap_err();
3311 assert!(err.to_string().contains("exhausted 1 eligible agent(s)"));
3312 assert_eq!(state.bump_decision_attempts.len(), 4);
3313 }
3314
3315 #[cfg(unix)]
3316 #[tokio::test]
3317 async fn decision_reason_rewrite_failure_preserves_the_selected_level() {
3318 let dir = tempfile::tempdir().unwrap();
3319 for reply in ["invalid", r#"{"level":"patch","reason":"Fixes a defect."}"#] {
3320 let mut state = merged_state();
3321 state.config.graph.github_text_guard = true;
3322 state.config.graph.github_language = "en".into();
3323 let spec = decision_cli("chosen", reply, "0");
3324 let mut seat = SeatState::new("bump", &spec.id, state.seed);
3325 let mut decision =
3326 parse_decision(r#"{"level":"minor","reason":"新しい機能を追加しました。"}"#)
3327 .unwrap();
3328 guard_reason(
3329 &mut state,
3330 dir.path(),
3331 "decide",
3332 &spec,
3333 &mut seat,
3334 &mut decision,
3335 )
3336 .await;
3337 assert_eq!(decision.level, BumpLevel::Minor);
3338 assert_eq!(decision.reason, "The merged change requires a release.");
3339 assert!(state.handovers.is_empty());
3340 assert!(state.bump_decision_attempts.is_empty());
3341 }
3342 }
3343
3344 #[cfg(unix)]
3345 #[tokio::test]
3346 async fn decision_exhaustion_is_bounded_reported_and_leaves_no_release_outcome() {
3347 let dir = tempfile::tempdir().unwrap();
3348 let mut state = decision_state(
3349 vec![
3350 decision_cli("first", "bad", "0"),
3351 decision_cli("second", "", "0"),
3352 ],
3353 &["first", "first", "second", "second"],
3354 );
3355 let err = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(1))
3356 .await
3357 .unwrap_err()
3358 .to_string();
3359 assert!(err.contains("exhausted 2 eligible agent(s)"), "{err}");
3360 assert!(err.contains("first: invalid decision JSON"), "{err}");
3361 assert!(err.contains("second: empty output"), "{err}");
3362 assert!(err.contains("[roles] chatter"), "{err}");
3363 assert_eq!(state.bump_decision_attempts.len(), 2);
3364 assert_eq!(state.handovers.len(), 1);
3365 assert!(state.release_bump.is_none());
3366 let report = crate::report::run(&state);
3367 assert!(report.contains("first: invalid decision JSON"));
3368 assert!(report.contains("second: empty output"));
3369 let restored: RunState =
3370 serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
3371 assert_eq!(restored.bump_decision_attempts.len(), 2);
3372 }
3373
3374 #[test]
3375 fn the_known_automerge_refusal_names_branch_protection() {
3376 assert!(automerge_hint(NO_RULES).contains("branch protection with required"));
3377 let other = automerge_hint("gh: network unreachable");
3378 assert!(!other.contains("branch protection"), "{other}");
3379 let body = automerge_failure_comment(NO_RULES);
3380 assert!(body.contains("enablePullRequestAutoMerge"), "{body}");
3381 assert!(body.contains("Action required"), "{body}");
3382 }
3383
3384 const CLEAN: &str = "gh pr merge --auto: GraphQL: Pull request Pull request is in clean \
3385 status (enablePullRequestAutoMerge)";
3386
3387 #[test]
3388 fn clean_status_refusal_is_matched_narrowly() {
3389 assert!(is_clean_status_refusal(CLEAN));
3390 assert!(!is_clean_status_refusal(NO_RULES));
3391 assert!(!is_clean_status_refusal("gh: network unreachable"));
3392 assert!(!is_clean_status_refusal("Pull request is in clean status"));
3393 assert!(automerge_hint(CLEAN).contains("already green"));
3394 }
3395
3396 #[test]
3397 fn the_direct_merge_argv_matches_the_land_flags() {
3398 let a = bump_merge_argv("https://github.com/o/r/pull/9", "chore: release v1.0.0");
3399 let l = land::merge_argv(9, "chore: release v1.0.0");
3400 assert_eq!(a[..2], l[..2]);
3401 assert_eq!(a[3..], l[3..]);
3402 assert_eq!(a[2], "https://github.com/o/r/pull/9");
3403 }
3404
3405 #[test]
3406 fn a_direct_merge_is_judged_by_the_forge_not_the_exit_code() {
3407 let argv = bump_merge_argv("u", "t");
3408 let merged = |o: &AutomergeOutcome| matches!(o, AutomergeOutcome::MergedDirectly { .. });
3409 assert!(merged(&resolve_direct_merge(CLEAN, &argv, true, "", None)));
3410 let detached = "not on any branch";
3411 assert!(merged(&resolve_direct_merge(
3412 CLEAN,
3413 &argv,
3414 false,
3415 detached,
3416 Some(PrLifecycle::Merged)
3417 )));
3418 for after in [Some(PrLifecycle::Open), None] {
3419 let o = resolve_direct_merge(CLEAN, &argv, false, "boom", after);
3420 match o {
3421 AutomergeOutcome::Failed { reason } => {
3422 assert!(
3423 reason.contains("clean status") && reason.contains("boom"),
3424 "{reason}"
3425 )
3426 }
3427 other => panic!("expected Failed, got {other:?}"),
3428 }
3429 }
3430 }
3431
3432 #[test]
3433 fn a_local_mode_bump_reads_back_and_old_records_are_not_local() {
3434 let old: run::ReleaseBump =
3435 serde_json::from_str(r#"{"pr_url":"u","automerge_enabled":true}"#).unwrap();
3436 assert!(!old.local);
3437 let mut state = merged_state();
3438 state.release_bump = Some(run::ReleaseBump {
3439 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
3440 version: Some("1.0.0".to_owned()),
3441 local: true,
3442 ..run::ReleaseBump::default()
3443 });
3444 assert!(!state.needs_attention());
3446 let text = crate::report::run(&state);
3447 assert!(text.contains("release.mode = local"), "{text}");
3448 assert!(!text.contains("FAILED"), "{text}");
3449 }
3450
3451 #[test]
3452 fn a_directly_merged_bump_is_not_reported_as_pending_or_failed() {
3453 let mut state = merged_state();
3454 state.release_bump = Some(run::ReleaseBump {
3455 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
3456 version: Some("1.0.0".to_owned()),
3457 merged_directly: true,
3458 ..run::ReleaseBump::default()
3459 });
3460 assert!(!state.needs_attention());
3461 let text = crate::report::run(&state);
3462 assert!(text.contains("merged directly"), "{text}");
3463 assert!(!text.contains("FAILED"), "{text}");
3464 }
3465
3466 #[test]
3467 fn an_automerge_failure_is_recorded_shown_and_filed_and_survives_settling() {
3468 let dir = tempfile::tempdir().unwrap();
3469 let store = Notices::at(dir.path().join("notifications"));
3470 let questions = ask::Questions::at(dir.path().join("questions"));
3471 let mut state = merged_state();
3472 let url = "https://github.com/o/r/pull/35";
3473
3474 let (n, comment) =
3475 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
3476
3477 let comment = comment.expect("a PR was opened, so it gets a comment");
3479 assert!(comment.contains("branch protection"), "{comment}");
3480
3481 assert_eq!(state.status, RunStatus::Merged);
3483 assert!(state.needs_attention());
3484 let text = crate::report::run(&state);
3485 assert!(text.contains("release bump"), "{text}");
3486 assert!(text.contains("FAILED"), "{text}");
3487 assert!(text.contains(url), "{text}");
3488 assert!(text.contains("action required"), "{text}");
3489 assert!(crate::report::line(&state).contains("release needs a human"));
3490
3491 assert_eq!(n.severity, Severity::Error);
3493 assert_eq!(
3494 n.link,
3495 Some(Link::Url {
3496 url: url.to_owned()
3497 })
3498 );
3499 assert_eq!(store.list().len(), 1);
3500 assert!(questions.open_for(&state.id).is_empty());
3501
3502 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
3504 let listed = store.list();
3505 assert_eq!(listed.len(), 1);
3506 assert_eq!(listed[0].count, 2);
3507 }
3508
3509 #[test]
3510 fn a_bump_that_never_ran_is_surfaced_without_a_pr_comment() {
3511 let dir = tempfile::tempdir().unwrap();
3512 let store = Notices::at(dir.path().join("notifications"));
3513 let mut state = merged_state();
3514 let (n, comment) = surface_problem(&mut state, &store, None, None, "no agent").unwrap();
3515 assert!(comment.is_none());
3516 assert!(matches!(n.link, Some(Link::Run { .. })));
3517 assert!(state.needs_attention());
3518 }
3519
3520 #[test]
3521 fn version_parses_and_bumps_each_digit() {
3522 let v = Version::parse("0.4.0").unwrap();
3523 assert_eq!(
3524 v,
3525 Version {
3526 major: 0,
3527 minor: 4,
3528 patch: 0
3529 }
3530 );
3531
3532 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
3533 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
3534 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
3535 }
3536
3537 #[test]
3538 fn version_tolerates_a_prerelease_suffix_on_patch() {
3539 let v = Version::parse("1.2.3-rc1").unwrap();
3540 assert_eq!(
3541 v,
3542 Version {
3543 major: 1,
3544 minor: 2,
3545 patch: 3
3546 }
3547 );
3548 }
3549
3550 #[test]
3551 fn version_rejects_garbage() {
3552 assert!(Version::parse("not-a-version").is_err());
3553 assert!(Version::parse("1.2").is_err());
3554 }
3555
3556 #[test]
3557 fn decision_parses_each_level() {
3558 for (json, level) in [
3559 (
3560 r#"{"level":"major","reason":"drops a config key"}"#,
3561 BumpLevel::Major,
3562 ),
3563 (
3564 r#"{"level":"minor","reason":"adds a new flag"}"#,
3565 BumpLevel::Minor,
3566 ),
3567 (
3568 r#"{"level":"patch","reason":"fixes a race"}"#,
3569 BumpLevel::Patch,
3570 ),
3571 ] {
3572 let decision = parse_decision(json).unwrap();
3573 assert_eq!(decision.level, level);
3574 assert!(!decision.reason.is_empty());
3575 }
3576 }
3577
3578 #[test]
3579 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
3580 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
3581 let decision = parse_decision(text).unwrap();
3582 assert_eq!(decision.level, BumpLevel::Minor);
3583 assert_eq!(decision.reason, "new HTTP route");
3584 }
3585
3586 #[test]
3587 fn a_broken_reply_is_an_error_not_a_default() {
3588 assert!(parse_decision("I decline to answer.").is_err());
3589 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
3590 assert!(
3591 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
3592 "an empty reason must not pass either"
3593 );
3594 assert!(
3595 parse_decision(r#"{"level":"patch"}"#).is_err(),
3596 "a reply with no reason at all must not pass"
3597 );
3598 }
3599
3600 #[test]
3601 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
3602 let prompt = decision_prompt(
3603 "feat: add a phone endpoint",
3604 "add POST /api/widgets",
3605 "1 file changed, 10 insertions(+)",
3606 &["src/web.rs".to_owned()],
3607 "0.8.0",
3608 );
3609 assert!(prompt.contains("0.8.0"), "the current version is stated");
3610 assert!(
3611 prompt.contains("below `1.0.0`")
3612 && prompt.contains("`minor` is the digit that carries a breaking change"),
3613 "the 0.x rule must be explicit: {prompt}"
3614 );
3615 assert!(
3616 prompt.contains("choose the larger"),
3617 "the tie-break toward the bigger digit must be explicit: {prompt}"
3618 );
3619 }
3620
3621 #[test]
3622 fn release_only_diffs_are_recognised() {
3623 assert!(is_release_only(&["Cargo.toml".to_owned()]));
3624 assert!(is_release_only(&[
3625 "Cargo.toml".to_owned(),
3626 "Cargo.lock".to_owned()
3627 ]));
3628 assert!(!is_release_only(&[]));
3629 assert!(!is_release_only(&[
3630 "Cargo.toml".to_owned(),
3631 "src/main.rs".to_owned()
3632 ]));
3633 }
3634
3635 #[test]
3636 fn cargo_version_rewrite_touches_only_the_package_table() {
3637 let toml = "\
3638[package]\n\
3639# a comment mentioning version on purpose\n\
3640name = \"magi-cli\"\n\
3641version = \"0.8.0\"\n\
3642edition = \"2024\"\n\
3643\n\
3644[dependencies]\n\
3645foo = { version = \"1.2.3\" }\n";
3646 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
3647 assert!(out.contains("version = \"0.9.0\""));
3648 assert!(
3649 out.contains("foo = { version = \"1.2.3\" }"),
3650 "a dependency's own version pin must survive: {out}"
3651 );
3652 assert!(
3653 out.contains("# a comment mentioning version on purpose"),
3654 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
3655 );
3656 assert_eq!(
3657 out.lines().count(),
3658 toml.lines().count(),
3659 "the rewrite replaces one line, it does not add or remove any"
3660 );
3661 }
3662
3663 #[test]
3664 fn cargo_version_rewrite_fails_without_a_package_table() {
3665 let toml = "[dependencies]\nfoo = \"1\"\n";
3666 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
3667 }
3668
3669 #[test]
3675 fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
3676 let toml = "\
3677[workspace]\n\
3678members = [\"crates/a\", \"crates/b\"]\n\
3679\n\
3680[workspace.package]\n\
3681version = \"0.45.18\"\n\
3682edition = \"2024\"\n\
3683\n\
3684[workspace.dependencies]\n\
3685foo = { version = \"1.2.3\" }\n";
3686 let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
3687 assert!(out.contains("version = \"0.45.19\""));
3688 assert!(
3689 out.contains("foo = { version = \"1.2.3\" }"),
3690 "a workspace dependency's own version pin must survive: {out}"
3691 );
3692 assert_eq!(
3693 out.lines().count(),
3694 toml.lines().count(),
3695 "the rewrite replaces one line, it does not add or remove any"
3696 );
3697 }
3698
3699 #[test]
3703 fn cargo_version_rewrite_bumps_an_internal_workspace_dependency_pin() {
3704 let toml = "\
3705[workspace]\n\
3706members = [\"crates/kanade-shared\"]\n\
3707\n\
3708[workspace.package]\n\
3709version = \"0.48.2\"\n\
3710\n\
3711[workspace.dependencies]\n\
3712kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n";
3713 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3714 assert!(out.contains("[workspace.package]\nversion = \"0.48.3\"\n"));
3715 assert!(
3716 out.contains(
3717 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3718 ),
3719 "the internal pin must move with the workspace version: {out}"
3720 );
3721 }
3722
3723 #[test]
3725 fn cargo_version_rewrite_bumps_an_internal_pin_with_version_before_path() {
3726 let toml = "\
3727[workspace.package]\n\
3728version = \"1.0.0\"\n\
3729\n\
3730[workspace.dependencies]\n\
3731inner = { version = \"1.0.0\", path = \"crates/inner\" }\n";
3732 let out = rewrite_cargo_version(toml, "1.0.1").unwrap();
3733 assert!(out.contains("inner = { version = \"1.0.1\", path = \"crates/inner\" }"));
3734 }
3735
3736 #[test]
3739 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_table_form() {
3740 let toml = "\
3741[workspace.package]\n\
3742version = \"2.3.0\"\n\
3743\n\
3744[workspace.dependencies.inner]\n\
3745path = \"crates/inner\"\n\
3746version = \"2.3.0\"\n";
3747 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3748 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3749 assert!(out.contains(
3750 "[workspace.dependencies.inner]\npath = \"crates/inner\"\nversion = \"2.4.0\"\n"
3751 ));
3752 }
3753
3754 #[test]
3759 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_key_form() {
3760 let toml = "\
3761[workspace.package]\n\
3762version = \"2.3.0\"\n\
3763\n\
3764[workspace.dependencies]\n\
3765inner.path = \"crates/inner\"\n\
3766inner.version = \"2.3.0\"\n";
3767 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3768 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3769 assert!(out.contains("inner.path = \"crates/inner\"\ninner.version = \"2.4.0\"\n"));
3770 }
3771
3772 #[test]
3775 fn cargo_version_rewrite_leaves_a_path_only_workspace_dependency_untouched() {
3776 let toml = "\
3777[workspace.package]\n\
3778version = \"0.1.0\"\n\
3779\n\
3780[workspace.dependencies]\n\
3781internal-only = { path = \"crates/internal-only\" }\n";
3782 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3783 assert!(out.contains("internal-only = { path = \"crates/internal-only\" }"));
3784 }
3785
3786 #[test]
3790 fn cargo_version_rewrite_leaves_an_external_dependency_untouched() {
3791 let toml = "\
3792[workspace.package]\n\
3793version = \"0.1.0\"\n\
3794\n\
3795[workspace.dependencies]\n\
3796serde = { version = \"1\", features = [\"derive\"] }\n\
3797inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3798 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3799 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3800 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3801 }
3802
3803 #[test]
3807 fn cargo_version_rewrite_without_workspace_dependencies_table_still_bumps_package() {
3808 let toml = "[workspace.package]\nversion = \"0.9.0\"\nedition = \"2024\"\n";
3809 let out = rewrite_cargo_version(toml, "0.10.0").unwrap();
3810 assert_eq!(
3811 out,
3812 "[workspace.package]\nversion = \"0.10.0\"\nedition = \"2024\"\n"
3813 );
3814 }
3815
3816 #[test]
3819 fn cargo_version_rewrite_handles_a_kanade_shaped_workspace_dependencies_table() {
3820 let toml = "\
3821[workspace.package]\n\
3822version = \"0.48.2\"\n\
3823\n\
3824[workspace.dependencies]\n\
3825anyhow = { version = \"1\" }\n\
3826serde = { version = \"1\", features = [\"derive\"] }\n\
3827kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n\
3828kanade-core = { path = \"crates/kanade-core\", version = \"0.48.2\" }\n\
3829kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }\n";
3830 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3831 assert!(out.contains("anyhow = { version = \"1\" }"));
3832 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3833 assert!(
3834 out.contains(
3835 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3836 )
3837 );
3838 assert!(
3839 out.contains("kanade-core = { path = \"crates/kanade-core\", version = \"0.48.3\" }")
3840 );
3841 assert!(out.contains("kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }"));
3842 }
3843
3844 #[test]
3848 fn cargo_version_rewrite_bails_on_an_unrepresentable_inline_table() {
3849 let toml = "\
3850[workspace.package]\n\
3851version = \"0.1.0\"\n\
3852\n\
3853[workspace.dependencies]\n\
3854inner = { path = \"crates/inner\",\n\
3855 version = \"0.1.0\" }\n";
3856 assert!(rewrite_cargo_version(toml, "0.2.0").is_err());
3857 }
3858
3859 #[test]
3862 fn cargo_version_rewrite_recognises_a_commented_workspace_dependencies_header() {
3863 let toml = "\
3864[workspace.package]\n\
3865version = \"0.1.0\"\n\
3866\n\
3867[workspace.dependencies] # internal pins\n\
3868inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3869 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3870 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3871 }
3872
3873 #[test]
3876 fn cargo_version_rewrite_bumps_a_quoted_dependency_key() {
3877 let toml = "\
3878[workspace.package]\n\
3879version = \"0.1.0\"\n\
3880\n\
3881[workspace.dependencies]\n\
3882\"inner\" = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3883 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3884 assert!(out.contains("\"inner\" = { path = \"crates/inner\", version = \"0.2.0\" }"));
3885 }
3886
3887 #[test]
3891 fn cargo_version_rewrite_bumps_a_literal_string_version_pin() {
3892 let toml = "\
3893[workspace.package]\n\
3894version = \"0.1.0\"\n\
3895\n\
3896[workspace.dependencies]\n\
3897inner = { path = 'crates/inner', version = '0.1.0' }\n";
3898 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3899 assert!(out.contains("inner = { path = 'crates/inner', version = '0.2.0' }"));
3900 }
3901
3902 #[test]
3908 fn cargo_version_rewrite_does_not_require_the_whole_file_to_parse() {
3909 let toml = "\
3910[workspace.package]\n\
3911version = \"0.1.0\"\n\
3912\n\
3913[workspace.dependencies]\n\
3914inner = { path = \"crates/inner\", version = \"0.1.0\" }\n\
3915\n\
3916[workspace.package]\n\
3917edition = \"2024\"\n";
3918 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3919 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3920 assert!(
3921 toml::from_str::<toml::Value>(toml).is_err(),
3922 "the fixture itself must be invalid as a whole file, or this test proves nothing"
3923 );
3924 }
3925
3926 #[test]
3927 fn current_version_prefers_the_package_table_when_both_exist() {
3928 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
3929 assert_eq!(current_version(toml).unwrap(), "0.8.0");
3930 }
3931
3932 #[test]
3935 fn current_version_falls_back_to_workspace_package_without_a_package_table() {
3936 let toml = "\
3937[workspace]\n\
3938members = [\"crates/a\", \"crates/b\"]\n\
3939\n\
3940[workspace.package]\n\
3941version = \"0.45.18\"\n";
3942 assert_eq!(current_version(toml).unwrap(), "0.45.18");
3943 }
3944
3945 #[test]
3946 fn coalesce_proceeds_with_nothing_pending() {
3947 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
3948 }
3949
3950 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
3953 PendingBump {
3954 target_version: target_version.to_owned(),
3955 level,
3956 branch: format!("chore/release-v{target_version}"),
3957 pr_url: "https://example.invalid/pull/9".to_owned(),
3958 }
3959 }
3960
3961 #[test]
3962 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
3963 let pending = test_pending("0.9.0", BumpLevel::Minor);
3964 assert_eq!(
3965 coalesce(Some(&pending), "0.8.0").unwrap(),
3966 Coalesce::Skip {
3967 target_version: "0.9.0".to_owned()
3968 }
3969 );
3970 }
3971
3972 #[test]
3973 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
3974 let pending = test_pending("0.9.0", BumpLevel::Minor);
3975 assert_eq!(
3977 coalesce(Some(&pending), "0.9.0").unwrap(),
3978 Coalesce::Proceed
3979 );
3980 assert_eq!(
3982 coalesce(Some(&pending), "1.0.0").unwrap(),
3983 Coalesce::Proceed
3984 );
3985 }
3986
3987 #[test]
3988 fn pending_action_escalates_only_for_a_more_severe_decision() {
3989 assert_eq!(
3990 pending_action(BumpLevel::Patch, BumpLevel::Patch),
3991 PendingAction::AlreadyCovered
3992 );
3993 assert_eq!(
3994 pending_action(BumpLevel::Patch, BumpLevel::Minor),
3995 PendingAction::Escalate
3996 );
3997 assert_eq!(
3998 pending_action(BumpLevel::Patch, BumpLevel::Major),
3999 PendingAction::Escalate
4000 );
4001 assert_eq!(
4002 pending_action(BumpLevel::Minor, BumpLevel::Patch),
4003 PendingAction::AlreadyCovered
4004 );
4005 assert_eq!(
4006 pending_action(BumpLevel::Major, BumpLevel::Minor),
4007 PendingAction::AlreadyCovered
4008 );
4009 assert_eq!(
4010 pending_action(BumpLevel::Major, BumpLevel::Major),
4011 PendingAction::AlreadyCovered
4012 );
4013 }
4014
4015 #[test]
4016 fn pr_state_parsing_reads_open_and_not_open() {
4017 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
4018 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
4019 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
4020 }
4021
4022 #[test]
4023 fn a_lock_is_exclusive_until_dropped() {
4024 let dir = tempfile::tempdir().unwrap();
4025 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4026 let first = MarkerLock::acquire(&marker)
4027 .unwrap()
4028 .expect("first attempt takes the lock");
4029 assert!(
4030 MarkerLock::acquire(&marker).unwrap().is_none(),
4031 "a second attempt must be refused while the first holds it"
4032 );
4033 drop(first);
4034 assert!(
4035 MarkerLock::acquire(&marker).unwrap().is_some(),
4036 "dropping the guard releases the lock for the next attempt"
4037 );
4038 }
4039
4040 #[test]
4041 fn a_stale_lock_is_reclaimed() {
4042 let dir = tempfile::tempdir().unwrap();
4043 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4044 let lock_path = marker.with_extension("lock");
4045 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
4046 std::fs::write(&lock_path, b"").unwrap();
4047 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
4048 std::fs::OpenOptions::new()
4049 .write(true)
4050 .open(&lock_path)
4051 .unwrap()
4052 .set_modified(old)
4053 .unwrap();
4054 assert!(
4055 MarkerLock::acquire(&marker).unwrap().is_some(),
4056 "a lock older than the stale window must be reclaimed rather than block forever"
4057 );
4058 }
4059
4060 #[tokio::test]
4061 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
4062 let dir = tempfile::tempdir().unwrap();
4063 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4064 let held = MarkerLock::acquire(&marker)
4065 .unwrap()
4066 .expect("seed the contention");
4067 let releaser = tokio::spawn(async move {
4068 tokio::time::sleep(Duration::from_millis(20)).await;
4069 drop(held);
4070 });
4071 let waited =
4072 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
4073 .await
4074 .unwrap();
4075 assert!(
4076 waited.is_some(),
4077 "a merge landing behind another's still-running decision must not be dropped - it \
4078 must wait for that decision to finish and then judge against what it left behind"
4079 );
4080 releaser.await.unwrap();
4081 }
4082
4083 #[tokio::test]
4084 async fn a_lock_held_past_the_ceiling_gives_up() {
4085 let dir = tempfile::tempdir().unwrap();
4086 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
4087 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
4088 let waited =
4089 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
4090 .await
4091 .unwrap();
4092 assert!(
4093 waited.is_none(),
4094 "a lock genuinely held past the ceiling must eventually give up rather than wait \
4095 forever"
4096 );
4097 }
4098
4099 #[test]
4100 fn level_between_reads_off_the_differing_digit() {
4101 assert_eq!(
4102 level_between(
4103 Version::parse("0.8.0").unwrap(),
4104 Version::parse("1.0.0").unwrap()
4105 ),
4106 Some(BumpLevel::Major)
4107 );
4108 assert_eq!(
4109 level_between(
4110 Version::parse("0.8.0").unwrap(),
4111 Version::parse("0.9.0").unwrap()
4112 ),
4113 Some(BumpLevel::Minor)
4114 );
4115 assert_eq!(
4116 level_between(
4117 Version::parse("0.8.0").unwrap(),
4118 Version::parse("0.8.1").unwrap()
4119 ),
4120 Some(BumpLevel::Patch)
4121 );
4122 assert_eq!(
4123 level_between(
4124 Version::parse("0.8.0").unwrap(),
4125 Version::parse("0.8.0").unwrap()
4126 ),
4127 None
4128 );
4129 }
4130
4131 #[test]
4132 fn open_release_pr_is_found_among_unrelated_pull_requests() {
4133 let json = r#"[
4134 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
4135 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
4136 ]"#;
4137 let found = parse_open_release_pr(json).unwrap();
4138 assert_eq!(
4139 found,
4140 Some((
4141 "chore/release-v0.9.0".to_owned(),
4142 "https://example.invalid/pull/2".to_owned()
4143 ))
4144 );
4145 }
4146
4147 #[test]
4148 fn no_open_release_pr_reads_as_none_not_an_error() {
4149 let json =
4150 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
4151 assert_eq!(parse_open_release_pr(json).unwrap(), None);
4152 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
4153 }
4154
4155 #[test]
4156 fn marker_round_trips_through_disk() {
4157 let dir = tempfile::tempdir().unwrap();
4158 let path = marker_path(dir.path(), Path::new("/repos/magi"));
4159 assert!(read_marker(&path).is_none());
4160
4161 let marker = test_pending("0.9.0", BumpLevel::Patch);
4162 write_marker(&path, &marker).unwrap();
4163 let read_back = read_marker(&path).unwrap();
4164 assert_eq!(read_back.target_version, "0.9.0");
4165 assert_eq!(read_back.level, BumpLevel::Patch);
4166 assert_eq!(read_back.pr_url, marker.pr_url);
4167
4168 clear_marker(&path);
4169 assert!(read_marker(&path).is_none());
4170 }
4171
4172 #[test]
4173 fn different_repos_get_different_marker_files() {
4174 let dir = tempfile::tempdir().unwrap();
4175 let a = marker_path(dir.path(), Path::new("/repos/a"));
4176 let b = marker_path(dir.path(), Path::new("/repos/b"));
4177 assert_ne!(a, b);
4178 }
4179
4180 #[test]
4184 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
4185 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
4186 assert!(
4187 is_release_only(&files),
4188 "the bump pull request's own diff must read as release-only"
4189 );
4190 }
4191
4192 #[test]
4193 fn should_release_bump_reads_only_a_merged_status() {
4194 assert!(should_release_bump(RunStatus::Merged));
4195 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
4196 assert!(!should_release_bump(other));
4197 }
4198 }
4199
4200 #[test]
4204 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
4205 let pr = land::PrState {
4206 url: "https://github.com/o/r/pull/1".to_owned(),
4207 number: 1,
4208 state: PrLifecycle::Merged,
4209 checks: land::Checks::Green,
4210 failing: Vec::new(),
4211 review_comments: Vec::new(),
4212 blocking: land::Blocking::No,
4213 };
4214 assert_eq!(
4215 land::decide(&pr, 0, 4, Duration::ZERO),
4216 land::Step::Done { merged: true }
4217 );
4218 assert!(should_release_bump(RunStatus::Merged));
4219 }
4220
4221 #[test]
4226 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
4227 let pr = land::PrState {
4228 url: "https://github.com/o/r/pull/2".to_owned(),
4229 number: 2,
4230 state: PrLifecycle::Open,
4231 checks: land::Checks::Green,
4232 failing: Vec::new(),
4233 review_comments: Vec::new(),
4234 blocking: land::Blocking::No,
4235 };
4236 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
4237 assert!(should_release_bump(RunStatus::Merged));
4240 }
4241
4242 #[test]
4245 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
4246 let argv = land::merge_argv(3, "feat: something");
4247 let outcome = land::merged_after_all(
4248 &argv,
4249 "could not determine current branch: not on any branch",
4250 Some(PrLifecycle::Merged),
4251 );
4252 assert!(outcome.is_some(), "the forge's confirmation must win");
4253 assert!(should_release_bump(RunStatus::Merged));
4254
4255 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
4258 assert!(land::merged_after_all(&argv, "network error", None).is_none());
4259 }
4260
4261 #[test]
4263 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
4264 let pr = land::PrState {
4265 url: "https://github.com/o/r/pull/4".to_owned(),
4266 number: 4,
4267 state: PrLifecycle::Closed,
4268 checks: land::Checks::Green,
4269 failing: Vec::new(),
4270 review_comments: Vec::new(),
4271 blocking: land::Blocking::No,
4272 };
4273 assert_eq!(
4274 land::decide(&pr, 0, 4, Duration::ZERO),
4275 land::Step::Done { merged: false }
4276 );
4277 assert!(!should_release_bump(RunStatus::Blocked));
4278 }
4279
4280 fn shun_files() -> Vec<crate::config::VersionFile> {
4281 use crate::config::{VersionFile, VersionKind};
4282 vec![
4283 VersionFile {
4284 path: "src-tauri/Cargo.toml".to_owned(),
4285 kind: VersionKind::Cargo,
4286 },
4287 VersionFile {
4288 path: "package.json".to_owned(),
4289 kind: VersionKind::Json,
4290 },
4291 VersionFile {
4292 path: "src-tauri/tauri.conf.json".to_owned(),
4293 kind: VersionKind::Json,
4294 },
4295 ]
4296 }
4297
4298 const SHUN_TOML: &str = "[package]\nname = \"shun\"\nversion = \"1.0.0\"\n";
4299 const SHUN_LOCK: &str = "[[package]]\nname = \"shun\"\nversion = \"1.0.0\"\n";
4300
4301 async fn shun_origin(package_version: &str) -> (tempfile::TempDir, PathBuf) {
4302 let pkg = format!("{{\n \"version\": \"{package_version}\"\n}}\n");
4303 let origin = origin_with(&[("README.md", "x\n")]).await;
4304 for (name, body) in [
4305 ("src-tauri/Cargo.toml", SHUN_TOML),
4306 ("src-tauri/Cargo.lock", SHUN_LOCK),
4307 ("package.json", pkg.as_str()),
4308 ("src-tauri/tauri.conf.json", "{ \"version\": \"1.0.0\" }\n"),
4309 ] {
4310 let path = origin.1.join(name);
4311 tokio::fs::create_dir_all(path.parent().unwrap())
4312 .await
4313 .unwrap();
4314 tokio::fs::write(path, body).await.unwrap();
4315 }
4316 git::git(&origin.1, &["add", "-A"]).await.unwrap();
4317 git::git(&origin.1, &["commit", "-m", "shun"])
4318 .await
4319 .unwrap();
4320 git::git(&origin.1, &["push", "origin", "main"])
4321 .await
4322 .unwrap();
4323 origin
4324 }
4325
4326 #[tokio::test]
4327 async fn a_shun_layout_bump_changes_only_the_declared_files_and_the_adjacent_lock() {
4328 let (_d, repo) = shun_origin("1.0.0").await;
4329 let mut state = winner_state(&repo, "main");
4330 state.config.release.version_files = shun_files();
4331 write_release_files(&state, &repo, "1.1.0").await.unwrap();
4332 let status = git::git(&repo, &["status", "--porcelain"]).await.unwrap();
4333 let mut paths: Vec<_> = status.lines().map(|l| l[3..].to_owned()).collect();
4334 paths.sort();
4335 assert_eq!(
4336 paths,
4337 [
4338 "package.json",
4339 "src-tauri/Cargo.lock",
4340 "src-tauri/Cargo.toml",
4341 "src-tauri/tauri.conf.json"
4342 ]
4343 );
4344 let lock = std::fs::read_to_string(repo.join("src-tauri/Cargo.lock")).unwrap();
4345 assert!(lock.contains("version = \"1.1.0\""));
4346 }
4347
4348 #[tokio::test]
4349 async fn an_unconfigured_repo_with_auto_tag_but_no_root_manifest_is_skipped_visibly() {
4350 let (_d, repo) = origin_with(&[("README.md", "x\n")]).await;
4351 let wf = repo.join(".github/workflows");
4352 tokio::fs::create_dir_all(&wf).await.unwrap();
4353 tokio::fs::write(wf.join("auto-tag.yml"), "name: t\n")
4354 .await
4355 .unwrap();
4356 git::git(&repo, &["add", "-A"]).await.unwrap();
4357 git::git(&repo, &["commit", "-m", "wf"]).await.unwrap();
4358 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
4359 let home = tempfile::tempdir().unwrap();
4360 let store = Notices::at(home.path().join("notifications"));
4361 let mut state = winner_state(&repo, "main");
4362 after_merge_at(
4363 &mut state,
4364 "https://example.invalid/pull/1",
4365 Some(home.path()),
4366 Some(&store),
4367 )
4368 .await
4369 .unwrap();
4370 let rec = state.release_bump.clone().expect("a skip is recorded");
4371 assert!(rec.skipped.is_some_and(|s| s.contains("Cargo.toml")));
4372 assert!(
4373 rec.action_required
4374 .is_some_and(|a| a.contains("version_files"))
4375 );
4376 assert!(rec.pr_url.is_none() && !rec.automerge_enabled);
4377 assert_eq!(store.list().len(), 1);
4378 assert!(crate::report::run(&state).contains("SKIPPED"));
4379 }
4380
4381 #[tokio::test]
4382 async fn a_non_rust_repo_without_auto_tag_records_a_skip_without_paging() {
4383 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
4384 let home = tempfile::tempdir().unwrap();
4385 let store = Notices::at(home.path().join("notifications"));
4386 let mut state = winner_state(&repo, "main");
4387 after_merge_at(
4388 &mut state,
4389 "https://example.invalid/pull/1",
4390 Some(home.path()),
4391 Some(&store),
4392 )
4393 .await
4394 .unwrap();
4395 let rec = state.release_bump.clone().unwrap();
4396 assert!(rec.skipped.is_some());
4397 assert!(rec.action_required.is_none());
4398 assert!(store.list().is_empty());
4399 }
4400
4401 #[tokio::test]
4402 async fn disagreeing_version_files_stop_the_bump_and_ask_the_owner() {
4403 let (_d, repo) = shun_origin("0.9.0").await;
4404 let home = tempfile::tempdir().unwrap();
4405 let store = Notices::at(home.path().join("notifications"));
4406 let mut state = winner_state(&repo, "main");
4407 state.config.release.version_files = shun_files();
4408 after_merge_at(
4409 &mut state,
4410 "https://example.invalid/pull/1",
4411 Some(home.path()),
4412 Some(&store),
4413 )
4414 .await
4415 .unwrap();
4416 let rec = state.release_bump.clone().unwrap();
4417 assert!(rec.skipped.is_some_and(|s| s.contains("disagree")));
4418 assert!(rec.action_required.is_some());
4419 assert_eq!(store.list().len(), 1);
4420 assert!(rec.pr_url.is_none());
4421 }
4422
4423 #[tokio::test]
4424 async fn a_change_that_already_raised_the_version_is_left_to_the_repos_auto_tag() {
4425 let (_d, repo) = shun_origin("1.0.0").await;
4426 git::git(&repo, &["checkout", "-b", "feat"]).await.unwrap();
4427 let toml = SHUN_TOML.replace("1.0.0", "1.0.1");
4428 tokio::fs::write(repo.join("src-tauri/Cargo.toml"), toml)
4429 .await
4430 .unwrap();
4431 tokio::fs::write(repo.join("app.ts"), "x\n").await.unwrap();
4432 git::git(&repo, &["add", "-A"]).await.unwrap();
4433 git::git(&repo, &["commit", "-m", "fix and bump"])
4434 .await
4435 .unwrap();
4436 let home = tempfile::tempdir().unwrap();
4437 let store = Notices::at(home.path().join("notifications"));
4438 let mut state = winner_state(&repo, "main");
4439 state.candidates[0].branch = "feat".to_owned();
4440 state.config.release.version_files = shun_files();
4441 after_merge_at(
4442 &mut state,
4443 "https://example.invalid/pull/1",
4444 Some(home.path()),
4445 Some(&store),
4446 )
4447 .await
4448 .unwrap();
4449 let rec = state.release_bump.clone().unwrap();
4450 assert!(
4451 rec.skipped
4452 .is_some_and(|s| s.contains("already") || s.contains("raised"))
4453 );
4454 assert!(rec.pr_url.is_none() && rec.action_required.is_none());
4455 assert!(store.list().is_empty());
4456 }
4457
4458 #[tokio::test]
4459 async fn a_stale_local_base_does_not_make_an_inherited_version_look_like_this_changes() {
4460 let (_d, repo) = shun_origin("1.0.0").await;
4461 let old = git::rev_parse(&repo, "HEAD").await.unwrap();
4462 tokio::fs::write(
4463 repo.join("src-tauri/Cargo.toml"),
4464 SHUN_TOML.replace("1.0.0", "1.0.1"),
4465 )
4466 .await
4467 .unwrap();
4468 git::git(&repo, &["commit", "-am", "bump upstream"])
4469 .await
4470 .unwrap();
4471 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
4472 git::git(&repo, &["checkout", "-b", "feat"]).await.unwrap();
4473 tokio::fs::write(repo.join("app.ts"), "x\n").await.unwrap();
4474 git::git(&repo, &["add", "-A"]).await.unwrap();
4475 git::git(&repo, &["commit", "-m", "fix"]).await.unwrap();
4476 git::git(&repo, &["update-ref", "refs/heads/main", &old])
4478 .await
4479 .unwrap();
4480 let home = tempfile::tempdir().unwrap();
4481 let store = Notices::at(home.path().join("notifications"));
4482 let mut state = winner_state(&repo, "main");
4483 state.candidates[0].branch = "feat".to_owned();
4484 state.config.release.version_files = shun_files();
4485 let _ = after_merge_at(
4486 &mut state,
4487 "https://example.invalid/pull/1",
4488 Some(home.path()),
4489 Some(&store),
4490 )
4491 .await;
4492 assert!(
4493 !state
4494 .events
4495 .iter()
4496 .any(|e| e.message.contains("already moved")),
4497 "{:?}",
4498 state.events
4499 );
4500 }
4501
4502 #[tokio::test]
4503 async fn a_dependency_only_change_on_a_stale_local_base_is_not_release_only() {
4504 let (_d, repo) = shun_origin("1.0.0").await;
4505 let old = git::rev_parse(&repo, "HEAD").await.unwrap();
4506 for (p, from) in [
4507 ("src-tauri/Cargo.toml", SHUN_TOML),
4508 ("package.json", "{\n \"version\": \"1.0.0\"\n}\n"),
4509 ("src-tauri/tauri.conf.json", "{ \"version\": \"1.0.0\" }\n"),
4510 ] {
4511 tokio::fs::write(repo.join(p), from.replace("1.0.0", "1.0.1"))
4512 .await
4513 .unwrap();
4514 }
4515 tokio::fs::write(
4516 repo.join("src-tauri/Cargo.lock"),
4517 SHUN_LOCK.replace("1.0.0", "1.0.1"),
4518 )
4519 .await
4520 .unwrap();
4521 git::git(&repo, &["commit", "-am", "bump upstream"])
4522 .await
4523 .unwrap();
4524 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
4525 git::git(&repo, &["checkout", "-b", "feat"]).await.unwrap();
4526 tokio::fs::write(
4527 repo.join("package.json"),
4528 "{\n \"version\": \"1.0.1\",\n \"dependencies\": {}\n}\n",
4529 )
4530 .await
4531 .unwrap();
4532 git::git(&repo, &["commit", "-am", "deps"]).await.unwrap();
4533 git::git(&repo, &["update-ref", "refs/heads/main", &old])
4534 .await
4535 .unwrap();
4536 let home = tempfile::tempdir().unwrap();
4537 let store = Notices::at(home.path().join("notifications"));
4538 let mut state = winner_state(&repo, "main");
4539 state.candidates[0].branch = "feat".to_owned();
4540 state.config.release.version_files = shun_files();
4541 let _ = after_merge_at(
4542 &mut state,
4543 "https://example.invalid/pull/1",
4544 Some(home.path()),
4545 Some(&store),
4546 )
4547 .await;
4548 assert!(
4549 !state
4550 .events
4551 .iter()
4552 .any(|e| e.message.contains("only the release manifest")),
4553 "{:?}",
4554 state.events
4555 );
4556 assert!(
4557 state
4558 .release_bump
4559 .as_ref()
4560 .is_none_or(|b| b.skipped.is_none())
4561 );
4562 }
4563}