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