Skip to main content

apm_core/
epic.rs

1use anyhow::Result;
2use std::path::Path;
3
4use crate::config::StateConfig;
5use crate::{git_util, worktree};
6
7pub fn epic_is_quiescent(
8    root: &Path,
9    epic_id: &str,
10    config: &crate::config::Config,
11    worktrees: &[(std::path::PathBuf, String)],
12) -> Result<Vec<String>> {
13    let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
14    let mut blockers = Vec::new();
15
16    for t in all_tickets.iter().filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id)) {
17        let id = &t.frontmatter.id;
18        let title = &t.frontmatter.title;
19        let state_id = &t.frontmatter.state;
20
21        let state_cfg = config.workflow.states.iter().find(|s| &s.id == state_id);
22        let is_terminal = state_cfg.map(|s| s.terminal).unwrap_or(false);
23        let is_worker_end = state_cfg.map(|s| s.worker_end).unwrap_or(false);
24
25        let state_blocks = !is_terminal && !is_worker_end;
26        if state_blocks {
27            blockers.push(format!("  {id} — {title} (state: {state_id})"));
28            continue;
29        }
30
31        let ticket_branch = t.frontmatter.branch.clone()
32            .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path));
33        if let Some(branch) = ticket_branch {
34            if let Some((wt_path, _)) = worktrees.iter().find(|(_, b)| b == &branch) {
35                let pid_file = wt_path.join(".apm-worker.pid");
36                if pid_file.exists() {
37                    if let Ok((pid, _)) = crate::worker::read_pid_file(&pid_file) {
38                        if crate::worker::is_alive(pid) {
39                            blockers.push(format!("  {id} — {title} (live worker)"));
40                        }
41                    }
42                }
43            }
44        }
45    }
46
47    Ok(blockers)
48}
49
50/// Derive the display state of an epic from the `StateConfig`s of its tickets.
51///
52/// Rules (evaluated in order):
53/// 1. Empty slice → "empty"
54/// 2. Any state has neither `satisfies_deps` nor `terminal` → "in_progress"
55/// 3. All states have `terminal = true` → "done"
56/// 4. All states have `satisfies_deps = true` or `terminal = true`, but not
57///    all are terminal → "implemented"
58/// 5. Otherwise → "in_progress"
59pub fn derive_epic_state(states: &[&StateConfig]) -> &'static str {
60    if states.is_empty() {
61        return "empty";
62    }
63    if states.iter().any(|s| !matches!(s.satisfies_deps, crate::config::SatisfiesDeps::Bool(true)) && !s.terminal) {
64        return "in_progress";
65    }
66    if states.iter().all(|s| s.terminal) {
67        return "done";
68    }
69    "implemented"
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::config::StateConfig;
76
77    #[test]
78    fn branch_to_title_basic() {
79        assert_eq!(branch_to_title("epic/ab12cd34-user-authentication"), "User Authentication");
80    }
81
82    #[test]
83    fn branch_to_title_single_word() {
84        assert_eq!(branch_to_title("epic/ab12cd34-dashboard"), "Dashboard");
85    }
86
87    #[test]
88    fn branch_to_title_many_words() {
89        assert_eq!(branch_to_title("epic/ab12cd34-add-oauth-login-flow"), "Add Oauth Login Flow");
90    }
91
92    #[test]
93    fn branch_to_title_no_slug() {
94        assert_eq!(branch_to_title("epic/ab12cd34"), "Ab12cd34");
95    }
96
97    #[test]
98    fn epic_id_from_branch_happy_path() {
99        assert_eq!(epic_id_from_branch("epic/57bce963-refactor-apm-core"), "57bce963");
100    }
101
102    #[test]
103    fn epic_id_from_branch_no_epic_prefix() {
104        assert_eq!(epic_id_from_branch("57bce963-refactor"), "57bce963");
105    }
106
107    #[test]
108    fn epic_id_from_branch_no_dash() {
109        assert_eq!(epic_id_from_branch("nodash"), "nodash");
110    }
111
112    fn git_cmd(dir: &std::path::Path, args: &[&str]) {
113        std::process::Command::new("git")
114            .args(args)
115            .current_dir(dir)
116            .env("GIT_AUTHOR_NAME", "test")
117            .env("GIT_AUTHOR_EMAIL", "test@test.com")
118            .env("GIT_COMMITTER_NAME", "test")
119            .env("GIT_COMMITTER_EMAIL", "test@test.com")
120            .output()
121            .unwrap();
122    }
123
124    fn setup_repo() -> tempfile::TempDir {
125        let tmp = tempfile::tempdir().unwrap();
126        let p = tmp.path();
127        git_cmd(p, &["init", "-q", "-b", "main"]);
128        git_cmd(p, &["config", "user.email", "test@test.com"]);
129        git_cmd(p, &["config", "user.name", "test"]);
130        // Initial commit so commit_to_branch can use worktrees.
131        std::fs::write(p.join("README.md"), "init\n").unwrap();
132        git_cmd(p, &["add", "README.md"]);
133        git_cmd(p, &["commit", "-m", "init"]);
134        tmp
135    }
136
137    const TOML_WITH_STATES: &str = concat!(
138        "[project]\nname = \"test\"\n\n",
139        "[tickets]\ndir = \"tickets\"\n\n",
140        "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\n\n",
141        "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
142    );
143
144    fn make_ticket_content(id: &str, state: &str, epic: &str) -> String {
145        format!(
146            "+++\nid = \"{id}\"\ntitle = \"Ticket {id}\"\nstate = \"{state}\"\nepic = \"{epic}\"\n+++\n\nBody.\n"
147        )
148    }
149
150    #[test]
151    fn set_epic_owner_updates_non_terminal_skips_terminal() {
152        let tmp = setup_repo();
153        let p = tmp.path();
154        std::fs::create_dir_all(p.join(".apm")).unwrap();
155        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
156        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
157
158        let config = crate::config::Config::load(p).unwrap();
159
160        // Non-terminal ticket in this epic.
161        let content_a = make_ticket_content("aaaa1234", "ready", "epic1234");
162        crate::git::commit_to_branch(p, "ticket/aaaa1234-t1", "tickets/aaaa1234-t1.md", &content_a, "add t1").unwrap();
163
164        // Terminal ticket in this epic — should be skipped.
165        let content_b = make_ticket_content("bbbb5678", "closed", "epic1234");
166        crate::git::commit_to_branch(p, "ticket/bbbb5678-t2", "tickets/bbbb5678-t2.md", &content_b, "add t2").unwrap();
167
168        // Ticket in a different epic — should be ignored.
169        let content_c = make_ticket_content("cccc9012", "ready", "other123");
170        crate::git::commit_to_branch(p, "ticket/cccc9012-t3", "tickets/cccc9012-t3.md", &content_c, "add t3").unwrap();
171
172        let (changed, skipped) = set_epic_owner(p, "epic1234", "alice", &config).unwrap();
173        assert_eq!(changed, 1, "one non-terminal ticket should be changed");
174        assert_eq!(skipped, 1, "one terminal ticket should be skipped");
175    }
176
177    #[test]
178    fn set_epic_owner_all_terminal_returns_zero_changed() {
179        let tmp = setup_repo();
180        let p = tmp.path();
181        std::fs::create_dir_all(p.join(".apm")).unwrap();
182        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
183
184        let config = crate::config::Config::load(p).unwrap();
185
186        let content_a = make_ticket_content("dddd1111", "closed", "epic5678");
187        crate::git::commit_to_branch(p, "ticket/dddd1111-t4", "tickets/dddd1111-t4.md", &content_a, "add t4").unwrap();
188        let content_b = make_ticket_content("eeee2222", "closed", "epic5678");
189        crate::git::commit_to_branch(p, "ticket/eeee2222-t5", "tickets/eeee2222-t5.md", &content_b, "add t5").unwrap();
190
191        let (changed, skipped) = set_epic_owner(p, "epic5678", "bob", &config).unwrap();
192        assert_eq!(changed, 0);
193        assert_eq!(skipped, 2);
194    }
195
196    const TOML_WITH_WORKER_END: &str = concat!(
197        "[project]\nname = \"test\"\n\n",
198        "[tickets]\ndir = \"tickets\"\n\n",
199        "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\nworker_end = false\n\n",
200        "[[workflow.states]]\nid = \"implemented\"\nlabel = \"Implemented\"\nterminal = false\nworker_end = true\n\n",
201        "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
202    );
203
204    #[test]
205    fn epic_is_quiescent_all_done() {
206        let tmp = setup_repo();
207        let p = tmp.path();
208        std::fs::create_dir_all(p.join(".apm")).unwrap();
209        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_WORKER_END).unwrap();
210        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
211
212        let config = crate::config::Config::load(p).unwrap();
213
214        let closed = make_ticket_content("aaaa0001", "closed", "epic0001");
215        crate::git::commit_to_branch(p, "ticket/aaaa0001-t1", "tickets/aaaa0001-t1.md", &closed, "add t1").unwrap();
216
217        let implemented = make_ticket_content("bbbb0002", "implemented", "epic0001");
218        crate::git::commit_to_branch(p, "ticket/bbbb0002-t2", "tickets/bbbb0002-t2.md", &implemented, "add t2").unwrap();
219
220        let blockers = epic_is_quiescent(p, "epic0001", &config, &[]).unwrap();
221        assert!(blockers.is_empty(), "expected no blockers, got: {blockers:?}");
222    }
223
224    #[test]
225    fn epic_is_quiescent_state_blocker() {
226        let tmp = setup_repo();
227        let p = tmp.path();
228        std::fs::create_dir_all(p.join(".apm")).unwrap();
229        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_WORKER_END).unwrap();
230        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
231
232        let config = crate::config::Config::load(p).unwrap();
233
234        let content = make_ticket_content("cccc0003", "ready", "epic0002");
235        crate::git::commit_to_branch(p, "ticket/cccc0003-t3", "tickets/cccc0003-t3.md", &content, "add t3").unwrap();
236
237        let blockers = epic_is_quiescent(p, "epic0002", &config, &[]).unwrap();
238        assert_eq!(blockers.len(), 1);
239        assert!(blockers[0].contains("cccc0003"));
240        assert!(blockers[0].contains("(state: ready)"));
241    }
242
243    #[test]
244    fn epic_is_quiescent_live_worker_blocker() {
245        let tmp = setup_repo();
246        let p = tmp.path();
247        std::fs::create_dir_all(p.join(".apm")).unwrap();
248        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_WORKER_END).unwrap();
249        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
250
251        let config = crate::config::Config::load(p).unwrap();
252
253        // Ticket in worker_end state (quiescent by state check).
254        let content = make_ticket_content("dddd0004", "implemented", "epic0003");
255        crate::git::commit_to_branch(p, "ticket/dddd0004-t4", "tickets/dddd0004-t4.md", &content, "add t4").unwrap();
256
257        // Simulate a worktree with a live .apm-worker.pid (current process PID).
258        let wt_path = tmp.path().join("fake-worktree-dddd0004");
259        std::fs::create_dir_all(&wt_path).unwrap();
260        let pid = std::process::id();
261        std::fs::write(
262            wt_path.join(".apm-worker.pid"),
263            format!(r#"{{"pid":{pid},"ticket_id":"dddd0004","started_at":"2026-01-01T00:00:00Z"}}"#),
264        ).unwrap();
265
266        let worktrees = vec![(wt_path, "ticket/dddd0004-t4".to_string())];
267        let blockers = epic_is_quiescent(p, "epic0003", &config, &worktrees).unwrap();
268        assert_eq!(blockers.len(), 1);
269        assert!(blockers[0].contains("dddd0004"));
270        assert!(blockers[0].contains("(live worker)"));
271    }
272
273    fn make_state(terminal: bool, satisfies_deps: bool, actionable: Vec<&str>) -> StateConfig {
274        StateConfig {
275            id: "x".to_string(),
276            label: "x".to_string(),
277            description: String::new(),
278            terminal,
279            worker_end: false,
280            satisfies_deps: crate::config::SatisfiesDeps::Bool(satisfies_deps),
281            dep_requires: None,
282            transitions: vec![],
283            actionable: actionable.into_iter().map(|s| s.to_string()).collect(),
284            instructions: None,
285        }
286    }
287
288    #[test]
289    fn empty_slice_is_empty() {
290        assert_eq!(derive_epic_state(&[]), "empty");
291    }
292
293    #[test]
294    fn all_terminal_is_done() {
295        let a = make_state(true, false, vec![]);
296        let b = make_state(true, false, vec![]);
297        assert_eq!(derive_epic_state(&[&a, &b]), "done");
298    }
299
300    #[test]
301    fn all_satisfies_deps_not_all_terminal_is_implemented() {
302        let a = make_state(false, true, vec![]);
303        let b = make_state(true, false, vec![]);
304        assert_eq!(derive_epic_state(&[&a, &b]), "implemented");
305    }
306
307    #[test]
308    fn any_neither_satisfies_nor_terminal_is_in_progress() {
309        let a = make_state(false, false, vec![]);
310        let b = make_state(true, false, vec![]);
311        assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
312    }
313
314    #[test]
315    fn mixed_non_terminal_non_satisfies_is_in_progress() {
316        let a = make_state(false, false, vec![]);
317        let b = make_state(true, false, vec![]);
318        assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
319    }
320}
321
322pub fn create(root: &Path, title: &str, config: &crate::config::Config) -> Result<String> {
323    let id = crate::ticket_fmt::gen_hex_id();
324    let slug = crate::ticket::slugify(title);
325    let branch = format!("epic/{id}-{slug}");
326
327    let default_branch = &config.project.default_branch;
328    git_util::fetch_branch(root, default_branch)?;
329
330    let unique = std::time::SystemTime::now()
331        .duration_since(std::time::UNIX_EPOCH)
332        .map(|d| d.subsec_nanos())
333        .unwrap_or(0);
334    let wt_path = std::env::temp_dir().join(format!(
335        "apm-{}-{}-{}",
336        std::process::id(),
337        unique,
338        branch.replace('/', "-"),
339    ));
340
341    let wt_path_str = wt_path.to_string_lossy();
342    git_util::run(root, &["worktree", "add", "-b", &branch, &wt_path_str, &format!("origin/{default_branch}")])?;
343
344    let result = (|| -> Result<()> {
345        let epic_md = wt_path.join("tickets/EPIC.md");
346        if let Some(parent) = epic_md.parent() {
347            std::fs::create_dir_all(parent)?;
348        }
349        std::fs::write(&epic_md, format!("# {title}\n"))?;
350
351        git_util::stage_files(&wt_path, &["tickets/EPIC.md"])?;
352
353        let commit_msg = format!("epic({id}): create {title}");
354        git_util::commit(&wt_path, &commit_msg)?;
355        Ok(())
356    })();
357
358    let _ = worktree::remove_worktree(root, &wt_path, true);
359    let _ = std::fs::remove_dir_all(&wt_path);
360
361    result?;
362
363    crate::git::push_branch_tracking(root, &branch)?;
364
365    Ok(branch)
366}
367
368pub fn find_epic_branch(root: &Path, short_id: &str) -> Option<String> {
369    let pattern = format!("epic/{short_id}-*");
370    let local = crate::git_util::run(root, &["branch", "--list", &pattern]).ok()?;
371    for b in local.lines().map(|l| l.trim().trim_start_matches(['*', '+']).trim()) {
372        if !b.is_empty() {
373            return Some(b.to_string());
374        }
375    }
376    let remote_pattern = format!("origin/epic/{short_id}-*");
377    let remote = crate::git_util::run(root, &["branch", "-r", "--list", &remote_pattern]).ok()?;
378    for b in remote.lines().map(|l| l.trim()) {
379        if !b.is_empty() {
380            return Some(b.trim_start_matches("origin/").to_string());
381        }
382    }
383    None
384}
385
386pub fn find_epic_branches(root: &Path, id_prefix: &str) -> Vec<String> {
387    let mut seen = std::collections::HashSet::new();
388    let mut result = Vec::new();
389
390    let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
391    for b in local.lines()
392        .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
393        .filter(|l| !l.is_empty())
394    {
395        let id_part = b.trim_start_matches("epic/").split('-').next().unwrap_or("");
396        if id_part.starts_with(id_prefix) && seen.insert(b.to_string()) {
397            result.push(b.to_string());
398        }
399    }
400
401    let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
402    for b in remote.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
403        let short = b.trim_start_matches("origin/");
404        let id_part = short.trim_start_matches("epic/").split('-').next().unwrap_or("");
405        if id_part.starts_with(id_prefix) && seen.insert(short.to_string()) {
406            result.push(short.to_string());
407        }
408    }
409
410    result
411}
412
413pub fn epic_branches(root: &Path) -> Result<Vec<String>> {
414    let mut seen = std::collections::HashSet::new();
415    let mut branches = Vec::new();
416
417    let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
418    for b in local.lines()
419        .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
420        .filter(|l| !l.is_empty())
421    {
422        if seen.insert(b.to_string()) {
423            branches.push(b.to_string());
424        }
425    }
426
427    let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
428    for b in remote.lines()
429        .map(|l| l.trim().trim_start_matches("origin/").to_string())
430        .filter(|l| !l.is_empty())
431    {
432        if seen.insert(b.clone()) {
433            branches.push(b);
434        }
435    }
436
437    branches.sort();
438    Ok(branches)
439}
440
441pub fn branch_to_title(branch: &str) -> String {
442    let rest = branch.trim_start_matches("epic/");
443    let slug = match rest.find('-') {
444        Some(pos) => &rest[pos + 1..],
445        None => rest,
446    };
447    slug.split('-')
448        .map(|word| {
449            let mut chars = word.chars();
450            match chars.next() {
451                None => String::new(),
452                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
453            }
454        })
455        .collect::<Vec<_>>()
456        .join(" ")
457}
458
459pub fn epic_id_from_branch(branch: &str) -> &str {
460    let rest = branch.trim_start_matches("epic/");
461    match rest.find('-') {
462        Some(pos) => &rest[..pos],
463        None => rest,
464    }
465}
466
467pub fn set_epic_owner(
468    root: &Path,
469    epic_id: &str,
470    new_owner: &str,
471    config: &crate::config::Config,
472) -> Result<(usize, usize)> {
473    let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
474    let terminal = config.terminal_state_ids();
475
476    let (mut to_change, skipped): (Vec<_>, Vec<_>) = all_tickets
477        .into_iter()
478        .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
479        .partition(|t| !terminal.contains(&t.frontmatter.state));
480
481    for t in &to_change {
482        crate::ticket::check_owner(root, t)?;
483    }
484
485    for t in &mut to_change {
486        crate::ticket::set_field(&mut t.frontmatter, "owner", new_owner)?;
487        let content = t.serialize()?;
488        let rel_path = format!(
489            "{}/{}",
490            config.tickets.dir.to_string_lossy(),
491            t.path.file_name().unwrap().to_string_lossy()
492        );
493        let ticket_branch = t.frontmatter.branch.clone()
494            .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path))
495            .unwrap_or_else(|| format!("ticket/{}", t.frontmatter.id));
496        crate::git::commit_to_branch(
497            root,
498            &ticket_branch,
499            &rel_path,
500            &content,
501            &format!("ticket({}): bulk set owner = {}", t.frontmatter.id, new_owner),
502        )?;
503    }
504
505    Ok((to_change.len(), skipped.len()))
506}
507
508pub fn create_epic_branch(root: &Path, title: &str, config: &crate::config::Config) -> Result<(String, String)> {
509    let id = crate::ticket_fmt::gen_hex_id();
510    let slug = crate::ticket::slugify(title);
511    let branch = format!("epic/{id}-{slug}");
512    let default_branch = &config.project.default_branch;
513    let _ = crate::git_util::run(root, &["fetch", "origin", default_branch]);
514    if crate::git_util::run(root, &["branch", &branch, &format!("origin/{default_branch}")]).is_err() {
515        crate::git_util::run(root, &["branch", &branch, default_branch])?;
516    }
517    crate::git_util::commit_to_branch(root, &branch, "tickets/EPIC.md", &format!("# {title}\n"), "epic: init")?;
518    let _ = crate::git_util::push_branch(root, &branch);
519    Ok((id, branch))
520}