Skip to main content

apm_core/
epic.rs

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