Skip to main content

apm_core/
state.rs

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    // Block transitions FROM terminal states (unless forced).
52    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            // Pre-merge leak detection: refuse if the target worktree has uncommitted
129            // overlap with files this ticket modified.
130            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    // No explicit transitions: all non-terminal, non-current states are valid.
336    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    // Remove existing section if present.
377    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    // Insert before ## History or append.
392    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}