1use anyhow::{anyhow, bail, Result};
2use crate::{config::{CompletionStrategy, Config}, git, review, ticket, ticket_fmt};
3use chrono::Utc;
4use std::path::{Path, PathBuf};
5
6pub struct TransitionOutput {
7 pub id: String,
8 pub old_state: String,
9 pub new_state: String,
10 pub worktree_path: Option<PathBuf>,
11 pub warnings: Vec<String>,
12 pub messages: Vec<String>,
13}
14
15pub fn transition(root: &Path, id_arg: &str, new_state: String, no_aggressive: bool, force: bool) -> Result<TransitionOutput> {
16 let mut warnings: Vec<String> = Vec::new();
17 let mut messages: Vec<String> = Vec::new();
18
19 let config = Config::load(root)?;
20 let valid_states: std::collections::HashSet<&str> = config.workflow.states.iter()
21 .map(|s| s.id.as_str())
22 .collect();
23 if !valid_states.is_empty() && !valid_states.contains(new_state.as_str()) {
24 let list: Vec<&str> = config.workflow.states.iter().map(|s| s.id.as_str()).collect();
25 bail!("unknown state {:?} — valid states: {}", new_state, list.join(", "));
26 }
27 let aggressive = config.sync.aggressive && !no_aggressive;
28
29 let mut tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
30 let id = ticket::resolve_id_in_slice(&tickets, id_arg)?;
31
32 if aggressive {
33 let branches = git::ticket_branches(root).unwrap_or_default();
34 if let Some(b) = branches.iter().find(|b| {
35 b.strip_prefix("ticket/")
36 .and_then(|s| s.split('-').next())
37 .map(|bid| bid == id.as_str())
38 .unwrap_or(false)
39 }) {
40 if let Err(e) = git::fetch_branch(root, b) {
41 warnings.push(format!("warning: fetch failed: {e:#}"));
42 }
43 }
44 }
45
46 let Some(t) = tickets.iter_mut().find(|t| t.frontmatter.id == id) else {
47 bail!("ticket {id:?} not found");
48 };
49 let old_state = t.frontmatter.state.clone();
50
51 let target_is_terminal = config.workflow.states.iter()
52 .find(|s| s.id == new_state)
53 .map(|s| s.terminal)
54 .unwrap_or(false);
55 let (completion, on_failure): (CompletionStrategy, Option<String>) = if force {
56 (CompletionStrategy::None, None)
57 } else if !target_is_terminal {
58 if let Some(state_cfg) = config.workflow.states.iter().find(|s| s.id == old_state) {
59 if !state_cfg.transitions.is_empty() {
60 let tr = state_cfg.transitions.iter().find(|tr| tr.to == new_state);
61 if tr.is_none() {
62 let allowed: Vec<&str> = state_cfg.transitions.iter().map(|tr| tr.to.as_str()).collect();
63 bail!(
64 "no transition from {:?} to {:?} — valid transitions from {:?}: {}",
65 old_state, new_state, old_state,
66 allowed.join(", ")
67 );
68 }
69 let found = tr.unwrap();
70 if let Some(ref w) = found.warning {
71 warnings.push(format!("⚠ {w}"));
72 }
73 (found.completion.clone(), found.on_failure.clone())
74 } else {
75 (CompletionStrategy::None, None)
76 }
77 } else {
78 (CompletionStrategy::None, None)
79 }
80 } else {
81 (CompletionStrategy::None, None)
82 };
83
84 let branch = t
85 .frontmatter
86 .branch
87 .clone()
88 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
89 .unwrap_or_else(|| format!("ticket/{id}"));
90
91 match new_state.as_str() {
92 "specd" => {
93 if let Ok(doc) = t.document() {
94 let errors = doc.validate(&config.ticket.sections);
95 if !errors.is_empty() {
96 let msgs: Vec<String> = errors.iter().map(|e| format!(" - {e}")).collect();
97 bail!("spec validation failed:\n{}", msgs.join("\n"));
98 }
99 if old_state == "ammend" {
100 let unchecked = doc.unchecked_tasks("Amendment requests");
101 if !unchecked.is_empty() {
102 bail!("not all amendment requests are checked — mark them [x] before resubmitting");
103 }
104 }
105 }
106 }
107 "implemented" => {
108 if let Ok(doc) = t.document() {
109 let unchecked = doc.unchecked_tasks("Acceptance criteria");
110 if !unchecked.is_empty() {
111 bail!(
112 "not all acceptance criteria are checked — mark them [x] before transitioning to implemented"
113 );
114 }
115 }
116 let should_check = match &completion {
119 CompletionStrategy::Merge => true,
120 CompletionStrategy::PrOrEpicMerge => t.frontmatter.target_branch.is_some(),
121 _ => false,
122 };
123 if should_check {
124 let merge_target = t.frontmatter.target_branch.as_deref()
125 .unwrap_or(config.project.default_branch.as_str());
126 let leaked = git::check_leaked_files(root, &branch, merge_target)?;
127 if !leaked.is_empty() {
128 let file_list = leaked
129 .iter()
130 .map(|f| format!(" {f}"))
131 .collect::<Vec<_>>()
132 .join("\n");
133 let log_hint = crate::worktree::find_worktree_for_branch(root, &branch)
134 .map(|p| p.join(".apm-worker.log").to_string_lossy().into_owned())
135 .unwrap_or_else(|| "<ticket-worktree>/.apm-worker.log".to_string());
136 bail!(
137 "cannot complete {new_state}: the target worktree has uncommitted changes \
138 to files this ticket also modified:\n{file_list}\n\
139 This usually means a worker leaked edits outside its worktree.\n\
140 Inspect the worker's transcript: {log_hint}\n\
141 Then either commit/restore the leaked files and re-run \
142 `apm state {id} implemented`, or run `apm verify` to investigate."
143 );
144 }
145 }
146 }
147 _ => {}
148 }
149
150 let now = Utc::now();
151 let actor = crate::config::resolve_caller_name();
152 t.frontmatter.state = new_state.clone();
153 t.frontmatter.updated_at = Some(now);
154 if new_state == "ammend" {
155 review::ensure_amendment_section(&mut t.body);
156 }
157 append_history(&mut t.body, &old_state, &new_state, &now.format("%Y-%m-%dT%H:%MZ").to_string(), &actor);
158
159 let content = t.serialize()?;
160 let rel_path = format!(
161 "{}/{}",
162 config.tickets.dir.to_string_lossy(),
163 t.path.file_name().unwrap().to_string_lossy()
164 );
165
166 git::commit_to_branch(
167 root,
168 &branch,
169 &rel_path,
170 &content,
171 &format!("ticket({id}): {old_state} → {new_state}"),
172 )?;
173 crate::logger::log("state_transition", &format!("{id:?} {old_state} -> {new_state}"));
174
175 if target_is_terminal {
176 let target = t.frontmatter.target_branch.as_deref()
177 .unwrap_or(config.project.default_branch.as_str());
178 if let Err(e) = git::commit_to_branch(root, target, &rel_path, &content, &format!("ticket({id}): {old_state} \u{2192} {new_state}")) {
179 warnings.push(format!("warning: commit terminal state to {target} failed: {e:#}"));
180 }
181 }
182
183 match completion {
184 CompletionStrategy::Pr => {
185 git::push_branch_tracking(root, &branch)?;
186 let pr_base = t.frontmatter.target_branch.as_deref()
187 .unwrap_or(&config.project.default_branch);
188 crate::github::gh_pr_create_or_update(root, &branch, pr_base, &id, &t.frontmatter.title, &format!("Closes #{id}"), &mut messages)?;
189 }
190 CompletionStrategy::Merge => {
191 let merge_result = {
192 let merge_target = t.frontmatter.target_branch.as_deref()
193 .unwrap_or(&config.project.default_branch);
194 let is_main = merge_target == config.project.default_branch;
195 if let Err(e) = git::push_branch_tracking(root, &branch) {
196 warnings.push(format!("warning: could not push {branch}: {e}"));
197 }
198 git::merge_into_default(root, &config, &branch, merge_target, is_main, &mut messages, &mut warnings)
199 };
200 if let Err(merge_err) = merge_result {
201 let merge_err_msg = format!("{merge_err:#}");
202 let failure_state = match &on_failure {
203 Some(s) => s.clone(),
204 None => {
205 return Err(anyhow!(
206 "{merge_err_msg}\n\nMerge failed and the transition to '{}' has \
207 no `on_failure` configured. Run `apm validate --fix` to add it.",
208 new_state
209 ));
210 }
211 };
212 let fail_now = Utc::now();
213 t.frontmatter.state = failure_state.clone();
214 t.frontmatter.updated_at = Some(fail_now);
215 set_merge_notes(&mut t.body, &merge_err_msg);
216 append_history(&mut t.body, &new_state, &failure_state, &fail_now.format("%Y-%m-%dT%H:%MZ").to_string(), &actor);
217 let fallback_content = match t.serialize() {
218 Ok(c) => c,
219 Err(_) => return Err(merge_err),
220 };
221 if git::commit_to_branch(root, &branch, &rel_path, &fallback_content, &format!("ticket({id}): {new_state} → {failure_state}")).is_err() {
222 return Err(merge_err);
223 }
224 crate::logger::log("state_transition", &format!("{id:?} {new_state} -> {failure_state}"));
225 return Ok(TransitionOutput {
226 id: id.clone(),
227 old_state: old_state.clone(),
228 new_state: failure_state,
229 worktree_path: None,
230 warnings,
231 messages,
232 });
233 }
234 }
235 CompletionStrategy::PrOrEpicMerge => {
236 git::push_branch_tracking(root, &branch)?;
237 if let Some(ref target) = t.frontmatter.target_branch {
238 let merge_result = git::merge_into_default(root, &config, &branch, target, false, &mut messages, &mut warnings);
239 if let Err(merge_err) = merge_result {
240 let merge_err_msg = format!("{merge_err:#}");
241 let failure_state = match &on_failure {
242 Some(s) => s.clone(),
243 None => {
244 return Err(anyhow!(
245 "{merge_err_msg}\n\nMerge failed and the transition to '{}' has \
246 no `on_failure` configured. Run `apm validate --fix` to add it.",
247 new_state
248 ));
249 }
250 };
251 let fail_now = Utc::now();
252 t.frontmatter.state = failure_state.clone();
253 t.frontmatter.updated_at = Some(fail_now);
254 set_merge_notes(&mut t.body, &merge_err_msg);
255 append_history(&mut t.body, &new_state, &failure_state, &fail_now.format("%Y-%m-%dT%H:%MZ").to_string(), &actor);
256 let fallback_content = match t.serialize() {
257 Ok(c) => c,
258 Err(_) => return Err(merge_err),
259 };
260 if git::commit_to_branch(root, &branch, &rel_path, &fallback_content, &format!("ticket({id}): {new_state} → {failure_state}")).is_err() {
261 return Err(merge_err);
262 }
263 crate::logger::log("state_transition", &format!("{id:?} {new_state} -> {failure_state}"));
264 return Ok(TransitionOutput {
265 id: id.clone(),
266 old_state: old_state.clone(),
267 new_state: failure_state,
268 worktree_path: None,
269 warnings,
270 messages,
271 });
272 }
273 } else {
274 crate::github::gh_pr_create_or_update(root, &branch, &config.project.default_branch, &id, &t.frontmatter.title, &format!("Closes #{id}"), &mut messages)?;
275 }
276 }
277 CompletionStrategy::Pull => {
278 git::pull_default(root, &config.project.default_branch, &mut warnings)?;
279 }
280 CompletionStrategy::None => {
281 if aggressive {
282 if let Err(e) = git::push_branch_tracking(root, &branch) {
283 warnings.push(format!("warning: push failed: {e:#}"));
284 }
285 }
286 }
287 }
288
289 let worktree_path = if new_state == "in_design" {
290 Some(crate::worktree::provision_worktree(root, &config, &branch, &mut warnings)?)
291 } else {
292 None
293 };
294
295 Ok(TransitionOutput {
296 id,
297 old_state,
298 new_state,
299 worktree_path,
300 warnings,
301 messages,
302 })
303}
304
305
306pub fn available_transitions(config: &crate::config::Config, current_state: &str) -> Vec<(String, String, String)> {
307 let terminal_ids: Vec<&str> = config.workflow.states.iter()
308 .filter(|s| s.terminal)
309 .map(|s| s.id.as_str())
310 .collect();
311
312 let state_cfg = config.workflow.states.iter().find(|s| s.id == current_state);
313
314 if let Some(sc) = state_cfg {
315 if !sc.transitions.is_empty() {
316 return sc.transitions.iter()
317 .filter(|tr| !tr.trigger.starts_with("event:"))
318 .map(|tr| (tr.to.clone(), tr.label.clone(), tr.hint.clone()))
319 .collect();
320 }
321 }
322
323 config.workflow.states.iter()
325 .filter(|s| s.id != current_state && !terminal_ids.contains(&s.id.as_str()))
326 .map(|s| (s.id.clone(), s.label.clone(), String::new()))
327 .collect()
328}
329
330#[derive(serde::Serialize, Clone, Debug)]
331pub struct TransitionOption {
332 pub to: String,
333 pub label: String,
334 #[serde(skip_serializing_if = "Option::is_none")]
335 pub warning: Option<String>,
336}
337
338pub fn compute_valid_transitions(state: &str, config: &crate::config::Config) -> Vec<TransitionOption> {
339 config
340 .workflow
341 .states
342 .iter()
343 .find(|s| s.id == state)
344 .map(|s| {
345 s.transitions
346 .iter()
347 .map(|tr| TransitionOption {
348 to: tr.to.clone(),
349 label: if tr.label.is_empty() {
350 format!("-> {}", tr.to)
351 } else {
352 tr.label.clone()
353 },
354 warning: tr.warning.clone(),
355 })
356 .collect()
357 })
358 .unwrap_or_default()
359}
360
361fn set_merge_notes(body: &mut String, notes: &str) {
362 const SECTION: &str = "### Merge notes";
363
364 if let Some(start) = body.find(SECTION) {
366 let actual_start = if start > 0 && body.as_bytes().get(start - 1) == Some(&b'\n') {
367 start - 1
368 } else {
369 start
370 };
371 let after_header = start + SECTION.len();
372 let end = body[after_header..]
373 .find("\n##")
374 .map(|i| after_header + i)
375 .unwrap_or(body.len());
376 body.replace_range(actual_start..end, "");
377 }
378
379 let block = format!("\n{SECTION}\n\n{notes}\n");
381 if let Some(pos) = body.find("\n## History") {
382 body.insert_str(pos, &block);
383 } else {
384 body.push_str(&block);
385 }
386}
387
388pub fn append_history(body: &mut String, from: &str, to: &str, when: &str, by: &str) {
389 let row = format!("| {when} | {from} | {to} | {by} |");
390 if body.contains("## History") {
391 if !body.ends_with('\n') {
392 body.push('\n');
393 }
394 body.push_str(&row);
395 body.push('\n');
396 } else {
397 body.push_str(&format!(
398 "\n## History\n\n| When | From | To | By |\n|------|------|----|----|\n{row}\n"
399 ));
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 fn config_with_transitions() -> crate::config::Config {
408 let toml = concat!(
409 "[project]\nname = \"test\"\n",
410 "[tickets]\ndir = \"tickets\"\n",
411 "[[workflow.states]]\n",
412 "id = \"new\"\nlabel = \"New\"\n",
413 "[[workflow.states.transitions]]\n",
414 "to = \"ready\"\nlabel = \"Mark ready\"\n",
415 "[[workflow.states.transitions]]\n",
416 "to = \"closed\"\nlabel = \"\"\n",
417 "warning = \"This will close the ticket\"\n",
418 "[[workflow.states]]\n",
419 "id = \"ready\"\nlabel = \"Ready\"\n",
420 "[[workflow.states]]\n",
421 "id = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
422 );
423 toml::from_str(toml).unwrap()
424 }
425
426 #[test]
427 fn set_merge_notes_inserts_before_history() {
428 let mut body = "## Spec\n\ncontent\n\n## History\n\n| row |".to_string();
429 set_merge_notes(&mut body, "conflict error");
430 assert!(body.contains("### Merge notes\n\nconflict error\n"));
431 let notes_pos = body.find("### Merge notes").unwrap();
432 let hist_pos = body.find("## History").unwrap();
433 assert!(notes_pos < hist_pos);
434 }
435
436 #[test]
437 fn set_merge_notes_appends_when_no_history() {
438 let mut body = "## Spec\n\ncontent".to_string();
439 set_merge_notes(&mut body, "error msg");
440 assert!(body.contains("### Merge notes\n\nerror msg\n"));
441 }
442
443 #[test]
444 fn set_merge_notes_overwrites_existing_section() {
445 let mut body = "## Spec\n\n### Merge notes\n\nold error\n\n## History\n\n| row |".to_string();
446 set_merge_notes(&mut body, "new error");
447 assert!(body.contains("### Merge notes\n\nnew error\n"));
448 assert!(!body.contains("old error"));
449 let notes_pos = body.find("### Merge notes").unwrap();
450 let hist_pos = body.find("## History").unwrap();
451 assert!(notes_pos < hist_pos);
452 }
453
454 #[test]
455 fn compute_valid_transitions_returns_expected_options() {
456 let config = config_with_transitions();
457 let opts = compute_valid_transitions("new", &config);
458 assert_eq!(opts.len(), 2);
459 assert_eq!(opts[0].to, "ready");
460 assert_eq!(opts[0].label, "Mark ready");
461 assert!(opts[0].warning.is_none());
462 assert_eq!(opts[1].to, "closed");
463 assert_eq!(opts[1].label, "-> closed");
464 assert_eq!(opts[1].warning.as_deref(), Some("This will close the ticket"));
465 }
466
467 #[test]
468 fn compute_valid_transitions_unknown_state_returns_empty() {
469 let config = config_with_transitions();
470 let opts = compute_valid_transitions("nonexistent", &config);
471 assert!(opts.is_empty());
472 }
473}