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    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    match new_state.as_str() {
85        "specd" => {
86            if let Ok(doc) = t.document() {
87                let errors = doc.validate(&config.ticket.sections);
88                if !errors.is_empty() {
89                    let msgs: Vec<String> = errors.iter().map(|e| format!("  - {e}")).collect();
90                    bail!("spec validation failed:\n{}", msgs.join("\n"));
91                }
92                if old_state == "ammend" {
93                    let unchecked = doc.unchecked_tasks("Amendment requests");
94                    if !unchecked.is_empty() {
95                        bail!("not all amendment requests are checked — mark them [x] before resubmitting");
96                    }
97                }
98            }
99        }
100        "implemented" => {
101            if let Ok(doc) = t.document() {
102                let unchecked = doc.unchecked_tasks("Acceptance criteria");
103                if !unchecked.is_empty() {
104                    bail!(
105                        "not all acceptance criteria are checked — mark them [x] before transitioning to implemented"
106                    );
107                }
108            }
109        }
110        _ => {}
111    }
112
113    let now = Utc::now();
114    let actor = crate::config::resolve_caller_name();
115    t.frontmatter.state = new_state.clone();
116    t.frontmatter.updated_at = Some(now);
117    if new_state == "ammend" {
118        review::ensure_amendment_section(&mut t.body);
119    }
120    append_history(&mut t.body, &old_state, &new_state, &now.format("%Y-%m-%dT%H:%MZ").to_string(), &actor);
121
122    let content = t.serialize()?;
123    let rel_path = format!(
124        "{}/{}",
125        config.tickets.dir.to_string_lossy(),
126        t.path.file_name().unwrap().to_string_lossy()
127    );
128    let branch = t
129        .frontmatter
130        .branch
131        .clone()
132        .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
133        .unwrap_or_else(|| format!("ticket/{id}"));
134
135    git::commit_to_branch(
136        root,
137        &branch,
138        &rel_path,
139        &content,
140        &format!("ticket({id}): {old_state} → {new_state}"),
141    )?;
142    crate::logger::log("state_transition", &format!("{id:?} {old_state} -> {new_state}"));
143
144    match completion {
145        CompletionStrategy::Pr => {
146            git::push_branch_tracking(root, &branch)?;
147            let pr_base = t.frontmatter.target_branch.as_deref()
148                .unwrap_or(&config.project.default_branch);
149            crate::github::gh_pr_create_or_update(root, &branch, pr_base, &id, &t.frontmatter.title, &format!("Closes #{id}"), &mut messages)?;
150        }
151        CompletionStrategy::Merge => {
152            let merge_result = {
153                let merge_target = t.frontmatter.target_branch.as_deref()
154                    .unwrap_or(&config.project.default_branch);
155                let is_main = merge_target == config.project.default_branch;
156                if let Err(e) = git::push_branch_tracking(root, &branch) {
157                    warnings.push(format!("warning: could not push {branch}: {e}"));
158                }
159                git::merge_into_default(root, &config, &branch, merge_target, is_main, &mut messages, &mut warnings)
160            };
161            if let Err(merge_err) = merge_result {
162                let merge_err_msg = format!("{merge_err:#}");
163                let failure_state = match &on_failure {
164                    Some(s) => s.clone(),
165                    None => {
166                        return Err(anyhow!(
167                            "{merge_err_msg}\n\nMerge failed and the transition to '{}' has \
168                             no `on_failure` configured. Run `apm validate --fix` to add it.",
169                            new_state
170                        ));
171                    }
172                };
173                let fail_now = Utc::now();
174                t.frontmatter.state = failure_state.clone();
175                t.frontmatter.updated_at = Some(fail_now);
176                set_merge_notes(&mut t.body, &merge_err_msg);
177                append_history(&mut t.body, &new_state, &failure_state, &fail_now.format("%Y-%m-%dT%H:%MZ").to_string(), &actor);
178                let fallback_content = match t.serialize() {
179                    Ok(c) => c,
180                    Err(_) => return Err(merge_err),
181                };
182                if git::commit_to_branch(root, &branch, &rel_path, &fallback_content, &format!("ticket({id}): {new_state} → {failure_state}")).is_err() {
183                    return Err(merge_err);
184                }
185                crate::logger::log("state_transition", &format!("{id:?} {new_state} -> {failure_state}"));
186                return Ok(TransitionOutput {
187                    id: id.clone(),
188                    old_state: old_state.clone(),
189                    new_state: failure_state,
190                    worktree_path: None,
191                    warnings,
192                    messages,
193                });
194            }
195        }
196        CompletionStrategy::PrOrEpicMerge => {
197            git::push_branch_tracking(root, &branch)?;
198            if let Some(ref target) = t.frontmatter.target_branch {
199                let merge_result = git::merge_into_default(root, &config, &branch, target, false, &mut messages, &mut warnings);
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            } else {
235                crate::github::gh_pr_create_or_update(root, &branch, &config.project.default_branch, &id, &t.frontmatter.title, &format!("Closes #{id}"), &mut messages)?;
236            }
237        }
238        CompletionStrategy::Pull => {
239            git::pull_default(root, &config.project.default_branch, &mut warnings)?;
240        }
241        CompletionStrategy::None => {
242            if aggressive {
243                if let Err(e) = git::push_branch_tracking(root, &branch) {
244                    warnings.push(format!("warning: push failed: {e:#}"));
245                }
246            }
247        }
248    }
249
250    let worktree_path = if new_state == "in_design" {
251        Some(crate::worktree::provision_worktree(root, &config, &branch, &mut warnings)?)
252    } else {
253        None
254    };
255
256    Ok(TransitionOutput {
257        id,
258        old_state,
259        new_state,
260        worktree_path,
261        warnings,
262        messages,
263    })
264}
265
266
267pub fn available_transitions(config: &crate::config::Config, current_state: &str) -> Vec<(String, String, String)> {
268    let terminal_ids: Vec<&str> = config.workflow.states.iter()
269        .filter(|s| s.terminal)
270        .map(|s| s.id.as_str())
271        .collect();
272
273    let state_cfg = config.workflow.states.iter().find(|s| s.id == current_state);
274
275    if let Some(sc) = state_cfg {
276        if !sc.transitions.is_empty() {
277            return sc.transitions.iter()
278                .filter(|tr| !tr.trigger.starts_with("event:"))
279                .map(|tr| (tr.to.clone(), tr.label.clone(), tr.hint.clone()))
280                .collect();
281        }
282    }
283
284    // No explicit transitions: all non-terminal, non-current states are valid.
285    config.workflow.states.iter()
286        .filter(|s| s.id != current_state && !terminal_ids.contains(&s.id.as_str()))
287        .map(|s| (s.id.clone(), s.label.clone(), String::new()))
288        .collect()
289}
290
291#[derive(serde::Serialize, Clone, Debug)]
292pub struct TransitionOption {
293    pub to: String,
294    pub label: String,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub warning: Option<String>,
297}
298
299pub fn compute_valid_transitions(state: &str, config: &crate::config::Config) -> Vec<TransitionOption> {
300    config
301        .workflow
302        .states
303        .iter()
304        .find(|s| s.id == state)
305        .map(|s| {
306            s.transitions
307                .iter()
308                .map(|tr| TransitionOption {
309                    to: tr.to.clone(),
310                    label: if tr.label.is_empty() {
311                        format!("-> {}", tr.to)
312                    } else {
313                        tr.label.clone()
314                    },
315                    warning: tr.warning.clone(),
316                })
317                .collect()
318        })
319        .unwrap_or_default()
320}
321
322fn set_merge_notes(body: &mut String, notes: &str) {
323    const SECTION: &str = "### Merge notes";
324
325    // Remove existing section if present.
326    if let Some(start) = body.find(SECTION) {
327        let actual_start = if start > 0 && body.as_bytes().get(start - 1) == Some(&b'\n') {
328            start - 1
329        } else {
330            start
331        };
332        let after_header = start + SECTION.len();
333        let end = body[after_header..]
334            .find("\n##")
335            .map(|i| after_header + i)
336            .unwrap_or(body.len());
337        body.replace_range(actual_start..end, "");
338    }
339
340    // Insert before ## History or append.
341    let block = format!("\n{SECTION}\n\n{notes}\n");
342    if let Some(pos) = body.find("\n## History") {
343        body.insert_str(pos, &block);
344    } else {
345        body.push_str(&block);
346    }
347}
348
349pub fn append_history(body: &mut String, from: &str, to: &str, when: &str, by: &str) {
350    let row = format!("| {when} | {from} | {to} | {by} |");
351    if body.contains("## History") {
352        if !body.ends_with('\n') {
353            body.push('\n');
354        }
355        body.push_str(&row);
356        body.push('\n');
357    } else {
358        body.push_str(&format!(
359            "\n## History\n\n| When | From | To | By |\n|------|------|----|----|\n{row}\n"
360        ));
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    fn config_with_transitions() -> crate::config::Config {
369        let toml = concat!(
370            "[project]\nname = \"test\"\n",
371            "[tickets]\ndir = \"tickets\"\n",
372            "[[workflow.states]]\n",
373            "id = \"new\"\nlabel = \"New\"\n",
374            "[[workflow.states.transitions]]\n",
375            "to = \"ready\"\nlabel = \"Mark ready\"\n",
376            "[[workflow.states.transitions]]\n",
377            "to = \"closed\"\nlabel = \"\"\n",
378            "warning = \"This will close the ticket\"\n",
379            "[[workflow.states]]\n",
380            "id = \"ready\"\nlabel = \"Ready\"\n",
381            "[[workflow.states]]\n",
382            "id = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
383        );
384        toml::from_str(toml).unwrap()
385    }
386
387    #[test]
388    fn set_merge_notes_inserts_before_history() {
389        let mut body = "## Spec\n\ncontent\n\n## History\n\n| row |".to_string();
390        set_merge_notes(&mut body, "conflict error");
391        assert!(body.contains("### Merge notes\n\nconflict error\n"));
392        let notes_pos = body.find("### Merge notes").unwrap();
393        let hist_pos = body.find("## History").unwrap();
394        assert!(notes_pos < hist_pos);
395    }
396
397    #[test]
398    fn set_merge_notes_appends_when_no_history() {
399        let mut body = "## Spec\n\ncontent".to_string();
400        set_merge_notes(&mut body, "error msg");
401        assert!(body.contains("### Merge notes\n\nerror msg\n"));
402    }
403
404    #[test]
405    fn set_merge_notes_overwrites_existing_section() {
406        let mut body = "## Spec\n\n### Merge notes\n\nold error\n\n## History\n\n| row |".to_string();
407        set_merge_notes(&mut body, "new error");
408        assert!(body.contains("### Merge notes\n\nnew error\n"));
409        assert!(!body.contains("old error"));
410        let notes_pos = body.find("### Merge notes").unwrap();
411        let hist_pos = body.find("## History").unwrap();
412        assert!(notes_pos < hist_pos);
413    }
414
415    #[test]
416    fn compute_valid_transitions_returns_expected_options() {
417        let config = config_with_transitions();
418        let opts = compute_valid_transitions("new", &config);
419        assert_eq!(opts.len(), 2);
420        assert_eq!(opts[0].to, "ready");
421        assert_eq!(opts[0].label, "Mark ready");
422        assert!(opts[0].warning.is_none());
423        assert_eq!(opts[1].to, "closed");
424        assert_eq!(opts[1].label, "-> closed");
425        assert_eq!(opts[1].warning.as_deref(), Some("This will close the ticket"));
426    }
427
428    #[test]
429    fn compute_valid_transitions_unknown_state_returns_empty() {
430        let config = config_with_transitions();
431        let opts = compute_valid_transitions("nonexistent", &config);
432        assert!(opts.is_empty());
433    }
434}