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
7#[derive(Debug)]
8pub struct EpicTicketInfo {
9    pub id: String,
10    pub state: String,
11    pub title: String,
12}
13
14pub struct EpicQuiescenceResult {
15    pub unsafe_tickets: Vec<EpicTicketInfo>,
16    pub auto_closeable: Vec<EpicTicketInfo>,
17    pub genuine_blockers: Vec<EpicTicketInfo>,
18}
19
20pub fn classify_epic_quiescence(
21    root: &Path,
22    epic_id: &str,
23    config: &crate::config::Config,
24    worktrees: &[(std::path::PathBuf, String)],
25    epic_branch: &str,
26) -> Result<EpicQuiescenceResult> {
27    let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
28    let terminal = config.terminal_state_ids();
29    let default_branch = &config.project.default_branch;
30
31    let mut unsafe_tickets = Vec::new();
32    let mut auto_closeable = Vec::new();
33    let mut genuine_blockers = Vec::new();
34
35    for t in all_tickets
36        .iter()
37        .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
38        .filter(|t| !terminal.contains(&t.frontmatter.state))
39    {
40        let info = EpicTicketInfo {
41            id: t.frontmatter.id.clone(),
42            state: t.frontmatter.state.clone(),
43            title: t.frontmatter.title.clone(),
44        };
45
46        // 1. Unsafe states always block manually.
47        if info.state == "blocked" || info.state == "question" {
48            unsafe_tickets.push(info);
49            continue;
50        }
51
52        // 2. Live worker → genuine blocker.
53        let ticket_branch = t.frontmatter.branch.clone()
54            .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path));
55        let has_live_worker = ticket_branch.as_ref().and_then(|branch| {
56            worktrees.iter().find(|(_, b)| b == branch)
57        }).map(|(wt_path, _)| {
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                    return crate::worker::is_alive(pid);
62                }
63            }
64            false
65        }).unwrap_or(false);
66
67        if has_live_worker {
68            genuine_blockers.push(info);
69            continue;
70        }
71
72        // 3. Branch merged into epic or default → auto_closeable.
73        let is_merged = ticket_branch.as_ref().map(|branch| {
74            let merged_into_epic = git_util::is_branch_merged_into(root, branch, epic_branch)
75                .unwrap_or(false);
76            let merged_into_default = git_util::is_branch_merged_into(root, branch, default_branch)
77                .unwrap_or(false);
78            merged_into_epic || merged_into_default
79        }).unwrap_or(false);
80
81        if is_merged {
82            auto_closeable.push(info);
83        } else {
84            genuine_blockers.push(info);
85        }
86    }
87
88    unsafe_tickets.sort_by(|a, b| a.id.cmp(&b.id));
89    auto_closeable.sort_by(|a, b| a.id.cmp(&b.id));
90    genuine_blockers.sort_by(|a, b| a.id.cmp(&b.id));
91
92    Ok(EpicQuiescenceResult { unsafe_tickets, auto_closeable, genuine_blockers })
93}
94
95pub struct MergeStatus {
96    pub ahead: usize,
97    pub clean: bool,
98}
99
100pub fn merge_tree_status(root: &Path, default_branch: &str, epic_branch: &str) -> Result<MergeStatus> {
101    let log_out = git_util::run(root, &[
102        "log", "--oneline", "--no-decorate",
103        &format!("{epic_branch}..{default_branch}"),
104    ])?;
105    let ahead = log_out.lines().filter(|l| !l.is_empty()).count();
106    if ahead == 0 {
107        return Ok(MergeStatus { ahead: 0, clean: true });
108    }
109    let merge_base = git_util::run(root, &["merge-base", epic_branch, default_branch])?;
110    let merge_base = merge_base.trim();
111    let merge_tree_out = git_util::run(root, &[
112        "merge-tree", merge_base, default_branch, epic_branch,
113    ])?;
114    let clean = !merge_tree_out.contains("<<<<<<< ");
115    Ok(MergeStatus { ahead, clean })
116}
117
118pub fn epic_is_quiescent(
119    root: &Path,
120    epic_id: &str,
121    config: &crate::config::Config,
122    worktrees: &[(std::path::PathBuf, String)],
123) -> Result<Vec<String>> {
124    let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
125    let mut blockers = Vec::new();
126    let impl_states = config.implementation_state_ids();
127    let terminal_states = config.terminal_state_ids();
128
129    for t in all_tickets.iter().filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id)) {
130        let id = &t.frontmatter.id;
131        let title = &t.frontmatter.title;
132        let state_id = &t.frontmatter.state;
133
134        let has_reached_impl = impl_states.contains(state_id.as_str())
135            || crate::ticket_fmt::history_target_states(&t.body)
136                .iter()
137                .any(|s| impl_states.contains(s.as_str()));
138        if has_reached_impl && !terminal_states.contains(state_id.as_str()) {
139            blockers.push(format!("  {id} — {title} (state: {state_id})"));
140            continue;
141        }
142
143        let ticket_branch = t.frontmatter.branch.clone()
144            .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path));
145        if let Some(branch) = ticket_branch {
146            if let Some((wt_path, _)) = worktrees.iter().find(|(_, b)| b == &branch) {
147                let pid_file = wt_path.join(".apm-worker.pid");
148                if pid_file.exists() {
149                    if let Ok((pid, _)) = crate::worker::read_pid_file(&pid_file) {
150                        if crate::worker::is_alive(pid) {
151                            blockers.push(format!("  {id} — {title} (live worker)"));
152                        }
153                    }
154                }
155            }
156        }
157    }
158
159    Ok(blockers)
160}
161
162/// Derive the display state of an epic from the `StateConfig`s of its tickets.
163///
164/// Rules (evaluated in order):
165/// 1. Empty slice → "empty"
166/// 2. Any state has neither `satisfies_deps` nor `terminal` → "in_progress"
167/// 3. All states have `terminal = true` → "done"
168/// 4. All states have `satisfies_deps = true` or `terminal = true`, but not
169///    all are terminal → "implemented"
170/// 5. Otherwise → "in_progress"
171pub fn derive_epic_state(states: &[&StateConfig]) -> &'static str {
172    if states.is_empty() {
173        return "empty";
174    }
175    if states.iter().any(|s| !matches!(s.satisfies_deps, crate::config::SatisfiesDeps::Bool(true)) && !s.terminal) {
176        return "in_progress";
177    }
178    if states.iter().all(|s| s.terminal) {
179        return "done";
180    }
181    "implemented"
182}
183
184pub fn create(root: &Path, title: &str, config: &crate::config::Config) -> Result<String> {
185    let id = crate::ticket_fmt::gen_hex_id();
186    let slug = crate::ticket::slugify(title);
187    let branch = format!("epic/{id}-{slug}");
188    let default_branch = &config.project.default_branch;
189    let _ = git_util::fetch_branch(root, default_branch);
190    if git_util::run(root, &["branch", &branch, &format!("origin/{default_branch}")]).is_err()
191        && git_util::run(root, &["branch", &branch, default_branch]).is_err()
192    {
193        crate::git::commit_to_branch(root, &branch, "tickets/.gitkeep", "", "epic: init")?;
194    }
195    let _ = crate::git::push_branch_tracking(root, &branch);
196    Ok(branch)
197}
198
199pub fn find_epic_branch(root: &Path, short_id: &str) -> Option<String> {
200    let pattern = format!("epic/{short_id}-*");
201    let local = crate::git_util::run(root, &["branch", "--list", &pattern]).ok()?;
202    for b in local.lines().map(|l| l.trim().trim_start_matches(['*', '+']).trim()) {
203        if !b.is_empty() {
204            return Some(b.to_string());
205        }
206    }
207    let remote_pattern = format!("origin/epic/{short_id}-*");
208    let remote = crate::git_util::run(root, &["branch", "-r", "--list", &remote_pattern]).ok()?;
209    for b in remote.lines().map(|l| l.trim()) {
210        if !b.is_empty() {
211            return Some(b.trim_start_matches("origin/").to_string());
212        }
213    }
214    None
215}
216
217pub fn find_epic_branches(root: &Path, id_prefix: &str) -> Vec<String> {
218    let mut seen = std::collections::HashSet::new();
219    let mut result = Vec::new();
220
221    let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
222    for b in local.lines()
223        .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
224        .filter(|l| !l.is_empty())
225    {
226        let id_part = b.trim_start_matches("epic/").split('-').next().unwrap_or("");
227        if id_part.starts_with(id_prefix) && seen.insert(b.to_string()) {
228            result.push(b.to_string());
229        }
230    }
231
232    let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
233    for b in remote.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
234        let short = b.trim_start_matches("origin/");
235        let id_part = short.trim_start_matches("epic/").split('-').next().unwrap_or("");
236        if id_part.starts_with(id_prefix) && seen.insert(short.to_string()) {
237            result.push(short.to_string());
238        }
239    }
240
241    result
242}
243
244pub fn epic_branches(root: &Path) -> Result<Vec<String>> {
245    let mut seen = std::collections::HashSet::new();
246    let mut branches = Vec::new();
247
248    let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
249    for b in local.lines()
250        .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
251        .filter(|l| !l.is_empty())
252    {
253        if seen.insert(b.to_string()) {
254            branches.push(b.to_string());
255        }
256    }
257
258    let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
259    for b in remote.lines()
260        .map(|l| l.trim().trim_start_matches("origin/").to_string())
261        .filter(|l| !l.is_empty())
262    {
263        if seen.insert(b.clone()) {
264            branches.push(b);
265        }
266    }
267
268    branches.sort();
269    Ok(branches)
270}
271
272pub fn branch_to_title(branch: &str) -> String {
273    let rest = branch.trim_start_matches("epic/");
274    let slug = match rest.find('-') {
275        Some(pos) => &rest[pos + 1..],
276        None => rest,
277    };
278    slug.split('-')
279        .map(|word| {
280            let mut chars = word.chars();
281            match chars.next() {
282                None => String::new(),
283                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
284            }
285        })
286        .collect::<Vec<_>>()
287        .join(" ")
288}
289
290pub fn epic_id_from_branch(branch: &str) -> &str {
291    let rest = branch.trim_start_matches("epic/");
292    match rest.find('-') {
293        Some(pos) => &rest[..pos],
294        None => rest,
295    }
296}
297
298pub fn set_epic_owner(
299    root: &Path,
300    epic_id: &str,
301    new_owner: &str,
302    config: &crate::config::Config,
303) -> Result<(usize, usize)> {
304    let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
305    let terminal = config.terminal_state_ids();
306
307    let (mut to_change, skipped): (Vec<_>, Vec<_>) = all_tickets
308        .into_iter()
309        .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
310        .partition(|t| !terminal.contains(&t.frontmatter.state));
311
312    for t in &to_change {
313        crate::ticket::check_owner(root, t)?;
314    }
315
316    for t in &mut to_change {
317        crate::ticket::set_field(&mut t.frontmatter, "owner", new_owner)?;
318        let content = t.serialize()?;
319        let rel_path = format!(
320            "{}/{}",
321            config.tickets.dir.to_string_lossy(),
322            t.path.file_name().unwrap().to_string_lossy()
323        );
324        let ticket_branch = t.frontmatter.branch.clone()
325            .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path))
326            .unwrap_or_else(|| format!("ticket/{}", t.frontmatter.id));
327        crate::git::commit_to_branch(
328            root,
329            &ticket_branch,
330            &rel_path,
331            &content,
332            &format!("ticket({}): bulk set owner = {}", t.frontmatter.id, new_owner),
333        )?;
334    }
335
336    Ok((to_change.len(), skipped.len()))
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::config::StateConfig;
343
344    #[test]
345    fn branch_to_title_basic() {
346        assert_eq!(branch_to_title("epic/ab12cd34-user-authentication"), "User Authentication");
347    }
348
349    #[test]
350    fn branch_to_title_single_word() {
351        assert_eq!(branch_to_title("epic/ab12cd34-dashboard"), "Dashboard");
352    }
353
354    #[test]
355    fn branch_to_title_many_words() {
356        assert_eq!(branch_to_title("epic/ab12cd34-add-oauth-login-flow"), "Add Oauth Login Flow");
357    }
358
359    #[test]
360    fn branch_to_title_no_slug() {
361        assert_eq!(branch_to_title("epic/ab12cd34"), "Ab12cd34");
362    }
363
364    #[test]
365    fn epic_id_from_branch_happy_path() {
366        assert_eq!(epic_id_from_branch("epic/57bce963-refactor-apm-core"), "57bce963");
367    }
368
369    #[test]
370    fn epic_id_from_branch_no_epic_prefix() {
371        assert_eq!(epic_id_from_branch("57bce963-refactor"), "57bce963");
372    }
373
374    #[test]
375    fn epic_id_from_branch_no_dash() {
376        assert_eq!(epic_id_from_branch("nodash"), "nodash");
377    }
378
379    fn git_cmd(dir: &std::path::Path, args: &[&str]) {
380        std::process::Command::new("git")
381            .args(args)
382            .current_dir(dir)
383            .env("GIT_AUTHOR_NAME", "test")
384            .env("GIT_AUTHOR_EMAIL", "test@test.com")
385            .env("GIT_COMMITTER_NAME", "test")
386            .env("GIT_COMMITTER_EMAIL", "test@test.com")
387            .output()
388            .unwrap();
389    }
390
391    fn setup_repo() -> tempfile::TempDir {
392        let tmp = tempfile::tempdir().unwrap();
393        let p = tmp.path();
394        git_cmd(p, &["init", "-q", "-b", "main"]);
395        git_cmd(p, &["config", "user.email", "test@test.com"]);
396        git_cmd(p, &["config", "user.name", "test"]);
397        // Initial commit so commit_to_branch can use worktrees.
398        std::fs::write(p.join("README.md"), "init\n").unwrap();
399        git_cmd(p, &["add", "README.md"]);
400        git_cmd(p, &["commit", "-m", "init"]);
401        tmp
402    }
403
404    const TOML_WITH_STATES: &str = concat!(
405        "[project]\nname = \"test\"\n\n",
406        "[tickets]\ndir = \"tickets\"\n\n",
407        "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\n\n",
408        "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
409    );
410
411    fn make_ticket_content(id: &str, state: &str, epic: &str) -> String {
412        format!(
413            "+++\nid = \"{id}\"\ntitle = \"Ticket {id}\"\nstate = \"{state}\"\nepic = \"{epic}\"\n+++\n\nBody.\n"
414        )
415    }
416
417    #[test]
418    fn set_epic_owner_updates_non_terminal_skips_terminal() {
419        let tmp = setup_repo();
420        let p = tmp.path();
421        std::fs::create_dir_all(p.join(".apm")).unwrap();
422        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
423        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
424
425        let config = crate::config::Config::load(p).unwrap();
426
427        // Non-terminal ticket in this epic.
428        let content_a = make_ticket_content("aaaa1234", "ready", "epic1234");
429        crate::git::commit_to_branch(p, "ticket/aaaa1234-t1", "tickets/aaaa1234-t1.md", &content_a, "add t1").unwrap();
430
431        // Terminal ticket in this epic — should be skipped.
432        let content_b = make_ticket_content("bbbb5678", "closed", "epic1234");
433        crate::git::commit_to_branch(p, "ticket/bbbb5678-t2", "tickets/bbbb5678-t2.md", &content_b, "add t2").unwrap();
434
435        // Ticket in a different epic — should be ignored.
436        let content_c = make_ticket_content("cccc9012", "ready", "other123");
437        crate::git::commit_to_branch(p, "ticket/cccc9012-t3", "tickets/cccc9012-t3.md", &content_c, "add t3").unwrap();
438
439        let (changed, skipped) = set_epic_owner(p, "epic1234", "alice", &config).unwrap();
440        assert_eq!(changed, 1, "one non-terminal ticket should be changed");
441        assert_eq!(skipped, 1, "one terminal ticket should be skipped");
442    }
443
444    #[test]
445    fn set_epic_owner_all_terminal_returns_zero_changed() {
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/config.toml"), TOML_WITH_STATES).unwrap();
450
451        let config = crate::config::Config::load(p).unwrap();
452
453        let content_a = make_ticket_content("dddd1111", "closed", "epic5678");
454        crate::git::commit_to_branch(p, "ticket/dddd1111-t4", "tickets/dddd1111-t4.md", &content_a, "add t4").unwrap();
455        let content_b = make_ticket_content("eeee2222", "closed", "epic5678");
456        crate::git::commit_to_branch(p, "ticket/eeee2222-t5", "tickets/eeee2222-t5.md", &content_b, "add t5").unwrap();
457
458        let (changed, skipped) = set_epic_owner(p, "epic5678", "bob", &config).unwrap();
459        assert_eq!(changed, 0);
460        assert_eq!(skipped, 2);
461    }
462
463    #[test]
464    fn classify_epic_quiescence_all_closed_returns_empty() {
465        let tmp = setup_repo();
466        let p = tmp.path();
467        std::fs::create_dir_all(p.join(".apm")).unwrap();
468        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
469
470        let config = crate::config::Config::load(p).unwrap();
471
472        // Create epic branch.
473        git_cmd(p, &["checkout", "-b", "epic/qq110000-test"]);
474        git_cmd(p, &["checkout", "main"]);
475
476        let content_a = make_ticket_content("qq110001", "closed", "qq110000");
477        crate::git::commit_to_branch(p, "ticket/qq110001-ta", "tickets/qq110001-ta.md", &content_a, "add ta").unwrap();
478        let content_b = make_ticket_content("qq110002", "closed", "qq110000");
479        crate::git::commit_to_branch(p, "ticket/qq110002-tb", "tickets/qq110002-tb.md", &content_b, "add tb").unwrap();
480
481        let result = classify_epic_quiescence(p, "qq110000", &config, &[], "epic/qq110000-test").unwrap();
482        assert!(result.unsafe_tickets.is_empty(), "unsafe should be empty");
483        assert!(result.auto_closeable.is_empty(), "auto_closeable should be empty");
484        assert!(result.genuine_blockers.is_empty(), "genuine_blockers should be empty");
485    }
486
487    #[test]
488    fn classify_epic_quiescence_ignores_other_epics() {
489        let tmp = setup_repo();
490        let p = tmp.path();
491        std::fs::create_dir_all(p.join(".apm")).unwrap();
492        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
493
494        let config = crate::config::Config::load(p).unwrap();
495
496        git_cmd(p, &["checkout", "-b", "epic/qq120000-test"]);
497        git_cmd(p, &["checkout", "main"]);
498
499        // Non-terminal in target epic.
500        let content_a = make_ticket_content("qq120001", "ready", "qq120000");
501        crate::git::commit_to_branch(p, "ticket/qq120001-ta", "tickets/qq120001-ta.md", &content_a, "add ta").unwrap();
502        // Non-terminal in different epic — must be ignored.
503        let content_b = make_ticket_content("qq120002", "ready", "otherep0");
504        crate::git::commit_to_branch(p, "ticket/qq120002-tb", "tickets/qq120002-tb.md", &content_b, "add tb").unwrap();
505
506        let result = classify_epic_quiescence(p, "qq120000", &config, &[], "epic/qq120000-test").unwrap();
507        let all: Vec<_> = result.unsafe_tickets.iter()
508            .chain(result.auto_closeable.iter())
509            .chain(result.genuine_blockers.iter())
510            .collect();
511        assert_eq!(all.len(), 1, "only ticket qq120001 should be classified");
512        assert_eq!(all[0].id, "qq120001");
513    }
514
515    #[test]
516    fn classify_epic_quiescence_three_buckets() {
517        let tmp = setup_repo();
518        let p = tmp.path();
519        std::fs::create_dir_all(p.join(".apm")).unwrap();
520        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
521
522        let config = crate::config::Config::load(p).unwrap();
523
524        // Create epic branch.
525        git_cmd(p, &["checkout", "-b", "epic/qq130000-test"]);
526        git_cmd(p, &["checkout", "main"]);
527
528        // 1. blocked ticket → unsafe_tickets.
529        let content_blocked = make_ticket_content("qq130001", "blocked", "qq130000");
530        crate::git::commit_to_branch(p, "ticket/qq130001-t-blocked", "tickets/qq130001-t-blocked.md", &content_blocked, "add blocked").unwrap();
531
532        // 2. ready ticket whose branch is merged into the epic branch → auto_closeable.
533        let content_merged = make_ticket_content("qq130002", "ready", "qq130000");
534        crate::git::commit_to_branch(p, "ticket/qq130002-t-merged", "tickets/qq130002-t-merged.md", &content_merged, "add merged").unwrap();
535        // Merge the ticket branch into the epic branch.
536        git_cmd(p, &["checkout", "epic/qq130000-test"]);
537        git_cmd(p, &["merge", "--no-ff", "ticket/qq130002-t-merged", "-m", "merge ticket qq130002"]);
538        git_cmd(p, &["checkout", "main"]);
539
540        // 3. ready ticket with an unmerged branch → genuine_blockers.
541        let content_unmerged = make_ticket_content("qq130003", "ready", "qq130000");
542        crate::git::commit_to_branch(p, "ticket/qq130003-t-unmerged", "tickets/qq130003-t-unmerged.md", &content_unmerged, "add unmerged").unwrap();
543
544        let result = classify_epic_quiescence(p, "qq130000", &config, &[], "epic/qq130000-test").unwrap();
545        assert_eq!(result.unsafe_tickets.len(), 1, "one unsafe ticket expected");
546        assert_eq!(result.unsafe_tickets[0].id, "qq130001");
547        assert_eq!(result.auto_closeable.len(), 1, "one auto_closeable ticket expected");
548        assert_eq!(result.auto_closeable[0].id, "qq130002");
549        assert_eq!(result.genuine_blockers.len(), 1, "one genuine blocker expected");
550        assert_eq!(result.genuine_blockers[0].id, "qq130003");
551    }
552
553    const TOML_WITH_WORKER_END: &str = concat!(
554        "[project]\nname = \"test\"\n\n",
555        "[tickets]\ndir = \"tickets\"\n\n",
556        "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\nworker_end = false\n\n",
557        "[[workflow.states]]\nid = \"implemented\"\nlabel = \"Implemented\"\nterminal = false\nworker_end = true\n\n",
558        "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
559    );
560
561    const TOML_WITH_IMPL_STATES: &str = r#"
562[project]
563name = "test"
564
565[tickets]
566dir = "tickets"
567
568[[workflow.states]]
569id    = "ready"
570label = "Ready"
571
572  [[workflow.states.transitions]]
573  to      = "in_progress"
574  trigger = "command:start"
575
576[[workflow.states]]
577id             = "in_progress"
578label          = "In Progress"
579worker_profile = "claude/coder"
580
581  [[workflow.states.transitions]]
582  to         = "implemented"
583  trigger    = "manual"
584  completion = "pr_or_epic_merge"
585
586[[workflow.states]]
587id    = "implemented"
588label = "Implemented"
589
590[[workflow.states]]
591id    = "ammend"
592label = "Ammend"
593
594[[workflow.states]]
595id    = "closed"
596label = "Closed"
597terminal = true
598"#;
599
600    const TOML_WITH_IMPL_STATES_REVERSED: &str = r#"
601[project]
602name = "test"
603
604[tickets]
605dir = "tickets"
606
607[[workflow.states]]
608id    = "closed"
609label = "Closed"
610terminal = true
611
612[[workflow.states]]
613id    = "ammend"
614label = "Ammend"
615
616[[workflow.states]]
617id    = "implemented"
618label = "Implemented"
619
620[[workflow.states]]
621id             = "in_progress"
622label          = "In Progress"
623worker_profile = "claude/coder"
624
625  [[workflow.states.transitions]]
626  to         = "implemented"
627  trigger    = "manual"
628  completion = "pr_or_epic_merge"
629
630[[workflow.states]]
631id    = "ready"
632label = "Ready"
633
634  [[workflow.states.transitions]]
635  to      = "in_progress"
636  trigger = "command:start"
637"#;
638
639    fn make_ticket_content_with_history(id: &str, state: &str, epic: &str, history: &[(&str, &str)]) -> String {
640        let mut s = format!(
641            "+++\nid = \"{id}\"\ntitle = \"Ticket {id}\"\nstate = \"{state}\"\nepic = \"{epic}\"\n+++\n\nBody.\n"
642        );
643        s.push_str("\n## History\n\n| When | From | To | By |\n|------|------|----|----|\n");
644        for (from, to) in history {
645            s.push_str(&format!("| 2026-01-01T00:00Z | {from} | {to} | test |\n"));
646        }
647        s
648    }
649
650    #[test]
651    fn epic_is_quiescent_all_done() {
652        let tmp = setup_repo();
653        let p = tmp.path();
654        std::fs::create_dir_all(p.join(".apm")).unwrap();
655        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_WORKER_END).unwrap();
656        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
657
658        let config = crate::config::Config::load(p).unwrap();
659
660        let closed = make_ticket_content("aaaa0001", "closed", "epic0001");
661        crate::git::commit_to_branch(p, "ticket/aaaa0001-t1", "tickets/aaaa0001-t1.md", &closed, "add t1").unwrap();
662
663        let implemented = make_ticket_content("bbbb0002", "implemented", "epic0001");
664        crate::git::commit_to_branch(p, "ticket/bbbb0002-t2", "tickets/bbbb0002-t2.md", &implemented, "add t2").unwrap();
665
666        let blockers = epic_is_quiescent(p, "epic0001", &config, &[]).unwrap();
667        assert!(blockers.is_empty(), "expected no blockers, got: {blockers:?}");
668    }
669
670    #[test]
671    fn epic_is_quiescent_state_blocker() {
672        let tmp = setup_repo();
673        let p = tmp.path();
674        std::fs::create_dir_all(p.join(".apm")).unwrap();
675        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
676        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
677
678        let config = crate::config::Config::load(p).unwrap();
679
680        let content = make_ticket_content("cccc0003", "in_progress", "epic0002");
681        crate::git::commit_to_branch(p, "ticket/cccc0003-t3", "tickets/cccc0003-t3.md", &content, "add t3").unwrap();
682
683        let blockers = epic_is_quiescent(p, "epic0002", &config, &[]).unwrap();
684        assert_eq!(blockers.len(), 1);
685        assert!(blockers[0].contains("cccc0003"));
686        assert!(blockers[0].contains("(state: in_progress)"));
687    }
688
689    #[test]
690    fn epic_is_quiescent_live_worker_blocker() {
691        let tmp = setup_repo();
692        let p = tmp.path();
693        std::fs::create_dir_all(p.join(".apm")).unwrap();
694        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_WORKER_END).unwrap();
695        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
696
697        let config = crate::config::Config::load(p).unwrap();
698
699        // Ticket in worker_end state (quiescent by state check).
700        let content = make_ticket_content("dddd0004", "implemented", "epic0003");
701        crate::git::commit_to_branch(p, "ticket/dddd0004-t4", "tickets/dddd0004-t4.md", &content, "add t4").unwrap();
702
703        // Simulate a worktree with a live .apm-worker.pid (current process PID).
704        let wt_path = tmp.path().join("fake-worktree-dddd0004");
705        std::fs::create_dir_all(&wt_path).unwrap();
706        let pid = std::process::id();
707        std::fs::write(
708            wt_path.join(".apm-worker.pid"),
709            format!(r#"{{"pid":{pid},"ticket_id":"dddd0004","started_at":"2026-01-01T00:00:00Z"}}"#),
710        ).unwrap();
711
712        let worktrees = vec![(wt_path, "ticket/dddd0004-t4".to_string())];
713        let blockers = epic_is_quiescent(p, "epic0003", &config, &worktrees).unwrap();
714        assert_eq!(blockers.len(), 1);
715        assert!(blockers[0].contains("dddd0004"));
716        assert!(blockers[0].contains("(live worker)"));
717    }
718
719    #[test]
720    fn epic_is_quiescent_ready_no_history_does_not_block() {
721        let tmp = setup_repo();
722        let p = tmp.path();
723        std::fs::create_dir_all(p.join(".apm")).unwrap();
724        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
725        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
726
727        let config = crate::config::Config::load(p).unwrap();
728
729        let content = make_ticket_content("eeee0005", "ready", "epic0005");
730        crate::git::commit_to_branch(p, "ticket/eeee0005-t5", "tickets/eeee0005-t5.md", &content, "add t5").unwrap();
731
732        let blockers = epic_is_quiescent(p, "epic0005", &config, &[]).unwrap();
733        assert!(blockers.is_empty(), "expected no blockers for ready ticket with no history, got: {blockers:?}");
734    }
735
736    #[test]
737    fn epic_is_quiescent_ammend_with_impl_history_blocks() {
738        let tmp = setup_repo();
739        let p = tmp.path();
740        std::fs::create_dir_all(p.join(".apm")).unwrap();
741        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
742        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
743
744        let config = crate::config::Config::load(p).unwrap();
745
746        let content = make_ticket_content_with_history(
747            "ffff0006", "ammend", "epic0006",
748            &[("groomed", "in_progress"), ("in_progress", "ammend")],
749        );
750        crate::git::commit_to_branch(p, "ticket/ffff0006-t6", "tickets/ffff0006-t6.md", &content, "add t6").unwrap();
751
752        let blockers = epic_is_quiescent(p, "epic0006", &config, &[]).unwrap();
753        assert_eq!(blockers.len(), 1, "expected ammend ticket with impl history to block");
754        assert!(blockers[0].contains("ffff0006"));
755    }
756
757    #[test]
758    fn epic_is_quiescent_closed_with_impl_history_does_not_block() {
759        let tmp = setup_repo();
760        let p = tmp.path();
761        std::fs::create_dir_all(p.join(".apm")).unwrap();
762        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
763        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
764
765        let config = crate::config::Config::load(p).unwrap();
766
767        let content = make_ticket_content_with_history(
768            "gggg0007", "closed", "epic0007",
769            &[("in_progress", "implemented"), ("implemented", "closed")],
770        );
771        crate::git::commit_to_branch(p, "ticket/gggg0007-t7", "tickets/gggg0007-t7.md", &content, "add t7").unwrap();
772
773        let blockers = epic_is_quiescent(p, "epic0007", &config, &[]).unwrap();
774        assert!(blockers.is_empty(), "expected closed ticket with impl history not to block, got: {blockers:?}");
775    }
776
777    #[test]
778    fn epic_is_quiescent_order_invariant() {
779        let tmp = setup_repo();
780        let p = tmp.path();
781        std::fs::create_dir_all(p.join(".apm")).unwrap();
782        std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
783
784        // A blocking ticket (in_progress) and a non-blocking ticket (ready, no history).
785        let blocking = make_ticket_content("hhhh0008", "in_progress", "epic0008");
786        crate::git::commit_to_branch(p, "ticket/hhhh0008-t8", "tickets/hhhh0008-t8.md", &blocking, "add t8").unwrap();
787        let non_blocking = make_ticket_content("iiii0009", "ready", "epic0008");
788        crate::git::commit_to_branch(p, "ticket/iiii0009-t9", "tickets/iiii0009-t9.md", &non_blocking, "add t9").unwrap();
789
790        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
791        let config1 = crate::config::Config::load(p).unwrap();
792        let blockers1 = epic_is_quiescent(p, "epic0008", &config1, &[]).unwrap();
793
794        std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES_REVERSED).unwrap();
795        let config2 = crate::config::Config::load(p).unwrap();
796        let blockers2 = epic_is_quiescent(p, "epic0008", &config2, &[]).unwrap();
797
798        let mut b1 = blockers1.clone();
799        let mut b2 = blockers2.clone();
800        b1.sort();
801        b2.sort();
802        assert_eq!(b1, b2, "epic_is_quiescent must be invariant to [[workflow.states]] order");
803        assert_eq!(b1.len(), 1, "expected exactly one blocker (the in_progress ticket)");
804    }
805
806    fn make_state(terminal: bool, satisfies_deps: bool) -> StateConfig {
807        StateConfig {
808            id: "x".to_string(),
809            label: "x".to_string(),
810            description: String::new(),
811            terminal,
812            worker_end: false,
813            satisfies_deps: crate::config::SatisfiesDeps::Bool(satisfies_deps),
814            dep_requires: None,
815            worker_profile: None,
816            transitions: vec![],
817        }
818    }
819
820    #[test]
821    fn empty_slice_is_empty() {
822        assert_eq!(derive_epic_state(&[]), "empty");
823    }
824
825    #[test]
826    fn all_terminal_is_done() {
827        let a = make_state(true, false);
828        let b = make_state(true, false);
829        assert_eq!(derive_epic_state(&[&a, &b]), "done");
830    }
831
832    #[test]
833    fn all_satisfies_deps_not_all_terminal_is_implemented() {
834        let a = make_state(false, true);
835        let b = make_state(true, false);
836        assert_eq!(derive_epic_state(&[&a, &b]), "implemented");
837    }
838
839    #[test]
840    fn any_neither_satisfies_nor_terminal_is_in_progress() {
841        let a = make_state(false, false);
842        let b = make_state(true, false);
843        assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
844    }
845
846    #[test]
847    fn mixed_non_terminal_non_satisfies_is_in_progress() {
848        let a = make_state(false, false);
849        let b = make_state(true, false);
850        assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
851    }
852
853    #[test]
854    fn merge_tree_status_up_to_date() {
855        let tmp = setup_repo();
856        let p = tmp.path();
857        git_cmd(p, &["checkout", "-b", "epic/aa000001-test"]);
858        git_cmd(p, &["checkout", "main"]);
859
860        let status = super::merge_tree_status(p, "main", "epic/aa000001-test").unwrap();
861        assert_eq!(status.ahead, 0);
862        assert!(status.clean);
863    }
864
865    #[test]
866    fn merge_tree_status_clean_merge() {
867        let tmp = setup_repo();
868        let p = tmp.path();
869        git_cmd(p, &["checkout", "-b", "epic/bb000002-test"]);
870        git_cmd(p, &["checkout", "main"]);
871        std::fs::write(p.join("main_only.md"), "content\n").unwrap();
872        git_cmd(p, &["add", "main_only.md"]);
873        git_cmd(p, &["commit", "-m", "add main-only file"]);
874
875        let status = super::merge_tree_status(p, "main", "epic/bb000002-test").unwrap();
876        assert!(status.ahead > 0, "expected main to be ahead");
877        assert!(status.clean, "expected clean merge");
878    }
879
880    #[test]
881    fn merge_tree_status_conflict() {
882        let tmp = setup_repo();
883        let p = tmp.path();
884        std::fs::write(p.join("shared.md"), "original line\n").unwrap();
885        git_cmd(p, &["add", "shared.md"]);
886        git_cmd(p, &["commit", "-m", "add shared file"]);
887
888        git_cmd(p, &["checkout", "-b", "epic/cc000003-test"]);
889        std::fs::write(p.join("shared.md"), "epic branch content\n").unwrap();
890        git_cmd(p, &["add", "shared.md"]);
891        git_cmd(p, &["commit", "-m", "modify shared on epic"]);
892
893        git_cmd(p, &["checkout", "main"]);
894        std::fs::write(p.join("shared.md"), "main branch content\n").unwrap();
895        git_cmd(p, &["add", "shared.md"]);
896        git_cmd(p, &["commit", "-m", "modify shared on main"]);
897
898        let status = super::merge_tree_status(p, "main", "epic/cc000003-test").unwrap();
899        assert!(status.ahead > 0, "expected main to be ahead");
900        assert!(!status.clean, "expected conflicted merge");
901    }
902}
903