1use std::fmt::Write as _;
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26use anyhow::{Context as _, Result, anyhow, bail};
27use serde::{Deserialize, Serialize};
28
29use crate::agent::{self, Invocation, SeatState};
30use crate::ask;
31use crate::config::AgentSpec;
32use crate::git;
33use crate::land;
34use crate::notices::{Link, Notice, Notices};
35use crate::proc::Quiet as _;
36use crate::run::{self, RunState, RunStatus};
37use crate::verdict;
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct DecisionAttempt {
42 pub agent: String,
44 pub outcome: String,
46}
47
48async fn ask_decision(
51 state: &mut RunState,
52 repo: &Path,
53 prompt: &str,
54 timeout: Duration,
55) -> Result<(AgentSpec, SeatState, BumpDecision)> {
56 let chain = agent::pick_chain(
57 &state.config.agents,
58 state.config.roles.chatter.as_ref(),
59 &agent::installed,
60 "chatter (release-bump decision)",
61 )?;
62 let attempt_timeout = timeout / u32::try_from(chain.len()).unwrap_or(u32::MAX);
65 let artifacts = agent::artifacts_dir(&state.dir());
66 let cache = state.config.cache_dir();
67 let mut failures = Vec::new();
68 for (n, spec) in chain.iter().enumerate() {
69 let mut seat = crate::graph::handover_seat("bump", &spec.id, state.next_seat_seed());
70 let attempt = state.bump_decision_attempts.len() + 1;
73 let stem = format!("bump-decision-{attempt}");
74 state.event(
75 "bump",
76 format!("decision attempt {attempt}: trying {} ({stem})", spec.id),
77 );
78 let outcome = agent::invoke(
79 spec,
80 &mut seat,
81 &Invocation {
82 cwd: repo,
83 prompt,
84 timeout: attempt_timeout,
85 allow_write: false,
86 unsandboxed: false,
87 sessions: false,
88 artifacts: &artifacts,
89 stem: &stem,
90 run: &state.id,
91 node: "bump",
92 cache_dir: cache.as_deref(),
93 attachments: &[],
94 writable: &[],
95 },
96 )
97 .await;
98 let parsed = if agent::chain_advances(&outcome) {
99 None
100 } else {
101 outcome
102 .as_ref()
103 .ok()
104 .and_then(|out| parse_decision(&out.text).ok())
105 };
106 let (class, reason) = match &outcome {
109 Ok(out) if out.quota_exhausted() => {
110 (run::FailClass::Quota, "rate/quota limit".to_owned())
111 }
112 Ok(out) if out.timed_out => (run::FailClass::Timeout, "timed out".to_owned()),
113 Ok(out) if out.exit_code != Some(0) => (
114 run::FailClass::Other("cli".into()),
115 format!("CLI exit {:?}", out.exit_code),
116 ),
117 Ok(out) if out.text.trim().is_empty() => (
118 run::FailClass::Other("empty".into()),
119 "empty output".to_owned(),
120 ),
121 Err(_) => (
122 run::FailClass::Other("cli".into()),
123 "CLI invocation failed; inspect attempt artifacts".to_owned(),
124 ),
125 _ => (
126 run::FailClass::Other("json".into()),
127 "invalid decision JSON or missing reason".to_owned(),
128 ),
129 };
130 let status = if parsed.is_some() {
131 "valid decision"
132 } else {
133 &reason
134 };
135 state.bump_decision_attempts.push(DecisionAttempt {
136 agent: spec.id.clone(),
137 outcome: status.to_owned(),
138 });
139 state.event(
140 "bump",
141 format!("decision attempt {attempt}: {}: {status}", spec.id),
142 );
143 if let Some(decision) = parsed {
144 return Ok((spec.clone(), seat, decision));
145 }
146 failures.push(format!("{}: {reason}", spec.id));
147 if let Some(next) = chain.get(n + 1) {
148 crate::graph::record_handover(
149 state, "bump", "bump", &spec.id, &next.id, &class, &reason,
150 );
151 }
152 }
153 bail!(
154 "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",
155 chain.len(),
156 failures.join("; ")
157 )
158}
159
160async fn guard_reason(
163 state: &mut RunState,
164 repo: &Path,
165 prompt: &str,
166 spec: &AgentSpec,
167 seat: &mut SeatState,
168 decision: &mut BumpDecision,
169) {
170 let artifacts = agent::artifacts_dir(&state.dir());
171 let mut violations =
172 crate::github_text::check_for(&state.config.graph, "", &decision.reason, None);
173 violations.retain(|v| *v != crate::github_text::Violation::SensitiveData);
175 if violations.is_empty() {
176 decision.reason = crate::scrub::scrub(&decision.reason, &crate::scrub::Identity::current());
177 }
178 if state.config.graph.github_text_guard && !violations.is_empty() {
179 state.event(
180 "github-text",
181 format!("release reason rejected: {violations:?}; requesting one rewrite"),
182 );
183 let rewrite = format!(
184 "{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: {}",
185 decision.reason
186 );
187 let retry = agent::invoke(
188 spec,
189 seat,
190 &Invocation {
191 cwd: repo,
192 prompt: &rewrite,
193 timeout: crate::graph::retry_budget(DECISION_TIMEOUT, true),
194 allow_write: false,
195 unsandboxed: false,
196 sessions: false,
197 artifacts: &artifacts,
198 stem: "bump-rewrite",
199 run: &state.id,
200 node: "bump",
201 cache_dir: state.config.cache_dir().as_deref(),
202 attachments: &[],
203 writable: &[],
204 },
205 )
206 .await;
207 let reason = retry
208 .ok()
209 .filter(|o| o.usable())
210 .and_then(|o| parse_decision(&o.text).ok())
211 .filter(|d| {
212 d.level == decision.level
213 && crate::github_text::check_for(&state.config.graph, "", &d.reason, None)
214 .is_empty()
215 })
216 .map(|d| d.reason);
217 if let Some(reason) = reason {
218 decision.reason = reason;
219 state.event("github-text", "release reason rewrite passed");
220 } else {
221 decision.reason = "The merged change requires a release.".to_owned();
222 state.event(
223 "github-text",
224 "release reason rewrite unavailable or rejected; using neutral text",
225 );
226 }
227 }
228}
229
230const DECISION_TIMEOUT: Duration = Duration::from_secs(600);
238
239pub fn should_release_bump(status: RunStatus) -> bool {
255 status == RunStatus::Merged
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "lowercase")]
261pub enum BumpLevel {
262 Major,
264 Minor,
266 Patch,
268}
269
270impl BumpLevel {
271 pub fn as_str(self) -> &'static str {
273 match self {
274 Self::Major => "major",
275 Self::Minor => "minor",
276 Self::Patch => "patch",
277 }
278 }
279
280 fn severity(self) -> u8 {
285 match self {
286 Self::Patch => 0,
287 Self::Minor => 1,
288 Self::Major => 2,
289 }
290 }
291}
292
293#[derive(Debug, Clone, Deserialize)]
300pub struct BumpDecision {
301 pub level: BumpLevel,
303 pub reason: String,
306}
307
308pub fn parse_decision(text: &str) -> Result<BumpDecision> {
312 let decision: BumpDecision = verdict::extract_json(text)?;
313 if decision.reason.trim().is_empty() {
314 bail!("the bump decision carried no reason");
315 }
316 Ok(decision)
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
322pub struct Version {
323 pub major: u64,
325 pub minor: u64,
327 pub patch: u64,
329}
330
331impl Version {
332 pub fn parse(s: &str) -> Result<Self> {
337 let s = s.trim();
338 let mut parts = s.splitn(3, '.');
339 let major = parts
340 .next()
341 .with_context(|| format!("`{s}` has no major component"))?;
342 let minor = parts
343 .next()
344 .with_context(|| format!("`{s}` has no minor component"))?;
345 let patch = parts
346 .next()
347 .with_context(|| format!("`{s}` has no patch component"))?;
348 let patch_digits: String = patch.chars().take_while(char::is_ascii_digit).collect();
349 Ok(Self {
350 major: major
351 .trim()
352 .parse()
353 .with_context(|| format!("`{major}` is not a number"))?,
354 minor: minor
355 .trim()
356 .parse()
357 .with_context(|| format!("`{minor}` is not a number"))?,
358 patch: patch_digits
359 .parse()
360 .with_context(|| format!("`{patch}` has no numeric patch component"))?,
361 })
362 }
363
364 #[must_use]
367 pub fn bump(self, level: BumpLevel) -> Self {
368 match level {
369 BumpLevel::Major => Self {
370 major: self.major + 1,
371 minor: 0,
372 patch: 0,
373 },
374 BumpLevel::Minor => Self {
375 major: self.major,
376 minor: self.minor + 1,
377 patch: 0,
378 },
379 BumpLevel::Patch => Self {
380 major: self.major,
381 minor: self.minor,
382 patch: self.patch + 1,
383 },
384 }
385 }
386}
387
388impl std::fmt::Display for Version {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
391 }
392}
393
394pub fn is_release_only(files: &[String]) -> bool {
403 !files.is_empty() && files.iter().all(|f| f == "Cargo.toml" || f == "Cargo.lock")
404}
405
406fn rewrite_table_version(toml: &str, table: &str, new_version: &str) -> Result<String> {
417 let mut out = String::with_capacity(toml.len() + 8);
418 let mut in_table = false;
419 let mut done = false;
420 for line in toml.split_inclusive('\n') {
421 let trimmed = line.trim();
422 if trimmed.starts_with('[') {
423 in_table = trimmed == table;
424 }
425 if !done && in_table && trimmed.split('=').next().map(str::trim) == Some("version") {
426 let newline = if line.ends_with("\r\n") { "\r\n" } else { "\n" };
427 let _ = write!(out, "version = \"{new_version}\"{newline}");
428 done = true;
429 continue;
430 }
431 out.push_str(line);
432 }
433 if !done {
434 bail!("no `version` field found under `{table}`");
435 }
436 Ok(out)
437}
438
439fn strip_trailing_comment(s: &str) -> &str {
445 let mut in_string = false;
446 let mut quote = '"';
447 for (i, c) in s.char_indices() {
448 if in_string {
449 if c == quote {
450 in_string = false;
451 }
452 } else if c == '"' || c == '\'' {
453 in_string = true;
454 quote = c;
455 } else if c == '#' {
456 return s[..i].trim_end();
457 }
458 }
459 s
460}
461
462fn unquote_key(key: &str) -> &str {
468 for quote in ['"', '\''] {
469 if let Some(inner) = key.strip_prefix(quote).and_then(|k| k.strip_suffix(quote)) {
470 return inner;
471 }
472 }
473 key
474}
475
476fn extract_workspace_dependencies_fragment(toml: &str) -> String {
488 let mut fragment = String::new();
489 let mut capturing = false;
490 for line in toml.split_inclusive('\n') {
491 let header = strip_trailing_comment(line.trim());
492 if header.starts_with('[') {
493 if header == "[workspace.dependencies]" {
494 capturing = true;
495 fragment.push_str("[dependencies]\n");
496 } else if let Some(name) = header
497 .strip_prefix("[workspace.dependencies.")
498 .and_then(|rest| rest.strip_suffix(']'))
499 {
500 capturing = true;
501 let _ = writeln!(fragment, "[dependencies.{name}]");
502 } else {
503 capturing = false;
504 }
505 continue;
506 }
507 if capturing {
508 fragment.push_str(line);
509 }
510 }
511 fragment
512}
513
514fn internal_pin_names(toml: &str) -> Result<Vec<String>> {
532 let fragment = extract_workspace_dependencies_fragment(toml);
533 if fragment.trim().is_empty() {
534 return Ok(Vec::new());
535 }
536 let value: toml::Value =
537 toml::from_str(&fragment).context("failed to parse `[workspace.dependencies]` as TOML")?;
538 let mut names: Vec<String> = value
539 .get("dependencies")
540 .and_then(|d| d.as_table())
541 .into_iter()
542 .flatten()
543 .filter(|(_, dep)| {
544 dep.as_table()
545 .is_some_and(|t| t.contains_key("path") && t.contains_key("version"))
546 })
547 .map(|(name, _)| name.clone())
548 .collect();
549 names.sort();
550 Ok(names)
551}
552
553fn rewrite_quoted_field(line: &str, key: &str, new_value: &str) -> Option<String> {
563 let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-';
564 let mut search_from = 0;
565 while let Some(rel) = line[search_from..].find(key) {
566 let key_start = search_from + rel;
567 let before_ok = key_start == 0 || !is_ident(line[..key_start].chars().next_back().unwrap());
568 let eq_pos = key_start
569 + key.len()
570 + (line[key_start + key.len()..].len()
571 - line[key_start + key.len()..].trim_start().len());
572 if before_ok && line[eq_pos..].starts_with('=') {
573 let after_eq = &line[eq_pos + 1..];
574 let ws_len = after_eq.len() - after_eq.trim_start().len();
575 let quote_pos = eq_pos + 1 + ws_len;
576 if let Some(quote_char) = line[quote_pos..]
577 .chars()
578 .next()
579 .filter(|c| *c == '"' || *c == '\'')
580 {
581 let value_begin = quote_pos + quote_char.len_utf8();
582 if let Some(end_rel) = line[value_begin..].find(quote_char) {
583 let value_end = value_begin + end_rel;
584 let mut out = String::with_capacity(line.len());
585 out.push_str(&line[..value_begin]);
586 out.push_str(new_value);
587 out.push_str(&line[value_end..]);
588 return Some(out);
589 }
590 }
591 }
592 search_from = key_start + key.len();
593 }
594 None
595}
596
597fn rewrite_workspace_dependency_pins(
612 toml: &str,
613 names: &[String],
614 new_version: &str,
615) -> Result<String> {
616 if names.is_empty() {
617 return Ok(toml.to_owned());
618 }
619 let mut out = String::with_capacity(toml.len() + names.len() * 8);
620 let mut in_flat_table = false;
621 let mut in_named_table: Option<String> = None;
622 let mut rewritten: std::collections::HashSet<String> = std::collections::HashSet::new();
623
624 for line in toml.split_inclusive('\n') {
625 let trimmed = line.trim();
626 if trimmed.starts_with('[') {
627 let header = strip_trailing_comment(trimmed);
628 in_flat_table = header == "[workspace.dependencies]";
629 in_named_table = header
630 .strip_prefix("[workspace.dependencies.")
631 .and_then(|rest| rest.strip_suffix(']'))
632 .map(str::to_owned);
633 out.push_str(line);
634 continue;
635 }
636
637 let key = trimmed.split('=').next().map(str::trim).map(unquote_key);
638
639 if in_flat_table {
640 if let Some(name) = key.and_then(|k| names.iter().find(|n| n.as_str() == k)) {
642 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
643 rewritten.insert(name.clone());
644 out.push_str(&rewritten_line);
645 continue;
646 }
647 } else if let Some(name) = key.and_then(|k| {
648 k.strip_suffix(".version")
649 .and_then(|prefix| names.iter().find(|n| n.as_str() == prefix))
650 }) {
651 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
656 rewritten.insert(name.clone());
657 out.push_str(&rewritten_line);
658 continue;
659 }
660 }
661 } else if let Some(table_name) = &in_named_table {
662 if key == Some("version") && names.iter().any(|n| n == table_name) {
663 if let Some(rewritten_line) = rewrite_quoted_field(line, "version", new_version) {
664 rewritten.insert(table_name.clone());
665 out.push_str(&rewritten_line);
666 continue;
667 }
668 }
669 }
670
671 out.push_str(line);
672 }
673
674 for name in names {
675 if !rewritten.contains(name) {
676 bail!(
677 "could not rewrite the `[workspace.dependencies]` version pin \
678 for `{name}` - its `version` field was not found in a shape \
679 this rewrite understands"
680 );
681 }
682 }
683 Ok(out)
684}
685
686fn verify_pins_rewritten(toml: &str, names: &[String], new_version: &str) -> Result<()> {
694 let fragment = extract_workspace_dependencies_fragment(toml);
695 let value: toml::Value = toml::from_str(&fragment)
696 .context("rewritten `[workspace.dependencies]` failed to parse")?;
697 let deps = value.get("dependencies").and_then(|d| d.as_table());
698 for name in names {
699 let actual = deps
700 .and_then(|d| d.get(name))
701 .and_then(|dep| dep.as_table())
702 .and_then(|t| t.get("version"))
703 .and_then(|v| v.as_str());
704 if actual != Some(new_version) {
705 bail!(
706 "the `[workspace.dependencies]` version pin for `{name}` did \
707 not end up at `{new_version}` after the rewrite"
708 );
709 }
710 }
711 Ok(())
712}
713
714pub fn rewrite_cargo_version(toml: &str, new_version: &str) -> Result<String> {
736 let rewritten = rewrite_table_version(toml, "[package]", new_version)
737 .or_else(|_| rewrite_table_version(toml, "[workspace.package]", new_version))
738 .context("no `version` field found under `[package]` or `[workspace.package]`")?;
739
740 let pin_names = internal_pin_names(&rewritten)?;
741 if pin_names.is_empty() {
742 return Ok(rewritten);
743 }
744
745 let rewritten = rewrite_workspace_dependency_pins(&rewritten, &pin_names, new_version)?;
746 verify_pins_rewritten(&rewritten, &pin_names, new_version)?;
747 Ok(rewritten)
748}
749
750fn version_in_table(toml: &str, table: &str) -> Option<String> {
752 let mut in_table = false;
753 for line in toml.lines() {
754 let trimmed = line.trim();
755 if trimmed.starts_with('[') {
756 in_table = trimmed == table;
757 continue;
758 }
759 if !in_table {
760 continue;
761 }
762 let mut parts = trimmed.splitn(2, '=');
763 let key = parts.next().map(str::trim);
764 let Some(value) = parts.next() else {
765 continue;
766 };
767 if key == Some("version") {
768 return Some(value.trim().trim_matches('"').to_owned());
769 }
770 }
771 None
772}
773
774pub(crate) fn current_version(toml: &str) -> Result<String> {
779 version_in_table(toml, "[package]")
780 .or_else(|| version_in_table(toml, "[workspace.package]"))
781 .context("no `version` field found under `[package]` or `[workspace.package]`")
782}
783
784async fn branch_version(repo: &Path, remote: &str, branch: &str) -> Option<String> {
788 let from_name = branch.strip_prefix("chore/release-v").map(str::to_owned);
789 let tracking = format!("refs/remotes/{remote}/{branch}");
792 let spec = format!("+refs/heads/{branch}:{tracking}");
793 let fetched = git::git_raw(repo, &["fetch", "--quiet", remote, &spec]).await;
794 if fetched.is_ok_and(|o| o.code == Some(0))
795 && let Ok(out) = git::git_raw(repo, &["show", &format!("{tracking}:Cargo.toml")]).await
796 && out.code == Some(0)
797 && let Ok(v) = current_version(&out.stdout)
798 {
799 return Some(v);
800 }
801 from_name
802}
803
804pub fn decision_prompt(
814 subject: &str,
815 instruction: &str,
816 diffstat: &str,
817 files: &[String],
818 current_version: &str,
819) -> String {
820 let mut s = format!(
821 "A pull request just merged into the base branch. Decide which digit \
822 of this project's `major.minor.patch` version this change earns, so \
823 a release bump can be opened for exactly it.\n\n\
824 Current version: {current_version}\n\n\
825 # Merge subject\n\n{subject}\n\n\
826 # The task that produced it\n\n{instruction}\n\n\
827 # Files changed ({} total)\n\n",
828 files.len()
829 );
830 const MAX_FILES: usize = 50;
831 for f in files.iter().take(MAX_FILES) {
832 let _ = writeln!(s, "- {f}");
833 }
834 if files.len() > MAX_FILES {
835 let _ = writeln!(s, "- ... and {} more", files.len() - MAX_FILES);
836 }
837 let _ = write!(s, "\n# Diffstat\n\n```\n{}\n```\n", diffstat.trim());
838
839 s.push_str(
840 "\n# How to decide\n\n\
841 This project is below version `1.0.0`. At that stage **`minor` is \
842 the digit that carries a breaking change** - do not spend `major` \
843 below `1.0.0`.\n\n\
844 A change is breaking, and earns `minor`, when it changes any of: \
845 the public API reachable from `src/lib.rs`, a CLI subcommand or \
846 flag, an HTTP API route or response shape, a configuration key, or \
847 the on-disk shape of persisted state.\n\n\
848 A user-visible new capability that breaks none of the above also \
849 earns `minor`.\n\n\
850 A fix, an internal refactor, or a dependency update earns `patch`.\n\n\
851 **When it is not obvious which digit applies, choose the larger \
852 one.** An oversized bump costs nothing; a breaking change shipped as \
853 `patch` breaks every downstream update that pins a range.\n\n\
854 # Output\n\n\
855 Reply with exactly one fenced JSON object and nothing that matters \
856 outside it:\n\n\
857 ```json\n\
858 {\"level\": \"major\" | \"minor\" | \"patch\", \"reason\": \"one line\"}\n\
859 ```\n",
860 );
861 let _ = write!(
863 s,
864 "\n{}\n\nThe `reason` goes into a GitHub pull request body, so write it \
865 in English.\n",
866 crate::prompt::GITHUB_ENGLISH_HEADING
867 );
868 s
869}
870
871#[derive(Debug, Clone, Serialize, Deserialize)]
887pub struct PendingBump {
888 pub target_version: String,
890 pub level: BumpLevel,
893 pub branch: String,
896 pub pr_url: String,
899}
900
901pub fn marker_path(home: &Path, repo: &Path) -> PathBuf {
905 let key = repo.to_string_lossy();
906 home.join("bump")
907 .join(format!("{:016x}.json", crate::rng::fnv1a(&key)))
908}
909
910pub fn read_marker(path: &Path) -> Option<PendingBump> {
914 let body = std::fs::read_to_string(path).ok()?;
915 serde_json::from_str(&body).ok()
916}
917
918pub fn write_marker(path: &Path, marker: &PendingBump) -> Result<()> {
922 if let Some(parent) = path.parent() {
923 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
924 }
925 let body = serde_json::to_string_pretty(marker).context("serialize pending bump")?;
926 let tmp = path.with_extension("json.tmp");
927 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
928 std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
929 Ok(())
930}
931
932pub fn clear_marker(path: &Path) {
935 let _ = std::fs::remove_file(path);
936}
937
938#[derive(Debug, Clone, PartialEq, Eq)]
942pub enum Coalesce {
943 Proceed,
946 Skip {
948 target_version: String,
950 },
951}
952
953pub fn coalesce(pending: Option<&PendingBump>, current_version: &str) -> Result<Coalesce> {
955 let Some(pending) = pending else {
956 return Ok(Coalesce::Proceed);
957 };
958 let current = Version::parse(current_version)?;
959 let target = Version::parse(&pending.target_version)?;
960 if current >= target {
961 return Ok(Coalesce::Proceed);
962 }
963 Ok(Coalesce::Skip {
964 target_version: pending.target_version.clone(),
965 })
966}
967
968#[derive(Debug, Clone, Copy, PartialEq, Eq)]
970pub enum PendingAction {
971 AlreadyCovered,
974 Escalate,
977}
978
979pub fn pending_action(pending_level: BumpLevel, decision_level: BumpLevel) -> PendingAction {
988 if decision_level.severity() > pending_level.severity() {
989 PendingAction::Escalate
990 } else {
991 PendingAction::AlreadyCovered
992 }
993}
994
995fn parse_pr_state(json: &str) -> Result<bool> {
997 #[derive(Deserialize)]
998 struct State {
999 state: String,
1000 }
1001 let parsed: State =
1002 serde_json::from_str(json).context("parse `gh pr view --json state` output")?;
1003 Ok(parsed.state.eq_ignore_ascii_case("OPEN"))
1004}
1005
1006async fn pr_is_open(repo: &Path, pr_url: &str) -> Result<bool> {
1019 let out = tokio::process::Command::new("gh")
1020 .args(["pr", "view", pr_url, "--json", "state"])
1021 .current_dir(repo)
1022 .quiet()
1023 .stdin(std::process::Stdio::null())
1024 .output()
1025 .await
1026 .context("spawn gh pr view")?;
1027 if !out.status.success() {
1028 bail!(
1029 "gh pr view {pr_url}: {}",
1030 String::from_utf8_lossy(&out.stderr).trim()
1031 );
1032 }
1033 parse_pr_state(&String::from_utf8_lossy(&out.stdout))
1034}
1035
1036const LOCK_STALE_AFTER: Duration = Duration::from_secs(30 * 60);
1044
1045struct MarkerLock {
1057 path: PathBuf,
1058}
1059
1060impl MarkerLock {
1061 fn acquire(marker: &Path) -> Result<Option<Self>> {
1065 let path = marker.with_extension("lock");
1066 if let Some(parent) = path.parent() {
1067 std::fs::create_dir_all(parent)
1068 .with_context(|| format!("create {}", parent.display()))?;
1069 }
1070 if Self::try_create(&path)? {
1071 return Ok(Some(Self { path }));
1072 }
1073 if Self::is_stale(&path) {
1074 let _ = std::fs::remove_file(&path);
1075 if Self::try_create(&path)? {
1076 return Ok(Some(Self { path }));
1077 }
1078 }
1079 Ok(None)
1080 }
1081
1082 fn try_create(path: &Path) -> Result<bool> {
1083 match std::fs::OpenOptions::new()
1084 .write(true)
1085 .create_new(true)
1086 .open(path)
1087 {
1088 Ok(_) => Ok(true),
1089 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1090 Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
1091 }
1092 }
1093
1094 fn is_stale(path: &Path) -> bool {
1095 std::fs::metadata(path)
1096 .and_then(|m| m.modified())
1097 .ok()
1098 .and_then(|m| m.elapsed().ok())
1099 .is_some_and(|age| age >= LOCK_STALE_AFTER)
1100 }
1101}
1102
1103impl Drop for MarkerLock {
1104 fn drop(&mut self) {
1105 let _ = std::fs::remove_file(&self.path);
1106 }
1107}
1108
1109const LOCK_POLL: Duration = Duration::from_secs(5);
1111
1112const LOCK_WAIT_CEILING: Duration = Duration::from_secs(25 * 60);
1125
1126async fn wait_for_marker_lock(marker: &Path) -> Result<Option<MarkerLock>> {
1129 wait_for_marker_lock_with(marker, LOCK_POLL, LOCK_WAIT_CEILING).await
1130}
1131
1132async fn wait_for_marker_lock_with(
1136 marker: &Path,
1137 poll: Duration,
1138 ceiling: Duration,
1139) -> Result<Option<MarkerLock>> {
1140 let mut waited = Duration::ZERO;
1141 loop {
1142 if let Some(lock) = MarkerLock::acquire(marker)? {
1143 return Ok(Some(lock));
1144 }
1145 if waited >= ceiling {
1146 return Ok(None);
1147 }
1148 tokio::time::sleep(poll).await;
1149 waited += poll;
1150 }
1151}
1152
1153fn level_between(from: Version, to: Version) -> Option<BumpLevel> {
1161 if to.major != from.major {
1162 Some(BumpLevel::Major)
1163 } else if to.minor != from.minor {
1164 Some(BumpLevel::Minor)
1165 } else if to.patch != from.patch {
1166 Some(BumpLevel::Patch)
1167 } else {
1168 None
1169 }
1170}
1171
1172fn parse_open_release_pr(json: &str) -> Result<Option<(String, String)>> {
1175 #[derive(Deserialize)]
1176 struct Pr {
1177 url: String,
1178 #[serde(rename = "headRefName")]
1179 head_ref_name: String,
1180 }
1181 let list: Vec<Pr> =
1182 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
1183 Ok(list
1184 .into_iter()
1185 .find(|p| p.head_ref_name.starts_with("chore/release-v"))
1186 .map(|p| (p.head_ref_name, p.url)))
1187}
1188
1189pub(crate) fn parse_open_release_prs(json: &str) -> Result<Vec<(String, String)>> {
1192 #[derive(Deserialize)]
1193 struct Pr {
1194 url: String,
1195 #[serde(rename = "headRefName")]
1196 head_ref_name: String,
1197 }
1198 let list: Vec<Pr> =
1199 serde_json::from_str(json).context("parse `gh pr list --json url,headRefName` output")?;
1200 Ok(list
1201 .into_iter()
1202 .filter(|p| p.head_ref_name.starts_with("chore/release-v"))
1203 .map(|p| (p.head_ref_name, p.url))
1204 .collect())
1205}
1206
1207pub(crate) async fn list_open_release_prs(repo: &Path) -> Result<Vec<(String, String)>> {
1209 let out = tokio::process::Command::new("gh")
1210 .args([
1211 "pr",
1212 "list",
1213 "--state",
1214 "open",
1215 "--limit",
1216 "100",
1217 "--json",
1218 "url,headRefName",
1219 ])
1220 .current_dir(repo)
1221 .env_remove("GH_REPO")
1222 .quiet()
1223 .stdin(std::process::Stdio::null())
1224 .output()
1225 .await
1226 .context("spawn gh pr list")?;
1227 if !out.status.success() {
1228 bail!(
1229 "gh pr list: {}",
1230 String::from_utf8_lossy(&out.stderr).trim()
1231 );
1232 }
1233 parse_open_release_prs(&String::from_utf8_lossy(&out.stdout))
1234}
1235
1236async fn find_open_release_pr(repo: &Path) -> Result<Option<(String, String)>> {
1251 let out = tokio::process::Command::new("gh")
1252 .args(["pr", "list", "--state", "open", "--json", "url,headRefName"])
1253 .current_dir(repo)
1254 .quiet()
1255 .stdin(std::process::Stdio::null())
1256 .output()
1257 .await
1258 .context("spawn gh pr list")?;
1259 if !out.status.success() {
1260 bail!(
1261 "gh pr list: {}",
1262 String::from_utf8_lossy(&out.stderr).trim()
1263 );
1264 }
1265 parse_open_release_pr(&String::from_utf8_lossy(&out.stdout))
1266}
1267
1268pub async fn after_merge(state: &mut RunState, pr_url: &str) -> Result<()> {
1288 after_merge_at(state, pr_url, None, None).await
1289}
1290
1291#[derive(Debug, Default)]
1293struct Progress {
1294 version: Option<String>,
1296 pending_pr: Option<String>,
1298}
1299
1300fn notice_for_failure(progress: &Progress, err: &anyhow::Error) -> (Option<String>, String) {
1306 let chain = format!("{err:#}");
1307 let first = chain.lines().next().unwrap_or_default().trim();
1308 (progress.version.clone(), first.to_owned())
1309}
1310
1311async fn base_has_cargo_toml(repo: &Path, remote: &str, base: &str) -> Result<bool> {
1317 let out = git::git(
1318 repo,
1319 &[
1320 "ls-tree",
1321 "--name-only",
1322 &format!("{remote}/{base}"),
1323 "--",
1324 "Cargo.toml",
1325 ],
1326 )
1327 .await
1328 .context("look for Cargo.toml on the base branch")?;
1329 Ok(!out.trim().is_empty())
1330}
1331
1332async fn after_merge_at(
1336 state: &mut RunState,
1337 pr_url: &str,
1338 home: Option<&Path>,
1339 store: Option<&Notices>,
1340) -> Result<()> {
1341 let mut progress = Progress::default();
1342 let result = after_merge_inner(state, pr_url, home, &mut progress).await;
1343 if let Err(e) = &result {
1344 let (version, reason) = notice_for_failure(&progress, e);
1345 let pending = progress.pending_pr.as_deref();
1346 let store = store.cloned().unwrap_or_else(Notices::open);
1347 report_problem_in(
1348 state,
1349 &store,
1350 pending,
1351 version.as_deref(),
1352 &reason,
1353 pending.is_some(),
1354 )
1355 .await;
1356 }
1357 result
1358}
1359
1360async fn after_merge_inner(
1361 state: &mut RunState,
1362 pr_url: &str,
1363 home: Option<&Path>,
1364 progress: &mut Progress,
1365) -> Result<()> {
1366 if !state.config.merge.release_bump {
1367 return Ok(());
1368 }
1369 let Some(winner) = state.winner().cloned() else {
1370 return Ok(());
1371 };
1372 let repo = state.repo.clone();
1373 let base = state.base_branch.clone();
1374 let remote = state.config.merge.remote.clone();
1375
1376 let files = git::changed_files(&winner.worktree, &base, &winner.branch)
1377 .await
1378 .unwrap_or_default();
1379 if is_release_only(&files) {
1380 state.event(
1381 "bump",
1382 "the merged change touches only the release manifest; not treating it as a trigger",
1383 );
1384 return Ok(());
1385 }
1386
1387 git::fetch(&repo, &remote, &base).await.ok();
1390 if !base_has_cargo_toml(&repo, &remote, &base).await? {
1391 state.event(
1392 "bump",
1393 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping",
1394 );
1395 return Ok(());
1396 }
1397
1398 let marker = marker_path(&home.map_or_else(run::home, Path::to_path_buf), &repo);
1399 let Some(_lock) = wait_for_marker_lock(&marker).await? else {
1406 state.event(
1407 "bump",
1408 "another release bump decision held the lock past the wait ceiling; skipping this round",
1409 );
1410 return Ok(());
1411 };
1412
1413 git::fetch(&repo, &remote, &base).await.ok();
1414 let cargo_toml = git::git(&repo, &["show", &format!("{remote}/{base}:Cargo.toml")])
1415 .await
1416 .context("read Cargo.toml from the base branch")?;
1417 let base_version = current_version(&cargo_toml)?;
1418
1419 let mut pending = read_marker(&marker);
1420 if let Some(p) = &pending {
1421 match coalesce(Some(p), &base_version)? {
1422 Coalesce::Proceed => {
1423 clear_marker(&marker);
1426 pending = None;
1427 }
1428 Coalesce::Skip { target_version } => {
1429 if !pr_is_open(&repo, &p.pr_url).await.unwrap_or(true) {
1430 state.event(
1431 "bump",
1432 format!(
1433 "the pending release bump to v{target_version} ({}) is no longer \
1434 open; treating it as abandoned",
1435 p.pr_url
1436 ),
1437 );
1438 clear_marker(&marker);
1439 pending = None;
1440 }
1441 }
1446 }
1447 }
1448
1449 if pending.is_none() {
1450 if let Ok(Some((branch, url))) = find_open_release_pr(&repo).await
1455 && let Some(target) = branch_version(&repo, &remote, &branch)
1456 .await
1457 .and_then(|v| Version::parse(&v).ok())
1458 {
1459 let base_parsed = Version::parse(&base_version)?;
1460 if target > base_parsed
1461 && let Some(level) = level_between(base_parsed, target)
1462 {
1463 let adopted = PendingBump {
1464 target_version: target.to_string(),
1465 level,
1466 branch,
1467 pr_url: url,
1468 };
1469 let _ = write_marker(&marker, &adopted);
1472 pending = Some(adopted);
1473 }
1474 }
1475 }
1476
1477 progress.pending_pr = pending.as_ref().map(|p| p.pr_url.clone());
1478 let title = pr_title(&repo, pr_url).await.unwrap_or_default();
1479 let subject = land::merge_subject(
1480 crate::graph::landing_title(state, &title),
1481 &crate::graph::landing_subject_source(state),
1482 );
1483 let stat = git::diff_stat(&winner.worktree, &base, &winner.branch)
1484 .await
1485 .unwrap_or_default();
1486 let prompt = decision_prompt(&subject, &state.instruction, &stat, &files, &base_version);
1487
1488 let (spec, mut seat, mut decision) =
1489 ask_decision(state, &repo, &prompt, DECISION_TIMEOUT).await?;
1490 guard_reason(state, &repo, &prompt, &spec, &mut seat, &mut decision).await;
1491
1492 if let Some(p) = pending {
1493 return match pending_action(p.level, decision.level) {
1494 PendingAction::AlreadyCovered => {
1495 state.event(
1496 "bump",
1497 format!(
1498 "a release bump to v{} ({}) already covers at least a {} change; not \
1499 opening another",
1500 p.target_version,
1501 p.pr_url,
1502 decision.level.as_str()
1503 ),
1504 );
1505 Ok(())
1506 }
1507 PendingAction::Escalate => {
1508 progress.version = Some(
1509 Version::parse(&base_version)?
1510 .bump(decision.level)
1511 .to_string(),
1512 );
1513 escalate_pending(state, &repo, &remote, &p, &decision, &base_version, &marker).await
1514 }
1515 };
1516 }
1517
1518 let next = Version::parse(&base_version)?
1519 .bump(decision.level)
1520 .to_string();
1521 progress.version = Some(next.clone());
1522 let branch = format!("chore/release-v{next}");
1523 let worktree = state.dir().join("bump");
1524 let (shared, branch_ref, next_ref, decision_ref) = (&mut *state, &branch, &next, &decision);
1525 let (pr_url_opened, outcome) =
1526 release_attempt(
1527 &repo,
1528 &remote,
1529 &base,
1530 &worktree,
1531 &branch,
1532 |head| {
1533 let repo = repo.clone();
1534 async move { gh_open_pr_for_head(&repo, &head).await }
1535 },
1536 |wt| async move {
1537 open_bump_pr(shared, &wt, branch_ref, next_ref, decision_ref, pr_url).await
1538 },
1539 )
1540 .await?;
1541 let local = outcome == AutomergeOutcome::LocalGate;
1542 let (automerge_warning, merged_detail) = match outcome {
1543 AutomergeOutcome::Enabled | AutomergeOutcome::LocalGate => (None, None),
1544 AutomergeOutcome::MergedDirectly { detail } => (None, Some(detail)),
1545 AutomergeOutcome::Failed { reason } => (Some(reason), None),
1546 };
1547
1548 let marker_write = write_marker(
1557 &marker,
1558 &PendingBump {
1559 target_version: next.clone(),
1560 level: decision.level,
1561 branch,
1562 pr_url: pr_url_opened.clone(),
1563 },
1564 );
1565 state.event(
1566 "bump",
1567 format!(
1568 "opened a {} release bump to v{next} ({}): {pr_url_opened}",
1569 decision.level.as_str(),
1570 decision.reason
1571 ),
1572 );
1573 if let Err(e) = marker_write {
1574 state.event(
1575 "bump",
1576 format!(
1577 "could not record the pending release bump marker for v{next}: {e:#}; a later \
1578 merge may open a duplicate pull request if it cannot find {pr_url_opened} on \
1579 the forge either"
1580 ),
1581 );
1582 }
1583 state.release_bump = Some(run::ReleaseBump {
1584 pr_url: Some(pr_url_opened.clone()),
1585 version: Some(next.clone()),
1586 automerge_enabled: automerge_warning.is_none() && merged_detail.is_none() && !local,
1587 merged_directly: merged_detail.is_some(),
1588 local,
1589 ..run::ReleaseBump::default()
1590 });
1591 if let Some(detail) = merged_detail {
1592 clear_marker(&marker);
1595 state.event("bump", format!("merged v{next} directly: {detail}"));
1596 }
1597 if let Some(warning) = automerge_warning {
1598 state.event(
1599 "bump",
1600 format!("could not enable automerge on {pr_url_opened}: {warning}; merge it by hand"),
1601 );
1602 report_problem(state, Some(&pr_url_opened), Some(&next), &warning).await;
1603 }
1604 Ok(())
1605}
1606
1607pub const NOTICE_NODE: &str = "release-bump";
1612
1613fn automerge_hint(reason: &str) -> &'static str {
1620 let r = reason.to_lowercase();
1621 if is_clean_status_refusal(reason) {
1622 "merge the release pull request by hand; CI is already green"
1623 } else if r.contains("enablepullrequestautomerge") || r.contains("protected branch rules") {
1624 "merge the release pull request by hand, and enable branch protection with required \
1625 status checks on the base branch so automerge can work next time"
1626 } else {
1627 "merge the release pull request by hand"
1628 }
1629}
1630
1631fn automerge_failure_comment(reason: &str) -> String {
1633 format!(
1634 "magi could not enable automerge on this pull request: {reason}\n\n\
1635 Action required: {}. Until then the release does not happen.",
1636 automerge_hint(reason)
1637 )
1638}
1639
1640#[cfg(test)]
1648fn surface_problem(
1649 state: &mut RunState,
1650 store: &Notices,
1651 pr_url: Option<&str>,
1652 version: Option<&str>,
1653 reason: &str,
1654) -> Result<(Notice, Option<String>)> {
1655 surface_problem_in(state, store, pr_url, version, reason, false)
1656}
1657
1658fn surface_problem_in(
1662 state: &mut RunState,
1663 store: &Notices,
1664 pr_url: Option<&str>,
1665 version: Option<&str>,
1666 reason: &str,
1667 pending: bool,
1668) -> Result<(Notice, Option<String>)> {
1669 let action = if let (true, Some(url)) = (pending, pr_url) {
1670 let ja = crate::lang::is_japanese(&state.config.graph.language);
1671 let first = reason.lines().next().filter(|l| !l.is_empty());
1672 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1673 if ja {
1674 let target = version.map(|v| format!(" v{v}")).unwrap_or_default();
1675 format!(
1676 "既存のリリース PR {url} を{target}へ更新できませんでした{cause}。PR を手で更新してください"
1677 )
1678 } else {
1679 let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1680 format!(
1681 "the pending release pull request {url} could not be updated{target}{cause}; update it by hand"
1682 )
1683 }
1684 } else if pr_url.is_some() {
1685 automerge_hint(reason).to_owned()
1686 } else {
1687 let ja = crate::lang::is_japanese(&state.config.graph.language);
1688 let first = reason.lines().next().filter(|l| !l.is_empty());
1689 if ja {
1690 let target = version.map(|v| format!(" (v{v})")).unwrap_or_default();
1691 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1692 format!(
1693 "リリースバンプ{target}は実行されませんでした{cause}。リリース PR を手で開いてください"
1694 )
1695 } else {
1696 let target = version.map(|v| format!(" to v{v}")).unwrap_or_default();
1697 let cause = first.map(|l| format!(" ({l})")).unwrap_or_default();
1698 format!(
1699 "the release bump{target} did not run{cause}; open the release pull request by hand"
1700 )
1701 }
1702 };
1703 let record = state.release_bump.get_or_insert_with(Default::default);
1704 record.pr_url = pr_url.map(str::to_owned).or(record.pr_url.take());
1705 record.version = version.map(str::to_owned).or(record.version.take());
1706 record.automerge_enabled = false;
1707 record.problem = Some(reason.to_owned());
1708 record.action_required = Some(action.clone());
1709
1710 let mut notice = Notice::error(
1711 &format!("release-bump:{}", state.id),
1712 format!(
1713 "Run {} merged, but its release step failed: {action}.",
1714 state.id
1715 ),
1716 );
1717 notice = match pr_url {
1718 Some(url) => notice.link(Link::Url {
1719 url: url.to_owned(),
1720 }),
1721 None => notice.link(Link::Run {
1722 id: state.id.clone(),
1723 }),
1724 };
1725 let notice = store
1726 .raise(notice)
1727 .context("raise the release-bump notification")?;
1728 state.event("bump", format!("needs attention: {action}"));
1729 let comment = pr_url
1730 .filter(|_| !pending)
1731 .map(|_| automerge_failure_comment(reason));
1732 Ok((notice, comment))
1733}
1734
1735pub async fn report_problem(
1740 state: &mut RunState,
1741 pr_url: Option<&str>,
1742 version: Option<&str>,
1743 reason: &str,
1744) {
1745 report_problem_in(state, &Notices::open(), pr_url, version, reason, false).await;
1746}
1747
1748async fn report_problem_in(
1750 state: &mut RunState,
1751 store: &Notices,
1752 pr_url: Option<&str>,
1753 version: Option<&str>,
1754 reason: &str,
1755 pending: bool,
1756) {
1757 match surface_problem_in(state, store, pr_url, version, reason, pending) {
1758 Ok((notice, comment)) => {
1759 if let (Some(url), Some(body)) = (pr_url, comment)
1760 && let Err(e) = gh_pr_comment(state, url, &body).await
1761 {
1762 state.event("bump", format!("could not comment on {url}: {e:#}"));
1763 }
1764 let summary = match pr_url {
1767 Some(url) if pending => format!("Release PR could not be updated: {url}"),
1768 Some(url) => format!("Release PR needs a human: {url}"),
1769 None if crate::lang::is_japanese(&state.config.graph.language) => {
1770 "リリースバンプが実行されませんでした".to_owned()
1771 }
1772 None => "Release bump did not run".to_owned(),
1773 };
1774 let q = ask::Question::new(
1775 state.id.clone(),
1776 NOTICE_NODE.to_owned(),
1777 "bump".to_owned(),
1778 summary,
1779 notice.message.clone(),
1780 Vec::new(),
1781 );
1782 if let Err(e) = ask::notify(&state.config.notify, &q).await {
1783 tracing::warn!(
1784 "could not notify about the release bump of {}: {e:#}",
1785 state.id
1786 );
1787 }
1788 }
1789 Err(e) => state.event("bump", format!("could not raise a notice: {e:#}")),
1790 }
1791}
1792
1793pub(crate) async fn gh_pr_comment(state: &mut RunState, pr_url: &str, body: &str) -> Result<()> {
1794 let (_, body) = crate::github_text::prepare(state, "", body);
1795 let cwd = &state.repo;
1796 let out = tokio::process::Command::new("gh")
1797 .args(["pr", "comment", pr_url, "--body", &body])
1798 .current_dir(cwd)
1799 .quiet()
1800 .stdin(std::process::Stdio::null())
1801 .output()
1802 .await
1803 .context("spawn gh pr comment")?;
1804 if out.status.success() {
1805 Ok(())
1806 } else {
1807 bail!(
1808 "gh pr comment: {}",
1809 String::from_utf8_lossy(&out.stderr).trim()
1810 )
1811 }
1812}
1813
1814async fn escalate_pending(
1823 state: &mut RunState,
1824 repo: &Path,
1825 remote: &str,
1826 pending: &PendingBump,
1827 decision: &BumpDecision,
1828 base_version: &str,
1829 marker: &Path,
1830) -> Result<()> {
1831 let next = Version::parse(base_version)?
1832 .bump(decision.level)
1833 .to_string();
1834 let worktree = state.dir().join("bump");
1835 git::worktree_remove(repo, &worktree).await.ok();
1836 let checked_out = git::git_raw(
1837 repo,
1838 &[
1839 "worktree",
1840 "add",
1841 "--force",
1842 &worktree.to_string_lossy(),
1843 &pending.branch,
1844 ],
1845 )
1846 .await?;
1847 if !checked_out.ok() {
1848 bail!(
1849 "checking out the pending release branch {} failed: {}",
1850 pending.branch,
1851 checked_out.stderr
1852 );
1853 }
1854
1855 let pushed: Result<()> = async {
1860 let cargo_toml_path = worktree.join("Cargo.toml");
1861 let toml = tokio::fs::read_to_string(&cargo_toml_path)
1862 .await
1863 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
1864 let rewritten = rewrite_cargo_version(&toml, &next)?;
1865 tokio::fs::write(&cargo_toml_path, rewritten)
1866 .await
1867 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
1868 sync_lockfile(&worktree, state.config.cache_dir().as_deref()).await?;
1869 let committed = git::commit_all(
1870 &worktree,
1871 &format!(
1872 "chore: release v{next} (supersedes v{})",
1873 pending.target_version
1874 ),
1875 )
1876 .await
1877 .context("commit the escalated version bump")?;
1878 if !committed {
1879 bail!("escalating the version bump left nothing to commit");
1880 }
1881 let pushed = git::push(&worktree, remote, &pending.branch).await?;
1882 if !pushed.ok() {
1883 bail!("pushing {} failed: {}", pending.branch, pushed.stderr);
1884 }
1885 Ok(())
1886 }
1887 .await;
1888 if let Err(e) = pushed {
1889 git::worktree_remove(repo, &worktree).await.ok();
1890 return Err(e);
1891 }
1892
1893 let (title, _) = crate::github_text::prepare(
1897 state,
1898 &format!("chore: release v{next} ({} bump)", decision.level.as_str()),
1899 "",
1900 );
1901 let title_warning = match gh_pr_edit_title(&worktree, &pending.pr_url, &title).await {
1902 Ok(()) => None,
1903 Err(e) => Some(e.to_string()),
1904 };
1905 git::worktree_remove(repo, &worktree).await.ok();
1906
1907 let marker_write = write_marker(
1908 marker,
1909 &PendingBump {
1910 target_version: next.clone(),
1911 level: decision.level,
1912 branch: pending.branch.clone(),
1913 pr_url: pending.pr_url.clone(),
1914 },
1915 );
1916 state.event(
1917 "bump",
1918 format!(
1919 "escalated the pending release bump from v{} to v{next} to a {} change ({}): {}",
1920 pending.target_version,
1921 decision.level.as_str(),
1922 decision.reason,
1923 pending.pr_url
1924 ),
1925 );
1926 if let Err(e) = marker_write {
1927 state.event(
1928 "bump",
1929 format!(
1930 "could not update the pending release bump marker to v{next}: {e:#}; a later \
1931 merge may misjudge whether it is already covered"
1932 ),
1933 );
1934 }
1935 if let Some(warning) = title_warning {
1936 state.event(
1937 "bump",
1938 format!(
1939 "pushed v{next} to {} but could not update its title: {warning}; the squashed \
1940 subject may still read the superseded version",
1941 pending.pr_url
1942 ),
1943 );
1944 }
1945 Ok(())
1946}
1947
1948async fn gh_open_pr_for_head(repo: &Path, branch: &str) -> Result<bool> {
1951 let out = tokio::process::Command::new("gh")
1952 .args([
1953 "pr", "list", "--head", branch, "--state", "open", "--json", "url",
1954 ])
1955 .current_dir(repo)
1956 .quiet()
1957 .stdin(std::process::Stdio::null())
1958 .output()
1959 .await
1960 .context("spawn gh pr list")?;
1961 if !out.status.success() {
1962 bail!(
1963 "gh pr list --head {branch}: {}",
1964 String::from_utf8_lossy(&out.stderr).trim()
1965 );
1966 }
1967 let prs: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout)
1968 .with_context(|| format!("parse the pull requests headed by {branch}"))?;
1969 Ok(!prs.is_empty())
1970}
1971
1972async fn release_attempt<T, F, Fut, P, PFut>(
1981 repo: &Path,
1982 remote: &str,
1983 base: &str,
1984 worktree: &Path,
1985 branch: &str,
1986 open_pr: P,
1987 fill: F,
1988) -> Result<T>
1989where
1990 F: FnOnce(PathBuf) -> Fut,
1991 Fut: std::future::Future<Output = Result<T>>,
1992 P: Fn(String) -> PFut,
1993 PFut: std::future::Future<Output = Result<bool>>,
1994{
1995 let start = format!("{remote}/{base}");
1996 git::worktree_remove(repo, worktree).await.ok();
1997 let mut retried = false;
1998 if git::branch_exists(repo, branch).await? {
1999 reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await?;
2000 retried = true;
2001 }
2002 loop {
2003 if let Err(e) = git::git(repo, &["branch", branch, &start]).await {
2008 let e = e.context("create the release branch");
2009 if !git::branch_exists(repo, branch).await.unwrap_or(false) {
2010 return Err(e);
2011 }
2012 if retried {
2014 return Err(anyhow!(
2015 "{e:#}; left {branch} in place: it is not one this attempt created"
2016 ));
2017 }
2018 retried = true;
2019 if let Err(r) = reclaim_stale_branch(repo, remote, &start, branch, &open_pr).await {
2020 return Err(anyhow!("{r:#} (after: {e:#})"));
2021 }
2022 continue;
2023 }
2024 if let Some(parent) = worktree.parent() {
2025 tokio::fs::create_dir_all(parent).await.ok();
2026 }
2027 let path = worktree.to_string_lossy();
2028 if let Err(e) = git::git(repo, &["worktree", "add", &path, branch]).await {
2029 let e = e.context("create the release-bump worktree");
2030 return Err(discard_attempt(repo, remote, worktree, branch, e).await);
2031 }
2032 break;
2033 }
2034 match fill(worktree.to_path_buf()).await {
2035 Ok(v) => {
2036 git::worktree_remove(repo, worktree).await.ok();
2039 Ok(v)
2040 }
2041 Err(e) => Err(discard_attempt(repo, remote, worktree, branch, e).await),
2042 }
2043}
2044
2045async fn discard_attempt(
2047 repo: &Path,
2048 remote: &str,
2049 worktree: &Path,
2050 branch: &str,
2051 cause: anyhow::Error,
2052) -> anyhow::Error {
2053 git::worktree_remove(repo, worktree).await.ok();
2054 if !git::branch_exists(repo, branch).await.unwrap_or(false) {
2055 return cause;
2056 }
2057 let note = match git::remote_has_branch(repo, remote, branch).await {
2060 Ok(false) => match git::branch_delete(repo, branch).await {
2061 Ok(true) => return cause,
2062 _ => format!("could not delete the local branch {branch}"),
2063 },
2064 Ok(true) => format!("left {branch} in place: it was pushed to {remote}"),
2065 Err(e) => format!(
2066 "left {branch} in place: could not tell whether it was pushed to {remote} ({e:#})"
2067 ),
2068 };
2069 anyhow!("{cause:#}; {note}")
2070}
2071
2072async fn reclaim_stale_branch<P, PFut>(
2077 repo: &Path,
2078 remote: &str,
2079 start: &str,
2080 branch: &str,
2081 open_pr: &P,
2082) -> Result<()>
2083where
2084 P: Fn(String) -> PFut,
2085 PFut: std::future::Future<Output = Result<bool>>,
2086{
2087 match git::remote_has_branch(repo, remote, branch).await {
2088 Ok(false) => {}
2089 Ok(true) => bail!("the branch {branch} already exists and is on {remote}; left alone"),
2090 Err(e) => bail!(
2091 "the branch {branch} already exists and could not check whether {remote} has it ({e:#}); left alone"
2092 ),
2093 }
2094 match open_pr(branch.to_string()).await {
2095 Ok(false) => {}
2096 Ok(true) => {
2097 bail!("the branch {branch} already exists and has an open pull request; left alone")
2098 }
2099 Err(e) => bail!(
2100 "the branch {branch} already exists and could not check for an open pull request \
2101 ({e:#}); left alone"
2102 ),
2103 }
2104 match git::commits_ahead(repo, start, branch).await {
2105 Ok(0) => {}
2106 Ok(n) => bail!(
2107 "the branch {branch} already exists with {n} commit(s) not in {start}; left alone"
2108 ),
2109 Err(e) => bail!(
2110 "the branch {branch} already exists and could not compare it with {start} ({e:#}); \
2111 left alone"
2112 ),
2113 }
2114 if let Some(held) = git::worktree_holding(repo, branch).await? {
2115 let same = |a: &Path, b: &Path| match (a.canonicalize(), b.canonicalize()) {
2116 (Ok(a), Ok(b)) => a == b,
2117 _ => a == b,
2118 };
2119 if same(&held, repo) {
2120 bail!(
2121 "the branch {branch} already exists and is checked out in the main checkout; left alone"
2122 );
2123 }
2124 git::worktree_remove(repo, &held).await.ok();
2125 }
2126 if !git::branch_delete(repo, branch).await? {
2127 bail!("the branch {branch} already exists and could not be deleted; left alone");
2128 }
2129 Ok(())
2130}
2131
2132async fn open_bump_pr(
2138 state: &mut RunState,
2139 worktree: &Path,
2140 branch: &str,
2141 next_version: &str,
2142 decision: &BumpDecision,
2143 source_pr_url: &str,
2144) -> Result<(String, AutomergeOutcome)> {
2145 let cargo_toml_path = worktree.join("Cargo.toml");
2146 let toml = tokio::fs::read_to_string(&cargo_toml_path)
2147 .await
2148 .with_context(|| format!("read {}", cargo_toml_path.display()))?;
2149 let rewritten = rewrite_cargo_version(&toml, next_version)?;
2150 tokio::fs::write(&cargo_toml_path, rewritten)
2151 .await
2152 .with_context(|| format!("write {}", cargo_toml_path.display()))?;
2153
2154 sync_lockfile(worktree, state.config.cache_dir().as_deref()).await?;
2155
2156 let committed = git::commit_all(worktree, &format!("chore: release v{next_version}"))
2157 .await
2158 .context("commit the version bump")?;
2159 if !committed {
2160 bail!("the version bump left nothing to commit");
2161 }
2162
2163 let remote = state.config.merge.remote.clone();
2164 let pushed = git::push(worktree, &remote, branch).await?;
2165 if !pushed.ok() {
2166 bail!("pushing {branch} failed: {}", pushed.stderr);
2167 }
2168
2169 let (title, body) = release_pr(
2170 decision.level.as_str(),
2171 &decision.reason,
2172 next_version,
2173 &state.id,
2174 source_pr_url,
2175 );
2176 let (title, body) = crate::github_text::prepare(state, &title, &body);
2177 let url = gh_pr_create(worktree, &state.base_branch, branch, &title, &body).await?;
2178 if state.config.release.is_local() {
2182 crate::release_watch::register(&crate::run::home(), &state.repo, &url, &state.id);
2185 return Ok((url, AutomergeOutcome::LocalGate));
2186 }
2187 let outcome = match gh_enable_automerge(worktree, &url).await {
2188 Ok(()) => AutomergeOutcome::Enabled,
2189 Err(e) => {
2190 let reason = e.to_string();
2191 if is_clean_status_refusal(&reason) {
2192 gh_merge_directly(worktree, &url, &title, reason).await
2193 } else {
2194 AutomergeOutcome::Failed { reason }
2195 }
2196 }
2197 };
2198 Ok((url, outcome))
2199}
2200
2201#[derive(Debug, Clone, PartialEq, Eq)]
2203enum AutomergeOutcome {
2204 Enabled,
2206 MergedDirectly { detail: String },
2208 LocalGate,
2210 Failed { reason: String },
2212}
2213
2214fn is_clean_status_refusal(reason: &str) -> bool {
2220 let r = reason.to_lowercase();
2221 r.contains("is in clean status") && r.contains("enablepullrequestautomerge")
2222}
2223
2224pub(crate) fn bump_merge_argv(pr_url: &str, subject: &str) -> Vec<String> {
2227 [
2228 "pr",
2229 "merge",
2230 pr_url,
2231 "--squash",
2232 "--delete-branch",
2233 "--subject",
2234 subject,
2235 ]
2236 .map(str::to_owned)
2237 .to_vec()
2238}
2239
2240fn resolve_direct_merge(
2244 refusal: &str,
2245 argv: &[String],
2246 merge_ok: bool,
2247 stderr: &str,
2248 after: Option<land::PrLifecycle>,
2249) -> AutomergeOutcome {
2250 if merge_ok {
2251 return AutomergeOutcome::MergedDirectly {
2252 detail: format!("automerge was refused ({refusal}); gh {}", argv.join(" ")),
2253 };
2254 }
2255 match land::merged_after_all(argv, stderr, after) {
2256 Some(m) => AutomergeOutcome::MergedDirectly { detail: m.detail },
2257 None => AutomergeOutcome::Failed {
2258 reason: format!("{refusal}; merging directly failed too: {}", stderr.trim()),
2259 },
2260 }
2261}
2262
2263async fn gh_merge_directly(
2267 cwd: &Path,
2268 pr_url: &str,
2269 subject: &str,
2270 refusal: String,
2271) -> AutomergeOutcome {
2272 let argv = bump_merge_argv(pr_url, subject);
2273 let out = match tokio::process::Command::new("gh")
2274 .args(&argv)
2275 .current_dir(cwd)
2276 .quiet()
2277 .stdin(std::process::Stdio::null())
2278 .output()
2279 .await
2280 {
2281 Ok(o) => o,
2282 Err(e) => {
2283 return AutomergeOutcome::Failed {
2284 reason: format!("{refusal}; could not spawn gh to merge directly: {e}"),
2285 };
2286 }
2287 };
2288 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
2289 let after = if out.status.success() {
2292 None
2293 } else {
2294 land::lifecycle(cwd, pr_url).await.ok()
2295 };
2296 resolve_direct_merge(&refusal, &argv, out.status.success(), &stderr, after)
2297}
2298
2299async fn sync_lockfile(worktree: &Path, cache_dir: Option<&Path>) -> Result<()> {
2307 let mut cmd = tokio::process::Command::new("cargo");
2308 cmd.arg("build").current_dir(worktree).quiet();
2309 if let Some(dir) = cache_dir {
2310 cmd.env("CARGO_TARGET_DIR", dir);
2311 }
2312 let out = cmd
2313 .stdin(std::process::Stdio::null())
2314 .output()
2315 .await
2316 .context("spawn cargo build")?;
2317 if !out.status.success() {
2318 bail!(
2319 "cargo build failed while syncing Cargo.lock: {}",
2320 String::from_utf8_lossy(&out.stderr).trim()
2321 );
2322 }
2323 Ok(())
2324}
2325
2326fn release_pr(
2331 level: &str,
2332 reason: &str,
2333 next_version: &str,
2334 run_id: &str,
2335 source_pr_url: &str,
2336) -> (String, String) {
2337 let title = format!("chore: release v{next_version} ({level} bump)");
2338 let body = format!(
2339 "## Background\n\n\
2340 A change that was just merged is a `{level}` change, so the crate needs a new \
2341 release: {reason}\n\n\
2342 Triggered by magi run `{run_id}`, which landed {source}.\n\n\
2343 ## Change\n\n\
2344 Raises the package version to `v{next_version}` in `Cargo.toml`, with \
2345 `Cargo.lock` following it. Nothing else changes.\n\n\
2346 ## Risk\n\n\
2347 Version-bump-only, so there is nothing here for a reviewer to find. Merging \
2348 it starts the release pipeline (auto-tag, then the release workflow).",
2349 source = source_pr_url,
2350 );
2351 (title, body)
2352}
2353
2354async fn pr_title(repo: &Path, pr_url: &str) -> Result<String> {
2356 let out = tokio::process::Command::new("gh")
2357 .args(["pr", "view", pr_url, "--json", "title"])
2358 .current_dir(repo)
2359 .quiet()
2360 .stdin(std::process::Stdio::null())
2361 .output()
2362 .await
2363 .context("spawn gh pr view")?;
2364 if !out.status.success() {
2365 bail!(
2366 "gh pr view {pr_url}: {}",
2367 String::from_utf8_lossy(&out.stderr).trim()
2368 );
2369 }
2370 #[derive(Deserialize)]
2371 struct Title {
2372 title: String,
2373 }
2374 let parsed: Title = serde_json::from_str(&String::from_utf8_lossy(&out.stdout))
2375 .context("parse `gh pr view --json title` output")?;
2376 Ok(parsed.title)
2377}
2378
2379async fn gh_pr_create(
2380 cwd: &Path,
2381 base: &str,
2382 head: &str,
2383 title: &str,
2384 body: &str,
2385) -> Result<String> {
2386 let out = tokio::process::Command::new("gh")
2387 .args([
2388 "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
2389 ])
2390 .current_dir(cwd)
2391 .quiet()
2392 .stdin(std::process::Stdio::null())
2393 .output()
2394 .await
2395 .context("spawn gh pr create")?;
2396 if out.status.success() {
2397 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2398 } else {
2399 bail!(
2400 "gh pr create: {}",
2401 String::from_utf8_lossy(&out.stderr).trim()
2402 )
2403 }
2404}
2405
2406async fn gh_enable_automerge(cwd: &Path, pr_url: &str) -> Result<()> {
2410 let out = tokio::process::Command::new("gh")
2411 .args([
2412 "pr",
2413 "merge",
2414 pr_url,
2415 "--auto",
2416 "--squash",
2417 "--delete-branch",
2418 ])
2419 .current_dir(cwd)
2420 .quiet()
2421 .stdin(std::process::Stdio::null())
2422 .output()
2423 .await
2424 .context("spawn gh pr merge --auto")?;
2425 if out.status.success() {
2426 Ok(())
2427 } else {
2428 bail!(
2429 "gh pr merge --auto: {}",
2430 String::from_utf8_lossy(&out.stderr).trim()
2431 )
2432 }
2433}
2434
2435async fn gh_pr_edit_title(cwd: &Path, pr_url: &str, title: &str) -> Result<()> {
2439 let out = tokio::process::Command::new("gh")
2440 .args(["pr", "edit", pr_url, "--title", title])
2441 .current_dir(cwd)
2442 .quiet()
2443 .stdin(std::process::Stdio::null())
2444 .output()
2445 .await
2446 .context("spawn gh pr edit")?;
2447 if out.status.success() {
2448 Ok(())
2449 } else {
2450 bail!(
2451 "gh pr edit --title: {}",
2452 String::from_utf8_lossy(&out.stderr).trim()
2453 )
2454 }
2455}
2456
2457#[cfg(test)]
2458mod tests {
2459 use super::*;
2460 use crate::notices::Severity;
2461
2462 #[test]
2463 fn github_facing_bump_text_is_english() {
2464 let (title, body) =
2465 release_pr("minor", "adds a flag", "0.37.0", "ab12", "https://x/pull/1");
2466 assert!(crate::github_text::check(&title, &body).is_empty());
2467 assert!(title.is_ascii() && body.is_ascii(), "{title}\n{body}");
2468 assert_eq!(title, "chore: release v0.37.0 (minor bump)");
2469 assert!(
2470 body.contains("## Background") && body.contains("## Change"),
2471 "{body}"
2472 );
2473 let p = decision_prompt("s", "i", "d", &[], "0.36.5");
2474 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
2475 }
2476 use crate::config::Config;
2477 use crate::land::PrLifecycle;
2478
2479 #[tokio::test]
2485 async fn a_disabled_config_does_nothing() {
2486 let config = Config {
2487 merge: crate::config::Merge {
2488 release_bump: false,
2489 ..crate::config::Merge::default()
2490 },
2491 ..Config::default()
2492 };
2493 let mut state = RunState::new(
2494 PathBuf::from("/no/such/repo"),
2495 "main".to_owned(),
2496 "0000000000000000000000000000000000000000".to_owned(),
2497 "irrelevant".to_owned(),
2498 config,
2499 );
2500 after_merge(&mut state, "https://example.invalid/pull/1")
2501 .await
2502 .expect("a disabled config must return Ok without touching anything");
2503 assert!(
2504 state.events.is_empty(),
2505 "nothing should happen at all, not even a logged event"
2506 );
2507 }
2508
2509 async fn origin_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
2511 let dir = tempfile::tempdir().unwrap();
2512 let origin = dir.path().join("origin.git");
2513 let repo = dir.path().join("repo");
2514 let o = origin.to_string_lossy().into_owned();
2515 git::git(dir.path(), &["init", "--bare", "-b", "main", &o])
2516 .await
2517 .unwrap();
2518 tokio::fs::create_dir_all(&repo).await.unwrap();
2519 git::git(&repo, &["init", "-b", "main"]).await.unwrap();
2520 git::git(&repo, &["config", "user.name", "test"])
2521 .await
2522 .unwrap();
2523 git::git(&repo, &["config", "user.email", "test@example.com"])
2524 .await
2525 .unwrap();
2526 for (name, body) in files {
2527 tokio::fs::write(repo.join(name), body).await.unwrap();
2528 }
2529 git::git(&repo, &["add", "-A"]).await.unwrap();
2530 git::git(&repo, &["commit", "-m", "init"]).await.unwrap();
2531 git::git(&repo, &["remote", "add", "origin", &o])
2532 .await
2533 .unwrap();
2534 git::git(&repo, &["push", "origin", "main"]).await.unwrap();
2535 (dir, repo)
2536 }
2537
2538 async fn no_pr(_: String) -> Result<bool> {
2539 Ok(false)
2540 }
2541
2542 async fn with_branch(ahead: bool) -> (tempfile::TempDir, PathBuf) {
2545 let (d, repo) = origin_with(&[("f", "x\n")]).await;
2546 git::git(&repo, &["branch", "b"]).await.unwrap();
2547 if ahead {
2548 git::git(&repo, &["checkout", "-q", "b"]).await.unwrap();
2549 git::git(&repo, &["commit", "--allow-empty", "-m", "wip"])
2550 .await
2551 .unwrap();
2552 git::git(&repo, &["checkout", "-q", "main"]).await.unwrap();
2553 }
2554 (d, repo)
2555 }
2556
2557 async fn attempt(repo: &Path, open_pr: bool, fail_after_push: Option<bool>) -> Result<()> {
2558 let wt = repo.parent().unwrap().join("bump");
2559 release_attempt(
2560 repo,
2561 "origin",
2562 "main",
2563 &wt,
2564 "b",
2565 |_| async move { Ok(open_pr) },
2566 |w| async move {
2567 if fail_after_push == Some(true) {
2568 git::push(&w, "origin", "b").await?;
2569 }
2570 if fail_after_push.is_some() {
2571 bail!("cargo build failed");
2572 }
2573 Ok(())
2574 },
2575 )
2576 .await
2577 }
2578
2579 #[tokio::test]
2580 async fn a_stale_ancestor_branch_is_deleted_and_the_attempt_proceeds() {
2581 let (_d, repo) = with_branch(false).await;
2582 attempt(&repo, false, None).await.unwrap();
2583 assert!(git::branch_exists(&repo, "b").await.unwrap());
2585 }
2586
2587 #[tokio::test]
2588 async fn a_stale_branch_holding_a_leftover_worktree_is_reclaimed() {
2589 let (_d, repo) = with_branch(false).await;
2590 let old = repo.parent().unwrap().join("old");
2591 git::git(&repo, &["worktree", "add", &old.to_string_lossy(), "b"])
2592 .await
2593 .unwrap();
2594 attempt(&repo, false, None).await.unwrap();
2595 assert!(!old.exists());
2596 }
2597
2598 #[tokio::test]
2599 async fn a_stale_branch_with_an_unmerged_commit_is_kept_with_a_reason() {
2600 let (_d, repo) = with_branch(true).await;
2601 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2602 assert!(e.contains("`b`") || e.contains("branch b"), "{e}");
2603 assert!(e.contains("1 commit(s) not in origin/main"), "{e}");
2604 assert!(git::branch_exists(&repo, "b").await.unwrap());
2605 }
2606
2607 #[tokio::test]
2608 async fn a_stale_branch_on_the_remote_is_kept() {
2609 let (_d, repo) = with_branch(false).await;
2610 git::git(&repo, &["push", "origin", "b"]).await.unwrap();
2611 let e = attempt(&repo, false, None).await.unwrap_err().to_string();
2612 assert!(e.contains("branch b") && e.contains("is on origin"), "{e}");
2613 assert!(git::branch_exists(&repo, "b").await.unwrap());
2614 }
2615
2616 #[tokio::test]
2617 async fn a_stale_branch_with_an_open_pull_request_is_kept() {
2618 let (_d, repo) = with_branch(false).await;
2619 let e = attempt(&repo, true, None).await.unwrap_err().to_string();
2620 assert!(e.contains("open pull request"), "{e}");
2621 assert!(git::branch_exists(&repo, "b").await.unwrap());
2622 }
2623
2624 #[tokio::test]
2625 async fn an_unanswerable_pull_request_check_keeps_the_branch() {
2626 let (_d, repo) = with_branch(false).await;
2627 let wt = repo.parent().unwrap().join("bump");
2628 let e = release_attempt(
2629 &repo,
2630 "origin",
2631 "main",
2632 &wt,
2633 "b",
2634 |_| async { bail!("offline") },
2635 |_| async { Ok(()) },
2636 )
2637 .await
2638 .unwrap_err()
2639 .to_string();
2640 assert!(
2641 e.contains("could not check for an open pull request"),
2642 "{e}"
2643 );
2644 assert!(git::branch_exists(&repo, "b").await.unwrap());
2645 }
2646
2647 #[tokio::test]
2648 async fn a_failed_attempt_removes_its_own_worktree_and_branch() {
2649 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2650 let wt = repo.parent().unwrap().join("bump");
2651 let e = attempt(&repo, false, Some(false)).await.unwrap_err();
2652 assert!(format!("{e:#}").contains("cargo build failed"));
2653 assert!(!wt.exists());
2654 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2655 }
2656
2657 #[tokio::test]
2658 async fn a_pushed_branch_survives_a_failed_attempt_and_the_reason_says_so() {
2659 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2660 let e = attempt(&repo, false, Some(true)).await.unwrap_err();
2661 let e = format!("{e:#}");
2662 assert!(
2663 e.contains("cargo build failed") && e.contains("pushed to origin"),
2664 "{e}"
2665 );
2666 assert!(git::branch_exists(&repo, "b").await.unwrap());
2667 }
2668
2669 #[tokio::test]
2670 async fn a_branch_left_by_a_half_done_worktree_add_is_removed() {
2671 let (_d, repo) = origin_with(&[("f", "x\n")]).await;
2674 let blocker = repo.parent().unwrap().join("blocker");
2675 tokio::fs::write(&blocker, "file").await.unwrap();
2676 let wt = blocker.join("bump");
2677 let r = release_attempt(
2678 &repo,
2679 "origin",
2680 "main",
2681 &wt,
2682 "b",
2683 |_| async { bail!("offline") },
2684 |_| async { Ok(()) },
2685 )
2686 .await;
2687 assert!(r.is_err());
2688 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2689 }
2690
2691 #[tokio::test]
2692 async fn the_retry_is_taken_at_most_once() {
2693 let (_d, repo) = with_branch(false).await;
2696 let blocker = repo.parent().unwrap().join("blocker");
2697 tokio::fs::write(&blocker, "file").await.unwrap();
2698 let wt = blocker.join("bump");
2699 let r = release_attempt(&repo, "origin", "main", &wt, "b", no_pr, |_| async {
2700 Ok(())
2701 })
2702 .await;
2703 assert!(r.is_err());
2704 assert!(!git::branch_exists(&repo, "b").await.unwrap());
2705 }
2706
2707 #[tokio::test]
2708 async fn base_has_cargo_toml_tells_rust_from_non_rust() {
2709 let (_d, rust) = origin_with(&[("Cargo.toml", "[package]\nversion = \"0.1.0\"\n")]).await;
2710 assert!(base_has_cargo_toml(&rust, "origin", "main").await.unwrap());
2711 let (_d2, other) = origin_with(&[("README.md", "hi\n")]).await;
2712 assert!(!base_has_cargo_toml(&other, "origin", "main").await.unwrap());
2713 assert!(base_has_cargo_toml(&other, "origin", "nope").await.is_err());
2715 }
2716
2717 #[tokio::test]
2718 async fn a_repo_without_cargo_toml_skips_with_one_event_and_no_lock() {
2719 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2720 let home = tempfile::tempdir().unwrap();
2721 let mut state = RunState::new(
2722 repo.clone(),
2723 "main".to_owned(),
2724 "0000000000000000000000000000000000000000".to_owned(),
2725 "task".to_owned(),
2726 Config::default(),
2727 );
2728 state.candidates.push(crate::run::Candidate {
2729 index: 0,
2730 label: 'A',
2731 agent: "x".to_owned(),
2732 branch: "main".to_owned(),
2733 worktree: repo.clone(),
2734 summary: String::new(),
2735 stat: String::new(),
2736 files: 1,
2737 commits: 1,
2738 empty: false,
2739 failed: None,
2740 verified_noop: None,
2741 duration_ms: 0,
2742 folded: false,
2743 });
2744 state.tally = Some(
2745 serde_json::from_value(serde_json::json!({
2746 "first_choice": {}, "borda": {}, "winner": "A",
2747 "unanimous_initial": true, "deliberated": false,
2748 "changed_votes": 0, "unanimous_final": true,
2749 }))
2750 .unwrap(),
2751 );
2752 after_merge_at(
2753 &mut state,
2754 "https://example.invalid/pull/1",
2755 Some(home.path()),
2756 None,
2757 )
2758 .await
2759 .expect("a non-Rust repository is not an error");
2760 let bumps: Vec<_> = state.events.iter().filter(|e| e.node == "bump").collect();
2761 assert_eq!(bumps.len(), 1, "{:?}", state.events);
2762 assert_eq!(
2763 bumps[0].message,
2764 "release bump: no Cargo.toml on the base branch; release bumps are Rust-only, skipping"
2765 );
2766 assert!(
2767 std::fs::read_dir(home.path()).unwrap().next().is_none(),
2768 "no marker and no lock may be created"
2769 );
2770 }
2771
2772 fn winner_state(repo: &Path, base: &str) -> RunState {
2773 let mut state = RunState::new(
2774 repo.to_path_buf(),
2775 base.to_owned(),
2776 "0000000000000000000000000000000000000000".to_owned(),
2777 "task".to_owned(),
2778 Config::default(),
2779 );
2780 state.candidates.push(crate::run::Candidate {
2781 index: 0,
2782 label: 'A',
2783 agent: "x".to_owned(),
2784 branch: "main".to_owned(),
2785 worktree: repo.to_path_buf(),
2786 summary: String::new(),
2787 stat: String::new(),
2788 files: 1,
2789 commits: 1,
2790 empty: false,
2791 failed: None,
2792 verified_noop: None,
2793 duration_ms: 0,
2794 folded: false,
2795 });
2796 state.tally = Some(
2797 serde_json::from_value(serde_json::json!({
2798 "first_choice": {}, "borda": {}, "winner": "A",
2799 "unanimous_initial": true, "deliberated": false,
2800 "changed_votes": 0, "unanimous_final": true,
2801 }))
2802 .unwrap(),
2803 );
2804 state
2805 }
2806
2807 #[tokio::test]
2808 async fn a_real_failure_without_a_pr_raises_one_notice_and_a_retry_folds_into_it() {
2809 let (_d, repo) =
2810 origin_with(&[("Cargo.toml", "[package]\nname=\"x\"\nversion=\"0.1.0\"\n")]).await;
2811 let home = tempfile::tempdir().unwrap();
2812 let store = Notices::at(home.path().join("notifications"));
2813 let mut state = winner_state(&repo, "nope");
2815 let url = "https://example.invalid/pull/1";
2816 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2817 .await
2818 .expect_err("an unresolvable base is a failure");
2819 let listed = store.list();
2820 assert_eq!(listed.len(), 1, "{listed:?}");
2821 assert!(
2822 listed[0].message.contains("did not run"),
2823 "{}",
2824 listed[0].message
2825 );
2826 assert!(
2827 listed[0].message.contains("Cargo.toml"),
2828 "{}",
2829 listed[0].message
2830 );
2831 assert!(state.events.iter().any(|e| e.node == "bump"));
2832 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2833 .await
2834 .expect_err("still failing");
2835 let listed = store.list();
2836 assert_eq!(listed.len(), 1, "{listed:?}");
2837 assert_eq!(listed[0].count, 2);
2838 }
2839
2840 #[tokio::test]
2841 async fn not_eligible_cases_raise_no_notice() {
2842 let home = tempfile::tempdir().unwrap();
2843 let store = Notices::at(home.path().join("notifications"));
2844 let url = "https://example.invalid/pull/1";
2845 let (_d, repo) = origin_with(&[("README.md", "hi\n")]).await;
2847 let mut state = winner_state(&repo, "main");
2848 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2849 .await
2850 .unwrap();
2851 let mut state = winner_state(&repo, "nope");
2853 state.config.merge.release_bump = false;
2854 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2855 .await
2856 .unwrap();
2857 let mut state = winner_state(&repo, "nope");
2859 state.tally = None;
2860 after_merge_at(&mut state, url, Some(home.path()), Some(&store))
2861 .await
2862 .unwrap();
2863 assert!(store.list().is_empty(), "{:?}", store.list());
2864 }
2865
2866 #[test]
2867 fn the_no_pr_notice_follows_the_configured_language() {
2868 let dir = tempfile::tempdir().unwrap();
2869 let store = Notices::at(dir.path().join("notifications"));
2870 let mut state = merged_state();
2871 state.config.graph.language = "ja".to_owned();
2872 let (n, _) = surface_problem(&mut state, &store, None, Some("0.2.0"), "boom").unwrap();
2873 assert!(n.message.contains("実行されませんでした"), "{}", n.message);
2874 assert!(n.message.contains("boom") && n.message.contains("v0.2.0"));
2875 }
2876
2877 #[test]
2878 fn a_failed_escalation_points_at_the_pending_pr_without_commenting() {
2879 let dir = tempfile::tempdir().unwrap();
2880 let store = Notices::at(dir.path().join("notifications"));
2881 let mut state = merged_state();
2882 let url = "https://example.invalid/pull/9";
2883 let (n, comment) = surface_problem_in(
2884 &mut state,
2885 &store,
2886 Some(url),
2887 Some("0.3.0"),
2888 "push failed",
2889 true,
2890 )
2891 .unwrap();
2892 assert!(comment.is_none());
2893 assert!(
2894 n.message.contains(url) && n.message.contains("v0.3.0"),
2895 "{}",
2896 n.message
2897 );
2898 assert!(!n.message.contains("open the release pull request by hand"));
2899 assert!(matches!(n.link, Some(Link::Url { .. })));
2900 }
2901
2902 #[test]
2903 fn a_failure_notice_names_the_pending_pr_and_the_first_line_of_the_cause() {
2904 let err = anyhow!("outer context").context("cargo build failed\nsecond line");
2905 let with_pr = Progress {
2906 version: Some("0.2.0".into()),
2907 pending_pr: Some("https://example.invalid/pull/9".into()),
2908 };
2909 let (v, reason) = notice_for_failure(&with_pr, &err);
2910 assert_eq!(v.as_deref(), Some("0.2.0"));
2911 assert_eq!(reason, "cargo build failed");
2912 let (v, reason) = notice_for_failure(&Progress::default(), &err);
2913 assert_eq!(v, None);
2914 assert_eq!(reason, "cargo build failed");
2915 }
2916
2917 const NO_RULES: &str = "gh pr merge --auto: GraphQL: Pull request Branch does not have \
2918 required protected branch rules (enablePullRequestAutoMerge)";
2919
2920 fn merged_state() -> RunState {
2921 run::pin_test_home();
2924 let mut s = RunState::new(
2925 PathBuf::from("/no/such/repo"),
2926 "main".to_owned(),
2927 "0000000000000000000000000000000000000000".to_owned(),
2928 "task".to_owned(),
2929 Config::default(),
2930 );
2931 s.status = RunStatus::Merged;
2932 s
2933 }
2934
2935 #[cfg(unix)]
2936 fn decision_cli(id: &str, reply: &str, exit: &str) -> AgentSpec {
2937 AgentSpec {
2938 id: id.into(),
2939 kind: crate::config::AgentKind::Command,
2940 model: None,
2941 command: vec![
2942 "sh".into(),
2943 "-c".into(),
2944 concat!(
2945 "if [ \"$STATUS\" = timeout ]; then exec sleep 5; fi; ",
2946 "printf '%s' \"$REPLY\"; exit \"$STATUS\""
2947 )
2948 .into(),
2949 ],
2950 extra_args: Vec::new(),
2951 env: std::collections::BTreeMap::from([
2952 ("REPLY".into(), reply.into()),
2953 ("STATUS".into(), exit.into()),
2954 ]),
2955 prompt_delivery: None,
2956 }
2957 }
2958
2959 #[cfg(unix)]
2960 fn decision_state(agents: Vec<AgentSpec>, preferences: &[&str]) -> RunState {
2961 let mut state = merged_state();
2962 state.config.agents = agents;
2963 state.config.roles.chatter = Some(crate::config::AgentChoice::Chain(
2964 preferences.iter().map(|s| (*s).to_owned()).collect(),
2965 ));
2966 state
2967 }
2968
2969 #[cfg(unix)]
2970 const VALID_DECISION: &str = r#"{"level":"minor","reason":"Adds a feature."}"#;
2971
2972 #[cfg(unix)]
2973 #[tokio::test]
2974 async fn decision_failover_handles_each_unusable_cli_outcome() {
2975 for (reply, exit, expected) in [
2976 (
2977 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2978 "1",
2979 "rate/quota limit",
2980 ),
2981 (
2982 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached","level":"minor","reason":"Looks valid."}"#,
2983 "0",
2984 "rate/quota limit",
2985 ),
2986 (
2987 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached","level":"minor","reason":"Looks valid."}"#,
2988 "0",
2989 "rate/quota limit",
2990 ),
2991 ("", "0", "empty output"),
2992 (
2993 "",
2994 "spawn",
2995 "CLI invocation failed; inspect attempt artifacts",
2996 ),
2997 ("not JSON", "0", "invalid decision JSON or missing reason"),
2998 (VALID_DECISION, "1", "CLI exit Some(1)"),
2999 ("", "timeout", "timed out"),
3000 ] {
3001 let dir = tempfile::tempdir().unwrap();
3002 let mut state = decision_state(
3003 vec![
3004 decision_cli("first", reply, exit),
3005 decision_cli("second", VALID_DECISION, "0"),
3006 ],
3007 &["first", "second"],
3008 );
3009 if exit == "spawn" {
3010 state.config.agents[0].command = vec!["magi-test-no-such-decision-cli".into()];
3011 }
3012 let (spec, seat, decision) =
3013 ask_decision(&mut state, dir.path(), "decide", Duration::from_millis(200))
3014 .await
3015 .unwrap();
3016 assert_eq!(spec.id, "second");
3017 assert_eq!(decision.level, BumpLevel::Minor);
3018 assert_eq!(state.bump_decision_attempts.len(), 2);
3019 assert_eq!(state.bump_decision_attempts[0].outcome, expected);
3020 assert_eq!(state.handovers.len(), 1);
3021 assert_eq!(state.handovers[0].to, "second");
3022 assert_ne!(
3023 seat.claude_session,
3024 SeatState::new("bump", "first", state.seed).claude_session
3025 );
3026 let report = crate::report::run(&state);
3027 assert!(report.contains(expected), "{report}");
3028 assert!(report.contains("second: valid decision"), "{report}");
3029 assert!(state.release_bump.is_none());
3030 let artifacts = agent::artifacts_dir(&state.dir());
3031 if exit != "spawn" {
3032 assert!(artifacts.join("bump-decision-1.out").exists());
3033 }
3034 assert!(artifacts.join("bump-decision-2.out").exists());
3035 }
3036 }
3037
3038 #[cfg(unix)]
3039 #[tokio::test]
3040 async fn decision_chain_respects_preferences_and_stops_on_first_success() {
3041 let dir = tempfile::tempdir().unwrap();
3042 let mut state = decision_state(
3043 vec![
3044 decision_cli("first", "bad", "1"),
3045 decision_cli("second", VALID_DECISION, "0"),
3046 ],
3047 &["missing", "second", "second", "first"],
3048 );
3049 let (spec, _, _) = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(1))
3050 .await
3051 .unwrap();
3052 assert_eq!(spec.id, "second");
3053 assert_eq!(state.bump_decision_attempts.len(), 1);
3054 assert!(state.handovers.is_empty());
3055 }
3056
3057 #[cfg(unix)]
3058 #[tokio::test]
3059 async fn decision_chain_consumes_same_failure_classes_and_preserves_unset_policy() {
3060 let dir = tempfile::tempdir().unwrap();
3061 let mut state = decision_state(
3062 vec![
3063 decision_cli("first", "bad", "0"),
3064 decision_cli("second", "bad", "0"),
3065 decision_cli("third", VALID_DECISION, "0"),
3066 ],
3067 &["first", "second", "third"],
3068 );
3069 let (spec, _, _) = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(2))
3070 .await
3071 .unwrap();
3072 assert_eq!(spec.id, "third");
3073 assert_eq!(state.bump_decision_attempts.len(), 3);
3074 assert_eq!(state.handovers.len(), 2);
3075 state.config.roles.chatter = None;
3077 let err = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(2))
3078 .await
3079 .unwrap_err();
3080 assert!(err.to_string().contains("exhausted 1 eligible agent(s)"));
3081 assert_eq!(state.bump_decision_attempts.len(), 4);
3082 }
3083
3084 #[cfg(unix)]
3085 #[tokio::test]
3086 async fn decision_reason_rewrite_failure_preserves_the_selected_level() {
3087 let dir = tempfile::tempdir().unwrap();
3088 for reply in ["invalid", r#"{"level":"patch","reason":"Fixes a defect."}"#] {
3089 let mut state = merged_state();
3090 state.config.graph.github_text_guard = true;
3091 state.config.graph.github_language = "en".into();
3092 let spec = decision_cli("chosen", reply, "0");
3093 let mut seat = SeatState::new("bump", &spec.id, state.seed);
3094 let mut decision =
3095 parse_decision(r#"{"level":"minor","reason":"新しい機能を追加しました。"}"#)
3096 .unwrap();
3097 guard_reason(
3098 &mut state,
3099 dir.path(),
3100 "decide",
3101 &spec,
3102 &mut seat,
3103 &mut decision,
3104 )
3105 .await;
3106 assert_eq!(decision.level, BumpLevel::Minor);
3107 assert_eq!(decision.reason, "The merged change requires a release.");
3108 assert!(state.handovers.is_empty());
3109 assert!(state.bump_decision_attempts.is_empty());
3110 }
3111 }
3112
3113 #[cfg(unix)]
3114 #[tokio::test]
3115 async fn decision_exhaustion_is_bounded_reported_and_leaves_no_release_outcome() {
3116 let dir = tempfile::tempdir().unwrap();
3117 let mut state = decision_state(
3118 vec![
3119 decision_cli("first", "bad", "0"),
3120 decision_cli("second", "", "0"),
3121 ],
3122 &["first", "first", "second", "second"],
3123 );
3124 let err = ask_decision(&mut state, dir.path(), "decide", Duration::from_secs(1))
3125 .await
3126 .unwrap_err()
3127 .to_string();
3128 assert!(err.contains("exhausted 2 eligible agent(s)"), "{err}");
3129 assert!(err.contains("first: invalid decision JSON"), "{err}");
3130 assert!(err.contains("second: empty output"), "{err}");
3131 assert!(err.contains("[roles] chatter"), "{err}");
3132 assert_eq!(state.bump_decision_attempts.len(), 2);
3133 assert_eq!(state.handovers.len(), 1);
3134 assert!(state.release_bump.is_none());
3135 let report = crate::report::run(&state);
3136 assert!(report.contains("first: invalid decision JSON"));
3137 assert!(report.contains("second: empty output"));
3138 let restored: RunState =
3139 serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap();
3140 assert_eq!(restored.bump_decision_attempts.len(), 2);
3141 }
3142
3143 #[test]
3144 fn the_known_automerge_refusal_names_branch_protection() {
3145 assert!(automerge_hint(NO_RULES).contains("branch protection with required"));
3146 let other = automerge_hint("gh: network unreachable");
3147 assert!(!other.contains("branch protection"), "{other}");
3148 let body = automerge_failure_comment(NO_RULES);
3149 assert!(body.contains("enablePullRequestAutoMerge"), "{body}");
3150 assert!(body.contains("Action required"), "{body}");
3151 }
3152
3153 const CLEAN: &str = "gh pr merge --auto: GraphQL: Pull request Pull request is in clean \
3154 status (enablePullRequestAutoMerge)";
3155
3156 #[test]
3157 fn clean_status_refusal_is_matched_narrowly() {
3158 assert!(is_clean_status_refusal(CLEAN));
3159 assert!(!is_clean_status_refusal(NO_RULES));
3160 assert!(!is_clean_status_refusal("gh: network unreachable"));
3161 assert!(!is_clean_status_refusal("Pull request is in clean status"));
3162 assert!(automerge_hint(CLEAN).contains("already green"));
3163 }
3164
3165 #[test]
3166 fn the_direct_merge_argv_matches_the_land_flags() {
3167 let a = bump_merge_argv("https://github.com/o/r/pull/9", "chore: release v1.0.0");
3168 let l = land::merge_argv(9, "chore: release v1.0.0");
3169 assert_eq!(a[..2], l[..2]);
3170 assert_eq!(a[3..], l[3..]);
3171 assert_eq!(a[2], "https://github.com/o/r/pull/9");
3172 }
3173
3174 #[test]
3175 fn a_direct_merge_is_judged_by_the_forge_not_the_exit_code() {
3176 let argv = bump_merge_argv("u", "t");
3177 let merged = |o: &AutomergeOutcome| matches!(o, AutomergeOutcome::MergedDirectly { .. });
3178 assert!(merged(&resolve_direct_merge(CLEAN, &argv, true, "", None)));
3179 let detached = "not on any branch";
3180 assert!(merged(&resolve_direct_merge(
3181 CLEAN,
3182 &argv,
3183 false,
3184 detached,
3185 Some(PrLifecycle::Merged)
3186 )));
3187 for after in [Some(PrLifecycle::Open), None] {
3188 let o = resolve_direct_merge(CLEAN, &argv, false, "boom", after);
3189 match o {
3190 AutomergeOutcome::Failed { reason } => {
3191 assert!(
3192 reason.contains("clean status") && reason.contains("boom"),
3193 "{reason}"
3194 )
3195 }
3196 other => panic!("expected Failed, got {other:?}"),
3197 }
3198 }
3199 }
3200
3201 #[test]
3202 fn a_local_mode_bump_reads_back_and_old_records_are_not_local() {
3203 let old: run::ReleaseBump =
3204 serde_json::from_str(r#"{"pr_url":"u","automerge_enabled":true}"#).unwrap();
3205 assert!(!old.local);
3206 let mut state = merged_state();
3207 state.release_bump = Some(run::ReleaseBump {
3208 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
3209 version: Some("1.0.0".to_owned()),
3210 local: true,
3211 ..run::ReleaseBump::default()
3212 });
3213 assert!(!state.needs_attention());
3215 let text = crate::report::run(&state);
3216 assert!(text.contains("release.mode = local"), "{text}");
3217 assert!(!text.contains("FAILED"), "{text}");
3218 }
3219
3220 #[test]
3221 fn a_directly_merged_bump_is_not_reported_as_pending_or_failed() {
3222 let mut state = merged_state();
3223 state.release_bump = Some(run::ReleaseBump {
3224 pr_url: Some("https://github.com/o/r/pull/9".to_owned()),
3225 version: Some("1.0.0".to_owned()),
3226 merged_directly: true,
3227 ..run::ReleaseBump::default()
3228 });
3229 assert!(!state.needs_attention());
3230 let text = crate::report::run(&state);
3231 assert!(text.contains("merged directly"), "{text}");
3232 assert!(!text.contains("FAILED"), "{text}");
3233 }
3234
3235 #[test]
3236 fn an_automerge_failure_is_recorded_shown_and_filed_and_survives_settling() {
3237 let dir = tempfile::tempdir().unwrap();
3238 let store = Notices::at(dir.path().join("notifications"));
3239 let questions = ask::Questions::at(dir.path().join("questions"));
3240 let mut state = merged_state();
3241 let url = "https://github.com/o/r/pull/35";
3242
3243 let (n, comment) =
3244 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
3245
3246 let comment = comment.expect("a PR was opened, so it gets a comment");
3248 assert!(comment.contains("branch protection"), "{comment}");
3249
3250 assert_eq!(state.status, RunStatus::Merged);
3252 assert!(state.needs_attention());
3253 let text = crate::report::run(&state);
3254 assert!(text.contains("release bump"), "{text}");
3255 assert!(text.contains("FAILED"), "{text}");
3256 assert!(text.contains(url), "{text}");
3257 assert!(text.contains("action required"), "{text}");
3258 assert!(crate::report::line(&state).contains("release needs a human"));
3259
3260 assert_eq!(n.severity, Severity::Error);
3262 assert_eq!(
3263 n.link,
3264 Some(Link::Url {
3265 url: url.to_owned()
3266 })
3267 );
3268 assert_eq!(store.list().len(), 1);
3269 assert!(questions.open_for(&state.id).is_empty());
3270
3271 surface_problem(&mut state, &store, Some(url), Some("0.8.0"), NO_RULES).unwrap();
3273 let listed = store.list();
3274 assert_eq!(listed.len(), 1);
3275 assert_eq!(listed[0].count, 2);
3276 }
3277
3278 #[test]
3279 fn a_bump_that_never_ran_is_surfaced_without_a_pr_comment() {
3280 let dir = tempfile::tempdir().unwrap();
3281 let store = Notices::at(dir.path().join("notifications"));
3282 let mut state = merged_state();
3283 let (n, comment) = surface_problem(&mut state, &store, None, None, "no agent").unwrap();
3284 assert!(comment.is_none());
3285 assert!(matches!(n.link, Some(Link::Run { .. })));
3286 assert!(state.needs_attention());
3287 }
3288
3289 #[test]
3290 fn version_parses_and_bumps_each_digit() {
3291 let v = Version::parse("0.4.0").unwrap();
3292 assert_eq!(
3293 v,
3294 Version {
3295 major: 0,
3296 minor: 4,
3297 patch: 0
3298 }
3299 );
3300
3301 assert_eq!(v.bump(BumpLevel::Major).to_string(), "1.0.0");
3302 assert_eq!(v.bump(BumpLevel::Minor).to_string(), "0.5.0");
3303 assert_eq!(v.bump(BumpLevel::Patch).to_string(), "0.4.1");
3304 }
3305
3306 #[test]
3307 fn version_tolerates_a_prerelease_suffix_on_patch() {
3308 let v = Version::parse("1.2.3-rc1").unwrap();
3309 assert_eq!(
3310 v,
3311 Version {
3312 major: 1,
3313 minor: 2,
3314 patch: 3
3315 }
3316 );
3317 }
3318
3319 #[test]
3320 fn version_rejects_garbage() {
3321 assert!(Version::parse("not-a-version").is_err());
3322 assert!(Version::parse("1.2").is_err());
3323 }
3324
3325 #[test]
3326 fn decision_parses_each_level() {
3327 for (json, level) in [
3328 (
3329 r#"{"level":"major","reason":"drops a config key"}"#,
3330 BumpLevel::Major,
3331 ),
3332 (
3333 r#"{"level":"minor","reason":"adds a new flag"}"#,
3334 BumpLevel::Minor,
3335 ),
3336 (
3337 r#"{"level":"patch","reason":"fixes a race"}"#,
3338 BumpLevel::Patch,
3339 ),
3340 ] {
3341 let decision = parse_decision(json).unwrap();
3342 assert_eq!(decision.level, level);
3343 assert!(!decision.reason.is_empty());
3344 }
3345 }
3346
3347 #[test]
3348 fn decision_wrapped_in_a_fence_and_prose_still_parses() {
3349 let text = "Here is my call.\n\n```json\n{\"level\":\"minor\",\"reason\":\"new HTTP route\"}\n```\n\nDone.";
3350 let decision = parse_decision(text).unwrap();
3351 assert_eq!(decision.level, BumpLevel::Minor);
3352 assert_eq!(decision.reason, "new HTTP route");
3353 }
3354
3355 #[test]
3356 fn a_broken_reply_is_an_error_not_a_default() {
3357 assert!(parse_decision("I decline to answer.").is_err());
3358 assert!(parse_decision(r#"{"level":"huge","reason":"go big"}"#).is_err());
3359 assert!(
3360 parse_decision(r#"{"level":"patch","reason":""}"#).is_err(),
3361 "an empty reason must not pass either"
3362 );
3363 assert!(
3364 parse_decision(r#"{"level":"patch"}"#).is_err(),
3365 "a reply with no reason at all must not pass"
3366 );
3367 }
3368
3369 #[test]
3370 fn prompt_states_the_zero_x_rule_and_the_tie_break() {
3371 let prompt = decision_prompt(
3372 "feat: add a phone endpoint",
3373 "add POST /api/widgets",
3374 "1 file changed, 10 insertions(+)",
3375 &["src/web.rs".to_owned()],
3376 "0.8.0",
3377 );
3378 assert!(prompt.contains("0.8.0"), "the current version is stated");
3379 assert!(
3380 prompt.contains("below `1.0.0`")
3381 && prompt.contains("`minor` is the digit that carries a breaking change"),
3382 "the 0.x rule must be explicit: {prompt}"
3383 );
3384 assert!(
3385 prompt.contains("choose the larger"),
3386 "the tie-break toward the bigger digit must be explicit: {prompt}"
3387 );
3388 }
3389
3390 #[test]
3391 fn release_only_diffs_are_recognised() {
3392 assert!(is_release_only(&["Cargo.toml".to_owned()]));
3393 assert!(is_release_only(&[
3394 "Cargo.toml".to_owned(),
3395 "Cargo.lock".to_owned()
3396 ]));
3397 assert!(!is_release_only(&[]));
3398 assert!(!is_release_only(&[
3399 "Cargo.toml".to_owned(),
3400 "src/main.rs".to_owned()
3401 ]));
3402 }
3403
3404 #[test]
3405 fn cargo_version_rewrite_touches_only_the_package_table() {
3406 let toml = "\
3407[package]\n\
3408# a comment mentioning version on purpose\n\
3409name = \"magi-cli\"\n\
3410version = \"0.8.0\"\n\
3411edition = \"2024\"\n\
3412\n\
3413[dependencies]\n\
3414foo = { version = \"1.2.3\" }\n";
3415 let out = rewrite_cargo_version(toml, "0.9.0").unwrap();
3416 assert!(out.contains("version = \"0.9.0\""));
3417 assert!(
3418 out.contains("foo = { version = \"1.2.3\" }"),
3419 "a dependency's own version pin must survive: {out}"
3420 );
3421 assert!(
3422 out.contains("# a comment mentioning version on purpose"),
3423 "unrelated lines, comments included, must be byte-for-byte preserved: {out}"
3424 );
3425 assert_eq!(
3426 out.lines().count(),
3427 toml.lines().count(),
3428 "the rewrite replaces one line, it does not add or remove any"
3429 );
3430 }
3431
3432 #[test]
3433 fn cargo_version_rewrite_fails_without_a_package_table() {
3434 let toml = "[dependencies]\nfoo = \"1\"\n";
3435 assert!(rewrite_cargo_version(toml, "1.0.0").is_err());
3436 }
3437
3438 #[test]
3444 fn cargo_version_rewrite_falls_back_to_workspace_package_without_a_package_table() {
3445 let toml = "\
3446[workspace]\n\
3447members = [\"crates/a\", \"crates/b\"]\n\
3448\n\
3449[workspace.package]\n\
3450version = \"0.45.18\"\n\
3451edition = \"2024\"\n\
3452\n\
3453[workspace.dependencies]\n\
3454foo = { version = \"1.2.3\" }\n";
3455 let out = rewrite_cargo_version(toml, "0.45.19").unwrap();
3456 assert!(out.contains("version = \"0.45.19\""));
3457 assert!(
3458 out.contains("foo = { version = \"1.2.3\" }"),
3459 "a workspace dependency's own version pin must survive: {out}"
3460 );
3461 assert_eq!(
3462 out.lines().count(),
3463 toml.lines().count(),
3464 "the rewrite replaces one line, it does not add or remove any"
3465 );
3466 }
3467
3468 #[test]
3472 fn cargo_version_rewrite_bumps_an_internal_workspace_dependency_pin() {
3473 let toml = "\
3474[workspace]\n\
3475members = [\"crates/kanade-shared\"]\n\
3476\n\
3477[workspace.package]\n\
3478version = \"0.48.2\"\n\
3479\n\
3480[workspace.dependencies]\n\
3481kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n";
3482 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3483 assert!(out.contains("[workspace.package]\nversion = \"0.48.3\"\n"));
3484 assert!(
3485 out.contains(
3486 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3487 ),
3488 "the internal pin must move with the workspace version: {out}"
3489 );
3490 }
3491
3492 #[test]
3494 fn cargo_version_rewrite_bumps_an_internal_pin_with_version_before_path() {
3495 let toml = "\
3496[workspace.package]\n\
3497version = \"1.0.0\"\n\
3498\n\
3499[workspace.dependencies]\n\
3500inner = { version = \"1.0.0\", path = \"crates/inner\" }\n";
3501 let out = rewrite_cargo_version(toml, "1.0.1").unwrap();
3502 assert!(out.contains("inner = { version = \"1.0.1\", path = \"crates/inner\" }"));
3503 }
3504
3505 #[test]
3508 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_table_form() {
3509 let toml = "\
3510[workspace.package]\n\
3511version = \"2.3.0\"\n\
3512\n\
3513[workspace.dependencies.inner]\n\
3514path = \"crates/inner\"\n\
3515version = \"2.3.0\"\n";
3516 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3517 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3518 assert!(out.contains(
3519 "[workspace.dependencies.inner]\npath = \"crates/inner\"\nversion = \"2.4.0\"\n"
3520 ));
3521 }
3522
3523 #[test]
3528 fn cargo_version_rewrite_bumps_an_internal_pin_in_dotted_key_form() {
3529 let toml = "\
3530[workspace.package]\n\
3531version = \"2.3.0\"\n\
3532\n\
3533[workspace.dependencies]\n\
3534inner.path = \"crates/inner\"\n\
3535inner.version = \"2.3.0\"\n";
3536 let out = rewrite_cargo_version(toml, "2.4.0").unwrap();
3537 assert!(out.contains("[workspace.package]\nversion = \"2.4.0\"\n"));
3538 assert!(out.contains("inner.path = \"crates/inner\"\ninner.version = \"2.4.0\"\n"));
3539 }
3540
3541 #[test]
3544 fn cargo_version_rewrite_leaves_a_path_only_workspace_dependency_untouched() {
3545 let toml = "\
3546[workspace.package]\n\
3547version = \"0.1.0\"\n\
3548\n\
3549[workspace.dependencies]\n\
3550internal-only = { path = \"crates/internal-only\" }\n";
3551 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3552 assert!(out.contains("internal-only = { path = \"crates/internal-only\" }"));
3553 }
3554
3555 #[test]
3559 fn cargo_version_rewrite_leaves_an_external_dependency_untouched() {
3560 let toml = "\
3561[workspace.package]\n\
3562version = \"0.1.0\"\n\
3563\n\
3564[workspace.dependencies]\n\
3565serde = { version = \"1\", features = [\"derive\"] }\n\
3566inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3567 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3568 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3569 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3570 }
3571
3572 #[test]
3576 fn cargo_version_rewrite_without_workspace_dependencies_table_still_bumps_package() {
3577 let toml = "[workspace.package]\nversion = \"0.9.0\"\nedition = \"2024\"\n";
3578 let out = rewrite_cargo_version(toml, "0.10.0").unwrap();
3579 assert_eq!(
3580 out,
3581 "[workspace.package]\nversion = \"0.10.0\"\nedition = \"2024\"\n"
3582 );
3583 }
3584
3585 #[test]
3588 fn cargo_version_rewrite_handles_a_kanade_shaped_workspace_dependencies_table() {
3589 let toml = "\
3590[workspace.package]\n\
3591version = \"0.48.2\"\n\
3592\n\
3593[workspace.dependencies]\n\
3594anyhow = { version = \"1\" }\n\
3595serde = { version = \"1\", features = [\"derive\"] }\n\
3596kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.2\" }\n\
3597kanade-core = { path = \"crates/kanade-core\", version = \"0.48.2\" }\n\
3598kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }\n";
3599 let out = rewrite_cargo_version(toml, "0.48.3").unwrap();
3600 assert!(out.contains("anyhow = { version = \"1\" }"));
3601 assert!(out.contains("serde = { version = \"1\", features = [\"derive\"] }"));
3602 assert!(
3603 out.contains(
3604 "kanade-shared = { path = \"crates/kanade-shared\", version = \"0.48.3\" }"
3605 )
3606 );
3607 assert!(
3608 out.contains("kanade-core = { path = \"crates/kanade-core\", version = \"0.48.3\" }")
3609 );
3610 assert!(out.contains("kanade-internal-tool = { path = \"crates/kanade-internal-tool\" }"));
3611 }
3612
3613 #[test]
3617 fn cargo_version_rewrite_bails_on_an_unrepresentable_inline_table() {
3618 let toml = "\
3619[workspace.package]\n\
3620version = \"0.1.0\"\n\
3621\n\
3622[workspace.dependencies]\n\
3623inner = { path = \"crates/inner\",\n\
3624 version = \"0.1.0\" }\n";
3625 assert!(rewrite_cargo_version(toml, "0.2.0").is_err());
3626 }
3627
3628 #[test]
3631 fn cargo_version_rewrite_recognises_a_commented_workspace_dependencies_header() {
3632 let toml = "\
3633[workspace.package]\n\
3634version = \"0.1.0\"\n\
3635\n\
3636[workspace.dependencies] # internal pins\n\
3637inner = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3638 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3639 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3640 }
3641
3642 #[test]
3645 fn cargo_version_rewrite_bumps_a_quoted_dependency_key() {
3646 let toml = "\
3647[workspace.package]\n\
3648version = \"0.1.0\"\n\
3649\n\
3650[workspace.dependencies]\n\
3651\"inner\" = { path = \"crates/inner\", version = \"0.1.0\" }\n";
3652 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3653 assert!(out.contains("\"inner\" = { path = \"crates/inner\", version = \"0.2.0\" }"));
3654 }
3655
3656 #[test]
3660 fn cargo_version_rewrite_bumps_a_literal_string_version_pin() {
3661 let toml = "\
3662[workspace.package]\n\
3663version = \"0.1.0\"\n\
3664\n\
3665[workspace.dependencies]\n\
3666inner = { path = 'crates/inner', version = '0.1.0' }\n";
3667 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3668 assert!(out.contains("inner = { path = 'crates/inner', version = '0.2.0' }"));
3669 }
3670
3671 #[test]
3677 fn cargo_version_rewrite_does_not_require_the_whole_file_to_parse() {
3678 let toml = "\
3679[workspace.package]\n\
3680version = \"0.1.0\"\n\
3681\n\
3682[workspace.dependencies]\n\
3683inner = { path = \"crates/inner\", version = \"0.1.0\" }\n\
3684\n\
3685[workspace.package]\n\
3686edition = \"2024\"\n";
3687 let out = rewrite_cargo_version(toml, "0.2.0").unwrap();
3688 assert!(out.contains("inner = { path = \"crates/inner\", version = \"0.2.0\" }"));
3689 assert!(
3690 toml::from_str::<toml::Value>(toml).is_err(),
3691 "the fixture itself must be invalid as a whole file, or this test proves nothing"
3692 );
3693 }
3694
3695 #[test]
3696 fn current_version_prefers_the_package_table_when_both_exist() {
3697 let toml = "[workspace.package]\nversion = \"9.9.9\"\n\n[package]\nversion = \"0.8.0\"\n";
3698 assert_eq!(current_version(toml).unwrap(), "0.8.0");
3699 }
3700
3701 #[test]
3704 fn current_version_falls_back_to_workspace_package_without_a_package_table() {
3705 let toml = "\
3706[workspace]\n\
3707members = [\"crates/a\", \"crates/b\"]\n\
3708\n\
3709[workspace.package]\n\
3710version = \"0.45.18\"\n";
3711 assert_eq!(current_version(toml).unwrap(), "0.45.18");
3712 }
3713
3714 #[test]
3715 fn coalesce_proceeds_with_nothing_pending() {
3716 assert_eq!(coalesce(None, "0.8.0").unwrap(), Coalesce::Proceed);
3717 }
3718
3719 fn test_pending(target_version: &str, level: BumpLevel) -> PendingBump {
3722 PendingBump {
3723 target_version: target_version.to_owned(),
3724 level,
3725 branch: format!("chore/release-v{target_version}"),
3726 pr_url: "https://example.invalid/pull/9".to_owned(),
3727 }
3728 }
3729
3730 #[test]
3731 fn coalesce_skips_while_the_pending_target_is_still_ahead() {
3732 let pending = test_pending("0.9.0", BumpLevel::Minor);
3733 assert_eq!(
3734 coalesce(Some(&pending), "0.8.0").unwrap(),
3735 Coalesce::Skip {
3736 target_version: "0.9.0".to_owned()
3737 }
3738 );
3739 }
3740
3741 #[test]
3742 fn coalesce_treats_a_landed_or_superseded_pending_bump_as_stale() {
3743 let pending = test_pending("0.9.0", BumpLevel::Minor);
3744 assert_eq!(
3746 coalesce(Some(&pending), "0.9.0").unwrap(),
3747 Coalesce::Proceed
3748 );
3749 assert_eq!(
3751 coalesce(Some(&pending), "1.0.0").unwrap(),
3752 Coalesce::Proceed
3753 );
3754 }
3755
3756 #[test]
3757 fn pending_action_escalates_only_for_a_more_severe_decision() {
3758 assert_eq!(
3759 pending_action(BumpLevel::Patch, BumpLevel::Patch),
3760 PendingAction::AlreadyCovered
3761 );
3762 assert_eq!(
3763 pending_action(BumpLevel::Patch, BumpLevel::Minor),
3764 PendingAction::Escalate
3765 );
3766 assert_eq!(
3767 pending_action(BumpLevel::Patch, BumpLevel::Major),
3768 PendingAction::Escalate
3769 );
3770 assert_eq!(
3771 pending_action(BumpLevel::Minor, BumpLevel::Patch),
3772 PendingAction::AlreadyCovered
3773 );
3774 assert_eq!(
3775 pending_action(BumpLevel::Major, BumpLevel::Minor),
3776 PendingAction::AlreadyCovered
3777 );
3778 assert_eq!(
3779 pending_action(BumpLevel::Major, BumpLevel::Major),
3780 PendingAction::AlreadyCovered
3781 );
3782 }
3783
3784 #[test]
3785 fn pr_state_parsing_reads_open_and_not_open() {
3786 assert!(parse_pr_state(r#"{"state":"OPEN"}"#).unwrap());
3787 assert!(!parse_pr_state(r#"{"state":"CLOSED"}"#).unwrap());
3788 assert!(!parse_pr_state(r#"{"state":"MERGED"}"#).unwrap());
3789 }
3790
3791 #[test]
3792 fn a_lock_is_exclusive_until_dropped() {
3793 let dir = tempfile::tempdir().unwrap();
3794 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3795 let first = MarkerLock::acquire(&marker)
3796 .unwrap()
3797 .expect("first attempt takes the lock");
3798 assert!(
3799 MarkerLock::acquire(&marker).unwrap().is_none(),
3800 "a second attempt must be refused while the first holds it"
3801 );
3802 drop(first);
3803 assert!(
3804 MarkerLock::acquire(&marker).unwrap().is_some(),
3805 "dropping the guard releases the lock for the next attempt"
3806 );
3807 }
3808
3809 #[test]
3810 fn a_stale_lock_is_reclaimed() {
3811 let dir = tempfile::tempdir().unwrap();
3812 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3813 let lock_path = marker.with_extension("lock");
3814 std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
3815 std::fs::write(&lock_path, b"").unwrap();
3816 let old = std::time::SystemTime::now() - LOCK_STALE_AFTER - Duration::from_secs(1);
3817 std::fs::OpenOptions::new()
3818 .write(true)
3819 .open(&lock_path)
3820 .unwrap()
3821 .set_modified(old)
3822 .unwrap();
3823 assert!(
3824 MarkerLock::acquire(&marker).unwrap().is_some(),
3825 "a lock older than the stale window must be reclaimed rather than block forever"
3826 );
3827 }
3828
3829 #[tokio::test]
3830 async fn a_contended_lock_is_retried_until_the_holder_releases_it() {
3831 let dir = tempfile::tempdir().unwrap();
3832 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3833 let held = MarkerLock::acquire(&marker)
3834 .unwrap()
3835 .expect("seed the contention");
3836 let releaser = tokio::spawn(async move {
3837 tokio::time::sleep(Duration::from_millis(20)).await;
3838 drop(held);
3839 });
3840 let waited =
3841 wait_for_marker_lock_with(&marker, Duration::from_millis(5), Duration::from_secs(5))
3842 .await
3843 .unwrap();
3844 assert!(
3845 waited.is_some(),
3846 "a merge landing behind another's still-running decision must not be dropped - it \
3847 must wait for that decision to finish and then judge against what it left behind"
3848 );
3849 releaser.await.unwrap();
3850 }
3851
3852 #[tokio::test]
3853 async fn a_lock_held_past_the_ceiling_gives_up() {
3854 let dir = tempfile::tempdir().unwrap();
3855 let marker = dir.path().join("bump").join("deadbeefdeadbeef.json");
3856 let _held = MarkerLock::acquire(&marker).unwrap().unwrap();
3857 let waited =
3858 wait_for_marker_lock_with(&marker, Duration::from_millis(2), Duration::from_millis(10))
3859 .await
3860 .unwrap();
3861 assert!(
3862 waited.is_none(),
3863 "a lock genuinely held past the ceiling must eventually give up rather than wait \
3864 forever"
3865 );
3866 }
3867
3868 #[test]
3869 fn level_between_reads_off_the_differing_digit() {
3870 assert_eq!(
3871 level_between(
3872 Version::parse("0.8.0").unwrap(),
3873 Version::parse("1.0.0").unwrap()
3874 ),
3875 Some(BumpLevel::Major)
3876 );
3877 assert_eq!(
3878 level_between(
3879 Version::parse("0.8.0").unwrap(),
3880 Version::parse("0.9.0").unwrap()
3881 ),
3882 Some(BumpLevel::Minor)
3883 );
3884 assert_eq!(
3885 level_between(
3886 Version::parse("0.8.0").unwrap(),
3887 Version::parse("0.8.1").unwrap()
3888 ),
3889 Some(BumpLevel::Patch)
3890 );
3891 assert_eq!(
3892 level_between(
3893 Version::parse("0.8.0").unwrap(),
3894 Version::parse("0.8.0").unwrap()
3895 ),
3896 None
3897 );
3898 }
3899
3900 #[test]
3901 fn open_release_pr_is_found_among_unrelated_pull_requests() {
3902 let json = r#"[
3903 {"url": "https://example.invalid/pull/1", "headRefName": "feat/something"},
3904 {"url": "https://example.invalid/pull/2", "headRefName": "chore/release-v0.9.0"}
3905 ]"#;
3906 let found = parse_open_release_pr(json).unwrap();
3907 assert_eq!(
3908 found,
3909 Some((
3910 "chore/release-v0.9.0".to_owned(),
3911 "https://example.invalid/pull/2".to_owned()
3912 ))
3913 );
3914 }
3915
3916 #[test]
3917 fn no_open_release_pr_reads_as_none_not_an_error() {
3918 let json =
3919 r#"[{"url": "https://example.invalid/pull/1", "headRefName": "feat/something"}]"#;
3920 assert_eq!(parse_open_release_pr(json).unwrap(), None);
3921 assert_eq!(parse_open_release_pr("[]").unwrap(), None);
3922 }
3923
3924 #[test]
3925 fn marker_round_trips_through_disk() {
3926 let dir = tempfile::tempdir().unwrap();
3927 let path = marker_path(dir.path(), Path::new("/repos/magi"));
3928 assert!(read_marker(&path).is_none());
3929
3930 let marker = test_pending("0.9.0", BumpLevel::Patch);
3931 write_marker(&path, &marker).unwrap();
3932 let read_back = read_marker(&path).unwrap();
3933 assert_eq!(read_back.target_version, "0.9.0");
3934 assert_eq!(read_back.level, BumpLevel::Patch);
3935 assert_eq!(read_back.pr_url, marker.pr_url);
3936
3937 clear_marker(&path);
3938 assert!(read_marker(&path).is_none());
3939 }
3940
3941 #[test]
3942 fn different_repos_get_different_marker_files() {
3943 let dir = tempfile::tempdir().unwrap();
3944 let a = marker_path(dir.path(), Path::new("/repos/a"));
3945 let b = marker_path(dir.path(), Path::new("/repos/b"));
3946 assert_ne!(a, b);
3947 }
3948
3949 #[test]
3953 fn a_bump_pull_requests_own_merge_does_not_retrigger() {
3954 let files = vec!["Cargo.toml".to_owned(), "Cargo.lock".to_owned()];
3955 assert!(
3956 is_release_only(&files),
3957 "the bump pull request's own diff must read as release-only"
3958 );
3959 }
3960
3961 #[test]
3962 fn should_release_bump_reads_only_a_merged_status() {
3963 assert!(should_release_bump(RunStatus::Merged));
3964 for other in [RunStatus::Blocked, RunStatus::Ready, RunStatus::Prep] {
3965 assert!(!should_release_bump(other));
3966 }
3967 }
3968
3969 #[test]
3973 fn all_three_merge_paths_report_pr_lifecycle_merged_case_done() {
3974 let pr = land::PrState {
3975 url: "https://github.com/o/r/pull/1".to_owned(),
3976 number: 1,
3977 state: PrLifecycle::Merged,
3978 checks: land::Checks::Green,
3979 failing: Vec::new(),
3980 review_comments: Vec::new(),
3981 blocking: land::Blocking::No,
3982 };
3983 assert_eq!(
3984 land::decide(&pr, 0, 4, Duration::ZERO),
3985 land::Step::Done { merged: true }
3986 );
3987 assert!(should_release_bump(RunStatus::Merged));
3988 }
3989
3990 #[test]
3995 fn all_three_merge_paths_report_pr_lifecycle_merged_case_direct_merge() {
3996 let pr = land::PrState {
3997 url: "https://github.com/o/r/pull/2".to_owned(),
3998 number: 2,
3999 state: PrLifecycle::Open,
4000 checks: land::Checks::Green,
4001 failing: Vec::new(),
4002 review_comments: Vec::new(),
4003 blocking: land::Blocking::No,
4004 };
4005 assert_eq!(land::decide(&pr, 0, 4, Duration::ZERO), land::Step::Merge);
4006 assert!(should_release_bump(RunStatus::Merged));
4009 }
4010
4011 #[test]
4014 fn all_three_merge_paths_report_pr_lifecycle_merged_case_merged_after_all() {
4015 let argv = land::merge_argv(3, "feat: something");
4016 let outcome = land::merged_after_all(
4017 &argv,
4018 "could not determine current branch: not on any branch",
4019 Some(PrLifecycle::Merged),
4020 );
4021 assert!(outcome.is_some(), "the forge's confirmation must win");
4022 assert!(should_release_bump(RunStatus::Merged));
4023
4024 assert!(land::merged_after_all(&argv, "network error", Some(PrLifecycle::Open)).is_none());
4027 assert!(land::merged_after_all(&argv, "network error", None).is_none());
4028 }
4029
4030 #[test]
4032 fn a_close_or_a_give_up_does_not_trigger_a_bump() {
4033 let pr = land::PrState {
4034 url: "https://github.com/o/r/pull/4".to_owned(),
4035 number: 4,
4036 state: PrLifecycle::Closed,
4037 checks: land::Checks::Green,
4038 failing: Vec::new(),
4039 review_comments: Vec::new(),
4040 blocking: land::Blocking::No,
4041 };
4042 assert_eq!(
4043 land::decide(&pr, 0, 4, Duration::ZERO),
4044 land::Step::Done { merged: false }
4045 );
4046 assert!(!should_release_bump(RunStatus::Blocked));
4047 }
4048}