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    let impl_states = config.implementation_state_ids();
39    let terminal_states = config.terminal_state_ids();
40
41    for t in all_tickets.iter().filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id)) {
42        let id = &t.frontmatter.id;
43        let title = &t.frontmatter.title;
44        let state_id = &t.frontmatter.state;
45
46        let has_reached_impl = impl_states.contains(state_id.as_str())
47            || crate::ticket_fmt::history_target_states(&t.body)
48                .iter()
49                .any(|s| impl_states.contains(s.as_str()));
50        if has_reached_impl && !terminal_states.contains(state_id.as_str()) {
51            blockers.push(format!("  {id} — {title} (state: {state_id})"));
52            continue;
53        }
54
55        let ticket_branch = t.frontmatter.branch.clone()
56            .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path));
57        if let Some(branch) = ticket_branch {
58            if let Some((wt_path, _)) = worktrees.iter().find(|(_, b)| b == &branch) {
59                let pid_file = wt_path.join(".apm-worker.pid");
60                if pid_file.exists() {
61                    if let Ok((pid, _)) = crate::worker::read_pid_file(&pid_file) {
62                        if crate::worker::is_alive(pid) {
63                            blockers.push(format!("  {id} — {title} (live worker)"));
64                        }
65                    }
66                }
67            }
68        }
69    }
70
71    Ok(blockers)
72}
73
74/// Derive the display state of an epic from the `StateConfig`s of its tickets.
75///
76/// Rules (evaluated in order):
77/// 1. Empty slice → "empty"
78/// 2. Any state has neither `satisfies_deps` nor `terminal` → "in_progress"
79/// 3. All states have `terminal = true` → "done"
80/// 4. All states have `satisfies_deps = true` or `terminal = true`, but not
81///    all are terminal → "implemented"
82/// 5. Otherwise → "in_progress"
83pub fn derive_epic_state(states: &[&StateConfig]) -> &'static str {
84    if states.is_empty() {
85        return "empty";
86    }
87    if states.iter().any(|s| !matches!(s.satisfies_deps, crate::config::SatisfiesDeps::Bool(true)) && !s.terminal) {
88        return "in_progress";
89    }
90    if states.iter().all(|s| s.terminal) {
91        return "done";
92    }
93    "implemented"
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::config::StateConfig;
100
101    #[test]
102    fn branch_to_title_basic() {
103        assert_eq!(branch_to_title("epic/ab12cd34-user-authentication"), "User Authentication");
104    }
105
106    #[test]
107    fn branch_to_title_single_word() {
108        assert_eq!(branch_to_title("epic/ab12cd34-dashboard"), "Dashboard");
109    }
110
111    #[test]
112    fn branch_to_title_many_words() {
113        assert_eq!(branch_to_title("epic/ab12cd34-add-oauth-login-flow"), "Add Oauth Login Flow");
114    }
115
116    #[test]
117    fn branch_to_title_no_slug() {
118        assert_eq!(branch_to_title("epic/ab12cd34"), "Ab12cd34");
119    }
120
121    #[test]
122    fn epic_id_from_branch_happy_path() {
123        assert_eq!(epic_id_from_branch("epic/57bce963-refactor-apm-core"), "57bce963");
124    }
125
126    #[test]
127    fn epic_id_from_branch_no_epic_prefix() {
128        assert_eq!(epic_id_from_branch("57bce963-refactor"), "57bce963");
129    }
130
131    #[test]
132    fn epic_id_from_branch_no_dash() {
133        assert_eq!(epic_id_from_branch("nodash"), "nodash");
134    }
135
136    fn git_cmd(dir: &std::path::Path, args: &[&str]) {
137        std::process::Command::new("git")
138            .args(args)
139            .current_dir(dir)
140            .env("GIT_AUTHOR_NAME", "test")
141            .env("GIT_AUTHOR_EMAIL", "test@test.com")
142            .env("GIT_COMMITTER_NAME", "test")
143            .env("GIT_COMMITTER_EMAIL", "test@test.com")
144            .output()
145            .unwrap();
146    }
147
148    fn setup_repo() -> tempfile::TempDir {
149        let tmp = tempfile::tempdir().unwrap();
150        let p = tmp.path();
151        git_cmd(p, &["init", "-q", "-b", "main"]);
152        git_cmd(p, &["config", "user.email", "test@test.com"]);
153        git_cmd(p, &["config", "user.name", "test"]);
154        // Initial commit so commit_to_branch can use worktrees.
155        std::fs::write(p.join("README.md"), "init\n").unwrap();
156        git_cmd(p, &["add", "README.md"]);
157        git_cmd(p, &["commit", "-m", "init"]);
158        tmp
159    }
160
161    const TOML_WITH_STATES: &str = concat!(
162        "[project]\nname = \"test\"\n\n",
163        "[tickets]\ndir = \"tickets\"\n\n",
164        "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\n\n",
165        "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
166    );
167
168    fn make_ticket_content(id: &str, state: &str, epic: &str) -> String {
169        format!(
170            "+++\nid = \"{id}\"\ntitle = \"Ticket {id}\"\nstate = \"{state}\"\nepic = \"{epic}\"\n+++\n\nBody.\n"
171        )
172    }
173
174    #[test]
175    fn set_epic_owner_updates_non_terminal_skips_terminal() {
176        let tmp = setup_repo();
177        let p = tmp.path();
178        std::fs::create_dir_all(p.join(".apm")).unwrap();
179        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
180        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
181
182        let config = crate::config::Config::load(p).unwrap();
183
184        // Non-terminal ticket in this epic.
185        let content_a = make_ticket_content("aaaa1234", "ready", "epic1234");
186        crate::git::commit_to_branch(p, "ticket/aaaa1234-t1", "tickets/aaaa1234-t1.md", &content_a, "add t1").unwrap();
187
188        // Terminal ticket in this epic — should be skipped.
189        let content_b = make_ticket_content("bbbb5678", "closed", "epic1234");
190        crate::git::commit_to_branch(p, "ticket/bbbb5678-t2", "tickets/bbbb5678-t2.md", &content_b, "add t2").unwrap();
191
192        // Ticket in a different epic — should be ignored.
193        let content_c = make_ticket_content("cccc9012", "ready", "other123");
194        crate::git::commit_to_branch(p, "ticket/cccc9012-t3", "tickets/cccc9012-t3.md", &content_c, "add t3").unwrap();
195
196        let (changed, skipped) = set_epic_owner(p, "epic1234", "alice", &config).unwrap();
197        assert_eq!(changed, 1, "one non-terminal ticket should be changed");
198        assert_eq!(skipped, 1, "one terminal ticket should be skipped");
199    }
200
201    #[test]
202    fn set_epic_owner_all_terminal_returns_zero_changed() {
203        let tmp = setup_repo();
204        let p = tmp.path();
205        std::fs::create_dir_all(p.join(".apm")).unwrap();
206        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
207
208        let config = crate::config::Config::load(p).unwrap();
209
210        let content_a = make_ticket_content("dddd1111", "closed", "epic5678");
211        crate::git::commit_to_branch(p, "ticket/dddd1111-t4", "tickets/dddd1111-t4.md", &content_a, "add t4").unwrap();
212        let content_b = make_ticket_content("eeee2222", "closed", "epic5678");
213        crate::git::commit_to_branch(p, "ticket/eeee2222-t5", "tickets/eeee2222-t5.md", &content_b, "add t5").unwrap();
214
215        let (changed, skipped) = set_epic_owner(p, "epic5678", "bob", &config).unwrap();
216        assert_eq!(changed, 0);
217        assert_eq!(skipped, 2);
218    }
219
220    const TOML_WITH_WORKER_END: &str = concat!(
221        "[project]\nname = \"test\"\n\n",
222        "[tickets]\ndir = \"tickets\"\n\n",
223        "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\nworker_end = false\n\n",
224        "[[workflow.states]]\nid = \"implemented\"\nlabel = \"Implemented\"\nterminal = false\nworker_end = true\n\n",
225        "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
226    );
227
228    const TOML_WITH_IMPL_STATES: &str = r#"
229[project]
230name = "test"
231
232[tickets]
233dir = "tickets"
234
235[[workflow.states]]
236id    = "ready"
237label = "Ready"
238
239  [[workflow.states.transitions]]
240  to             = "in_progress"
241  trigger        = "command:start"
242  worker_profile = "claude/coder"
243
244[[workflow.states]]
245id    = "in_progress"
246label = "In Progress"
247
248  [[workflow.states.transitions]]
249  to         = "implemented"
250  trigger    = "manual"
251  completion = "pr_or_epic_merge"
252
253[[workflow.states]]
254id    = "implemented"
255label = "Implemented"
256
257[[workflow.states]]
258id    = "ammend"
259label = "Ammend"
260
261[[workflow.states]]
262id    = "closed"
263label = "Closed"
264terminal = true
265"#;
266
267    const TOML_WITH_IMPL_STATES_REVERSED: &str = r#"
268[project]
269name = "test"
270
271[tickets]
272dir = "tickets"
273
274[[workflow.states]]
275id    = "closed"
276label = "Closed"
277terminal = true
278
279[[workflow.states]]
280id    = "ammend"
281label = "Ammend"
282
283[[workflow.states]]
284id    = "implemented"
285label = "Implemented"
286
287[[workflow.states]]
288id    = "in_progress"
289label = "In Progress"
290
291  [[workflow.states.transitions]]
292  to         = "implemented"
293  trigger    = "manual"
294  completion = "pr_or_epic_merge"
295
296[[workflow.states]]
297id    = "ready"
298label = "Ready"
299
300  [[workflow.states.transitions]]
301  to             = "in_progress"
302  trigger        = "command:start"
303  worker_profile = "claude/coder"
304"#;
305
306    fn make_ticket_content_with_history(id: &str, state: &str, epic: &str, history: &[(&str, &str)]) -> String {
307        let mut s = format!(
308            "+++\nid = \"{id}\"\ntitle = \"Ticket {id}\"\nstate = \"{state}\"\nepic = \"{epic}\"\n+++\n\nBody.\n"
309        );
310        s.push_str("\n## History\n\n| When | From | To | By |\n|------|------|----|----|\n");
311        for (from, to) in history {
312            s.push_str(&format!("| 2026-01-01T00:00Z | {from} | {to} | test |\n"));
313        }
314        s
315    }
316
317    #[test]
318    fn epic_is_quiescent_all_done() {
319        let tmp = setup_repo();
320        let p = tmp.path();
321        std::fs::create_dir_all(p.join(".apm")).unwrap();
322        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_WORKER_END).unwrap();
323        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
324
325        let config = crate::config::Config::load(p).unwrap();
326
327        let closed = make_ticket_content("aaaa0001", "closed", "epic0001");
328        crate::git::commit_to_branch(p, "ticket/aaaa0001-t1", "tickets/aaaa0001-t1.md", &closed, "add t1").unwrap();
329
330        let implemented = make_ticket_content("bbbb0002", "implemented", "epic0001");
331        crate::git::commit_to_branch(p, "ticket/bbbb0002-t2", "tickets/bbbb0002-t2.md", &implemented, "add t2").unwrap();
332
333        let blockers = epic_is_quiescent(p, "epic0001", &config, &[]).unwrap();
334        assert!(blockers.is_empty(), "expected no blockers, got: {blockers:?}");
335    }
336
337    #[test]
338    fn epic_is_quiescent_state_blocker() {
339        let tmp = setup_repo();
340        let p = tmp.path();
341        std::fs::create_dir_all(p.join(".apm")).unwrap();
342        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
343        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
344
345        let config = crate::config::Config::load(p).unwrap();
346
347        let content = make_ticket_content("cccc0003", "in_progress", "epic0002");
348        crate::git::commit_to_branch(p, "ticket/cccc0003-t3", "tickets/cccc0003-t3.md", &content, "add t3").unwrap();
349
350        let blockers = epic_is_quiescent(p, "epic0002", &config, &[]).unwrap();
351        assert_eq!(blockers.len(), 1);
352        assert!(blockers[0].contains("cccc0003"));
353        assert!(blockers[0].contains("(state: in_progress)"));
354    }
355
356    #[test]
357    fn epic_is_quiescent_live_worker_blocker() {
358        let tmp = setup_repo();
359        let p = tmp.path();
360        std::fs::create_dir_all(p.join(".apm")).unwrap();
361        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_WORKER_END).unwrap();
362        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
363
364        let config = crate::config::Config::load(p).unwrap();
365
366        // Ticket in worker_end state (quiescent by state check).
367        let content = make_ticket_content("dddd0004", "implemented", "epic0003");
368        crate::git::commit_to_branch(p, "ticket/dddd0004-t4", "tickets/dddd0004-t4.md", &content, "add t4").unwrap();
369
370        // Simulate a worktree with a live .apm-worker.pid (current process PID).
371        let wt_path = tmp.path().join("fake-worktree-dddd0004");
372        std::fs::create_dir_all(&wt_path).unwrap();
373        let pid = std::process::id();
374        std::fs::write(
375            wt_path.join(".apm-worker.pid"),
376            format!(r#"{{"pid":{pid},"ticket_id":"dddd0004","started_at":"2026-01-01T00:00:00Z"}}"#),
377        ).unwrap();
378
379        let worktrees = vec![(wt_path, "ticket/dddd0004-t4".to_string())];
380        let blockers = epic_is_quiescent(p, "epic0003", &config, &worktrees).unwrap();
381        assert_eq!(blockers.len(), 1);
382        assert!(blockers[0].contains("dddd0004"));
383        assert!(blockers[0].contains("(live worker)"));
384    }
385
386    #[test]
387    fn epic_is_quiescent_ready_no_history_does_not_block() {
388        let tmp = setup_repo();
389        let p = tmp.path();
390        std::fs::create_dir_all(p.join(".apm")).unwrap();
391        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
392        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
393
394        let config = crate::config::Config::load(p).unwrap();
395
396        let content = make_ticket_content("eeee0005", "ready", "epic0005");
397        crate::git::commit_to_branch(p, "ticket/eeee0005-t5", "tickets/eeee0005-t5.md", &content, "add t5").unwrap();
398
399        let blockers = epic_is_quiescent(p, "epic0005", &config, &[]).unwrap();
400        assert!(blockers.is_empty(), "expected no blockers for ready ticket with no history, got: {blockers:?}");
401    }
402
403    #[test]
404    fn epic_is_quiescent_ammend_with_impl_history_blocks() {
405        let tmp = setup_repo();
406        let p = tmp.path();
407        std::fs::create_dir_all(p.join(".apm")).unwrap();
408        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
409        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
410
411        let config = crate::config::Config::load(p).unwrap();
412
413        let content = make_ticket_content_with_history(
414            "ffff0006", "ammend", "epic0006",
415            &[("groomed", "in_progress"), ("in_progress", "ammend")],
416        );
417        crate::git::commit_to_branch(p, "ticket/ffff0006-t6", "tickets/ffff0006-t6.md", &content, "add t6").unwrap();
418
419        let blockers = epic_is_quiescent(p, "epic0006", &config, &[]).unwrap();
420        assert_eq!(blockers.len(), 1, "expected ammend ticket with impl history to block");
421        assert!(blockers[0].contains("ffff0006"));
422    }
423
424    #[test]
425    fn epic_is_quiescent_closed_with_impl_history_does_not_block() {
426        let tmp = setup_repo();
427        let p = tmp.path();
428        std::fs::create_dir_all(p.join(".apm")).unwrap();
429        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
430        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
431
432        let config = crate::config::Config::load(p).unwrap();
433
434        let content = make_ticket_content_with_history(
435            "gggg0007", "closed", "epic0007",
436            &[("in_progress", "implemented"), ("implemented", "closed")],
437        );
438        crate::git::commit_to_branch(p, "ticket/gggg0007-t7", "tickets/gggg0007-t7.md", &content, "add t7").unwrap();
439
440        let blockers = epic_is_quiescent(p, "epic0007", &config, &[]).unwrap();
441        assert!(blockers.is_empty(), "expected closed ticket with impl history not to block, got: {blockers:?}");
442    }
443
444    #[test]
445    fn epic_is_quiescent_order_invariant() {
446        let tmp = setup_repo();
447        let p = tmp.path();
448        std::fs::create_dir_all(p.join(".apm")).unwrap();
449        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
450
451        // A blocking ticket (in_progress) and a non-blocking ticket (ready, no history).
452        let blocking = make_ticket_content("hhhh0008", "in_progress", "epic0008");
453        crate::git::commit_to_branch(p, "ticket/hhhh0008-t8", "tickets/hhhh0008-t8.md", &blocking, "add t8").unwrap();
454        let non_blocking = make_ticket_content("iiii0009", "ready", "epic0008");
455        crate::git::commit_to_branch(p, "ticket/iiii0009-t9", "tickets/iiii0009-t9.md", &non_blocking, "add t9").unwrap();
456
457        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
458        let config1 = crate::config::Config::load(p).unwrap();
459        let blockers1 = epic_is_quiescent(p, "epic0008", &config1, &[]).unwrap();
460
461        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES_REVERSED).unwrap();
462        let config2 = crate::config::Config::load(p).unwrap();
463        let blockers2 = epic_is_quiescent(p, "epic0008", &config2, &[]).unwrap();
464
465        let mut b1 = blockers1.clone();
466        let mut b2 = blockers2.clone();
467        b1.sort();
468        b2.sort();
469        assert_eq!(b1, b2, "epic_is_quiescent must be invariant to [[workflow.states]] order");
470        assert_eq!(b1.len(), 1, "expected exactly one blocker (the in_progress ticket)");
471    }
472
473    fn make_state(terminal: bool, satisfies_deps: bool, actionable: Vec<&str>) -> StateConfig {
474        StateConfig {
475            id: "x".to_string(),
476            label: "x".to_string(),
477            description: String::new(),
478            terminal,
479            worker_end: false,
480            satisfies_deps: crate::config::SatisfiesDeps::Bool(satisfies_deps),
481            dep_requires: None,
482            transitions: vec![],
483            actionable: actionable.into_iter().map(|s| s.to_string()).collect(),
484        }
485    }
486
487    #[test]
488    fn empty_slice_is_empty() {
489        assert_eq!(derive_epic_state(&[]), "empty");
490    }
491
492    #[test]
493    fn all_terminal_is_done() {
494        let a = make_state(true, false, vec![]);
495        let b = make_state(true, false, vec![]);
496        assert_eq!(derive_epic_state(&[&a, &b]), "done");
497    }
498
499    #[test]
500    fn all_satisfies_deps_not_all_terminal_is_implemented() {
501        let a = make_state(false, true, vec![]);
502        let b = make_state(true, false, vec![]);
503        assert_eq!(derive_epic_state(&[&a, &b]), "implemented");
504    }
505
506    #[test]
507    fn any_neither_satisfies_nor_terminal_is_in_progress() {
508        let a = make_state(false, false, vec![]);
509        let b = make_state(true, false, vec![]);
510        assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
511    }
512
513    #[test]
514    fn mixed_non_terminal_non_satisfies_is_in_progress() {
515        let a = make_state(false, false, vec![]);
516        let b = make_state(true, false, vec![]);
517        assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
518    }
519
520    #[test]
521    fn merge_tree_status_up_to_date() {
522        let tmp = setup_repo();
523        let p = tmp.path();
524        git_cmd(p, &["checkout", "-b", "epic/aa000001-test"]);
525        git_cmd(p, &["checkout", "main"]);
526
527        let status = super::merge_tree_status(p, "main", "epic/aa000001-test").unwrap();
528        assert_eq!(status.ahead, 0);
529        assert!(status.clean);
530    }
531
532    #[test]
533    fn merge_tree_status_clean_merge() {
534        let tmp = setup_repo();
535        let p = tmp.path();
536        git_cmd(p, &["checkout", "-b", "epic/bb000002-test"]);
537        git_cmd(p, &["checkout", "main"]);
538        std::fs::write(p.join("main_only.md"), "content\n").unwrap();
539        git_cmd(p, &["add", "main_only.md"]);
540        git_cmd(p, &["commit", "-m", "add main-only file"]);
541
542        let status = super::merge_tree_status(p, "main", "epic/bb000002-test").unwrap();
543        assert!(status.ahead > 0, "expected main to be ahead");
544        assert!(status.clean, "expected clean merge");
545    }
546
547    #[test]
548    fn merge_tree_status_conflict() {
549        let tmp = setup_repo();
550        let p = tmp.path();
551        std::fs::write(p.join("shared.md"), "original line\n").unwrap();
552        git_cmd(p, &["add", "shared.md"]);
553        git_cmd(p, &["commit", "-m", "add shared file"]);
554
555        git_cmd(p, &["checkout", "-b", "epic/cc000003-test"]);
556        std::fs::write(p.join("shared.md"), "epic branch content\n").unwrap();
557        git_cmd(p, &["add", "shared.md"]);
558        git_cmd(p, &["commit", "-m", "modify shared on epic"]);
559
560        git_cmd(p, &["checkout", "main"]);
561        std::fs::write(p.join("shared.md"), "main branch content\n").unwrap();
562        git_cmd(p, &["add", "shared.md"]);
563        git_cmd(p, &["commit", "-m", "modify shared on main"]);
564
565        let status = super::merge_tree_status(p, "main", "epic/cc000003-test").unwrap();
566        assert!(status.ahead > 0, "expected main to be ahead");
567        assert!(!status.clean, "expected conflicted merge");
568    }
569}
570
571pub fn create(root: &Path, title: &str, config: &crate::config::Config) -> Result<String> {
572    let id = crate::ticket_fmt::gen_hex_id();
573    let slug = crate::ticket::slugify(title);
574    let branch = format!("epic/{id}-{slug}");
575    let default_branch = &config.project.default_branch;
576    let _ = git_util::fetch_branch(root, default_branch);
577    if git_util::run(root, &["branch", &branch, &format!("origin/{default_branch}")]).is_err()
578        && git_util::run(root, &["branch", &branch, default_branch]).is_err()
579    {
580        crate::git::commit_to_branch(root, &branch, "tickets/.gitkeep", "", "epic: init")?;
581    }
582    let _ = crate::git::push_branch_tracking(root, &branch);
583    Ok(branch)
584}
585
586pub fn find_epic_branch(root: &Path, short_id: &str) -> Option<String> {
587    let pattern = format!("epic/{short_id}-*");
588    let local = crate::git_util::run(root, &["branch", "--list", &pattern]).ok()?;
589    for b in local.lines().map(|l| l.trim().trim_start_matches(['*', '+']).trim()) {
590        if !b.is_empty() {
591            return Some(b.to_string());
592        }
593    }
594    let remote_pattern = format!("origin/epic/{short_id}-*");
595    let remote = crate::git_util::run(root, &["branch", "-r", "--list", &remote_pattern]).ok()?;
596    for b in remote.lines().map(|l| l.trim()) {
597        if !b.is_empty() {
598            return Some(b.trim_start_matches("origin/").to_string());
599        }
600    }
601    None
602}
603
604pub fn find_epic_branches(root: &Path, id_prefix: &str) -> Vec<String> {
605    let mut seen = std::collections::HashSet::new();
606    let mut result = Vec::new();
607
608    let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
609    for b in local.lines()
610        .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
611        .filter(|l| !l.is_empty())
612    {
613        let id_part = b.trim_start_matches("epic/").split('-').next().unwrap_or("");
614        if id_part.starts_with(id_prefix) && seen.insert(b.to_string()) {
615            result.push(b.to_string());
616        }
617    }
618
619    let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
620    for b in remote.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
621        let short = b.trim_start_matches("origin/");
622        let id_part = short.trim_start_matches("epic/").split('-').next().unwrap_or("");
623        if id_part.starts_with(id_prefix) && seen.insert(short.to_string()) {
624            result.push(short.to_string());
625        }
626    }
627
628    result
629}
630
631pub fn epic_branches(root: &Path) -> Result<Vec<String>> {
632    let mut seen = std::collections::HashSet::new();
633    let mut branches = Vec::new();
634
635    let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
636    for b in local.lines()
637        .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
638        .filter(|l| !l.is_empty())
639    {
640        if seen.insert(b.to_string()) {
641            branches.push(b.to_string());
642        }
643    }
644
645    let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
646    for b in remote.lines()
647        .map(|l| l.trim().trim_start_matches("origin/").to_string())
648        .filter(|l| !l.is_empty())
649    {
650        if seen.insert(b.clone()) {
651            branches.push(b);
652        }
653    }
654
655    branches.sort();
656    Ok(branches)
657}
658
659pub fn branch_to_title(branch: &str) -> String {
660    let rest = branch.trim_start_matches("epic/");
661    let slug = match rest.find('-') {
662        Some(pos) => &rest[pos + 1..],
663        None => rest,
664    };
665    slug.split('-')
666        .map(|word| {
667            let mut chars = word.chars();
668            match chars.next() {
669                None => String::new(),
670                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
671            }
672        })
673        .collect::<Vec<_>>()
674        .join(" ")
675}
676
677pub fn epic_id_from_branch(branch: &str) -> &str {
678    let rest = branch.trim_start_matches("epic/");
679    match rest.find('-') {
680        Some(pos) => &rest[..pos],
681        None => rest,
682    }
683}
684
685pub fn set_epic_owner(
686    root: &Path,
687    epic_id: &str,
688    new_owner: &str,
689    config: &crate::config::Config,
690) -> Result<(usize, usize)> {
691    let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
692    let terminal = config.terminal_state_ids();
693
694    let (mut to_change, skipped): (Vec<_>, Vec<_>) = all_tickets
695        .into_iter()
696        .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
697        .partition(|t| !terminal.contains(&t.frontmatter.state));
698
699    for t in &to_change {
700        crate::ticket::check_owner(root, t)?;
701    }
702
703    for t in &mut to_change {
704        crate::ticket::set_field(&mut t.frontmatter, "owner", new_owner)?;
705        let content = t.serialize()?;
706        let rel_path = format!(
707            "{}/{}",
708            config.tickets.dir.to_string_lossy(),
709            t.path.file_name().unwrap().to_string_lossy()
710        );
711        let ticket_branch = t.frontmatter.branch.clone()
712            .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path))
713            .unwrap_or_else(|| format!("ticket/{}", t.frontmatter.id));
714        crate::git::commit_to_branch(
715            root,
716            &ticket_branch,
717            &rel_path,
718            &content,
719            &format!("ticket({}): bulk set owner = {}", t.frontmatter.id, new_owner),
720        )?;
721    }
722
723    Ok((to_change.len(), skipped.len()))
724}
725