Skip to main content

apm/cmd/
epic.rs

1use anyhow::{Context, Result};
2use std::io::IsTerminal;
3use std::path::Path;
4use crate::ctx::CmdContext;
5use apm_core::epic::{branch_to_title, epic_id_from_branch};
6
7pub fn run_list(root: &Path) -> Result<()> {
8    let ctx = CmdContext::load(root, false)?;
9
10    let epic_branches = apm_core::epic::epic_branches(root)?;
11    if epic_branches.is_empty() {
12        return Ok(());
13    }
14
15    let tickets = ctx.tickets;
16
17    for branch in &epic_branches {
18        let id = epic_id_from_branch(branch);
19        let title = branch_to_title(branch);
20
21        // Find tickets belonging to this epic.
22        let epic_tickets: Vec<_> = tickets
23            .iter()
24            .filter(|t| t.frontmatter.epic.as_deref() == Some(id))
25            .collect();
26
27        // Collect StateConfig references for each ticket (skip unknown states).
28        let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
29            .iter()
30            .filter_map(|t| ctx.config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
31            .collect();
32
33        let derived = apm_core::epic::derive_epic_state(&state_configs);
34
35        // Build per-state counts (non-zero only).
36        let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
37        for t in &epic_tickets {
38            *counts.entry(t.frontmatter.state.clone()).or_insert(0) += 1;
39        }
40        let counts_str: String = counts
41            .iter()
42            .filter(|(_, &v)| v > 0)
43            .map(|(k, v)| format!("{v} {k}"))
44            .collect::<Vec<_>>()
45            .join(", ");
46
47        println!("{id:<8} [{derived:<12}] {title:<40} {counts_str}");
48    }
49
50    Ok(())
51}
52
53pub fn run_new(root: &Path, title: String) -> Result<()> {
54    let config = apm_core::config::Config::load(root)?;
55    let branch = apm_core::epic::create(root, &title, &config)?;
56    println!("{branch}");
57    Ok(())
58}
59
60pub fn run_close(root: &Path, id_arg: &str) -> Result<()> {
61    let config = CmdContext::load_config_only(root)?;
62
63    // 1. Resolve the epic branch from the id prefix.
64    let matches = apm_core::epic::find_epic_branches(root, id_arg);
65    let epic_branch = match matches.len() {
66        0 => anyhow::bail!("no epic branch found matching '{id_arg}'"),
67        1 => matches.into_iter().next().unwrap(),
68        _ => anyhow::bail!(
69            "ambiguous id '{id_arg}': matches {}\n  {}",
70            matches.len(),
71            matches.join("\n  ")
72        ),
73    };
74
75    // 2. Parse the 8-char epic ID from the branch name: epic/<id>-<slug>
76    let epic_id = epic_id_from_branch(&epic_branch);
77
78    // 3. Load all tickets and find those belonging to this epic.
79    let tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
80    let epic_tickets: Vec<_> = tickets
81        .iter()
82        .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
83        .collect();
84
85    // 4. Gate check: every epic ticket must be in a satisfies_deps or terminal state.
86    let mut not_ready: Vec<String> = Vec::new();
87    for t in &epic_tickets {
88        let state_id = &t.frontmatter.state;
89        let passes = config
90            .workflow
91            .states
92            .iter()
93            .find(|s| &s.id == state_id)
94            .map(|s| matches!(s.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) || s.terminal)
95            .unwrap_or(false);
96        if !passes {
97            not_ready.push(format!("  {} — {} (state: {})", t.frontmatter.id, t.frontmatter.title, state_id));
98        }
99    }
100    if !not_ready.is_empty() {
101        anyhow::bail!(
102            "cannot close epic: the following tickets are not ready:\n{}",
103            not_ready.join("\n")
104        );
105    }
106
107    // 5. Derive a human-readable title from the branch name.
108    let pr_title = branch_to_title(&epic_branch);
109
110    // 6. Push the epic branch and create or reuse an open PR.
111    let default_branch = &config.project.default_branch;
112    apm_core::git::push_branch_tracking(root, &epic_branch)?;
113    let mut messages = vec![];
114    apm_core::github::gh_pr_create_or_update(
115        root,
116        &epic_branch,
117        default_branch,
118        epic_id,
119        &pr_title,
120        &format!("Epic: {epic_branch}"),
121        &mut messages,
122    )?;
123    for m in &messages {
124        println!("{m}");
125    }
126    Ok(())
127}
128
129pub fn run_show(root: &std::path::Path, id_arg: &str, no_aggressive: bool) -> anyhow::Result<()> {
130    let ctx = CmdContext::load(root, no_aggressive)?;
131
132    let matches = apm_core::epic::find_epic_branches(root, id_arg);
133    let branch = match matches.len() {
134        0 => anyhow::bail!("no epic matching '{id_arg}'"),
135        1 => matches.into_iter().next().unwrap(),
136        _ => anyhow::bail!(
137            "ambiguous prefix '{id_arg}', matches:\n  {}",
138            matches.join("\n  ")
139        ),
140    };
141
142    let epic_id = epic_id_from_branch(&branch);
143    let title = branch_to_title(&branch);
144
145    let epic_tickets: Vec<_> = ctx.tickets
146        .iter()
147        .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
148        .collect();
149
150    let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
151        .iter()
152        .filter_map(|t| ctx.config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
153        .collect();
154
155    let derived = apm_core::epic::derive_epic_state(&state_configs);
156
157    println!("Epic:   {title}");
158    println!("Branch: {branch}");
159    println!("State:  {derived}");
160    if let Some(limit) = ctx.config.epic_max_workers(epic_id) {
161        println!("Max workers: {limit}");
162    }
163
164    if epic_tickets.is_empty() {
165        println!();
166        println!("(no tickets)");
167        return Ok(());
168    }
169
170    // Column widths
171    let id_w = 8usize;
172    let state_w = 13usize;
173    let title_w = 32usize;
174
175    println!();
176    println!(
177        "{:<id_w$}  {:<state_w$}  {:<title_w$}  {}",
178        "ID", "State", "Title", "Depends on"
179    );
180    println!(
181        "{:-<id_w$}  {:-<state_w$}  {:-<title_w$}  {}",
182        "", "", "", "----------"
183    );
184
185    for t in &epic_tickets {
186        let fm = &t.frontmatter;
187        let deps = fm
188            .depends_on
189            .as_deref()
190            .map(|d| d.join(", "))
191            .unwrap_or_else(|| "-".to_string());
192        println!(
193            "{:<id_w$}  {:<state_w$}  {:<title_w$}  {}",
194            fm.id, fm.state, fm.title, deps
195        );
196    }
197
198    Ok(())
199}
200
201pub fn run_set(root: &std::path::Path, id_arg: &str, field: &str, value: &str) -> anyhow::Result<()> {
202    if field != "max_workers" && field != "owner" {
203        anyhow::bail!("unknown field {field:?}; valid fields: max_workers, owner");
204    }
205
206    // Validate the epic exists.
207    let matches = apm_core::epic::find_epic_branches(root, id_arg);
208    if matches.is_empty() {
209        eprintln!("error: no epic branch found matching '{id_arg}'");
210        std::process::exit(1);
211    }
212    if matches.len() > 1 {
213        anyhow::bail!(
214            "ambiguous id '{id_arg}': matches {}\n  {}",
215            matches.len(),
216            matches.join("\n  ")
217        );
218    }
219    let branch = &matches[0];
220    let epic_id = epic_id_from_branch(branch).to_string();
221
222    if field == "owner" {
223        let config = apm_core::config::Config::load(root)?;
224
225        // Pre-flight: validate the new owner
226        let local = apm_core::config::LocalConfig::load(root);
227        apm_core::validate::validate_owner(&config, &local, value)?;
228
229        let (changed, skipped) = apm_core::epic::set_epic_owner(root, &epic_id, value, &config)?;
230        println!("updated {changed} ticket(s), skipped {skipped} terminal ticket(s)");
231        return Ok(());
232    }
233
234    let apm_dir = root.join(".apm");
235    let epics_path = apm_dir.join("epics.toml");
236
237    let raw = if epics_path.exists() {
238        std::fs::read_to_string(&epics_path)
239            .with_context(|| format!("cannot read {}", epics_path.display()))?
240    } else {
241        String::new()
242    };
243    let mut doc: toml_edit::DocumentMut = raw.parse()
244        .with_context(|| format!("cannot parse {}", epics_path.display()))?;
245
246    if value == "-" {
247        // Remove max_workers from the epic table.
248        if let Some(epic_tbl) = doc.get_mut(&epic_id) {
249            if let Some(t) = epic_tbl.as_table_mut() {
250                t.remove("max_workers");
251            }
252        }
253    } else {
254        let n: i64 = value.parse().map_err(|_| anyhow::anyhow!("max_workers must be a positive integer, got {value:?}"))?;
255        if n <= 0 {
256            eprintln!("error: max_workers must be ≥ 1, got {n}");
257            std::process::exit(1);
258        }
259
260        // Ensure [<epic_id>] table exists.
261        if doc.get(&epic_id).is_none() {
262            doc.insert(&epic_id, toml_edit::Item::Table(toml_edit::Table::new()));
263        }
264        doc[&epic_id]["max_workers"] = toml_edit::value(n);
265    }
266
267    std::fs::create_dir_all(&apm_dir)?;
268    std::fs::write(&epics_path, doc.to_string())
269        .with_context(|| format!("cannot write {}", epics_path.display()))?;
270    Ok(())
271}
272
273
274pub(crate) fn run_epic_clean(
275    root: &Path,
276    config: &apm_core::config::Config,
277    dry_run: bool,
278    yes: bool,
279) -> Result<()> {
280    // Get local epic branches.
281    let local_output = std::process::Command::new("git")
282        .current_dir(root)
283        .args(["branch", "--list", "epic/*"])
284        .output()?;
285
286    let local_branches: Vec<String> = String::from_utf8_lossy(&local_output.stdout)
287        .lines()
288        .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
289        .filter(|l| !l.is_empty())
290        .collect();
291
292    // Load all tickets.
293    let tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
294
295    // Find epic branches whose derived state is "done".
296    let mut candidates: Vec<String> = Vec::new();
297    for branch in &local_branches {
298        let id = apm_core::epic::epic_id_from_branch(branch);
299
300        let epic_tickets: Vec<_> = tickets
301            .iter()
302            .filter(|t| t.frontmatter.epic.as_deref() == Some(id))
303            .collect();
304
305        let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
306            .iter()
307            .filter_map(|t| config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
308            .collect();
309
310        if apm_core::epic::derive_epic_state(&state_configs) == "done" {
311            candidates.push(branch.clone());
312        }
313    }
314
315    if candidates.is_empty() {
316        println!("Nothing to clean.");
317        return Ok(());
318    }
319
320    // Print candidate list.
321    println!("Would delete {} epic(s):", candidates.len());
322    for branch in &candidates {
323        let id = apm_core::epic::epic_id_from_branch(branch);
324        let title = apm_core::epic::branch_to_title(branch);
325        println!("  {id}  {title}");
326    }
327
328    if dry_run {
329        println!("Dry run — no changes made.");
330        return Ok(());
331    }
332
333    // Confirmation gate.
334    if !yes {
335        if std::io::stdout().is_terminal() {
336            if !crate::util::prompt_yes_no(&format!("Delete {} epic(s)? [y/N] ", candidates.len()))? {
337                println!("Aborted.");
338                return Ok(());
339            }
340        } else {
341            println!("Skipping — non-interactive terminal. Use --yes to confirm.");
342            return Ok(());
343        }
344    }
345
346    // Delete each candidate.
347    let epics_path = root.join(".apm").join("epics.toml");
348    for branch in &candidates {
349        let id = apm_core::epic::epic_id_from_branch(branch).to_string();
350
351        // Remove active worktree before attempting branch deletion.
352        if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
353            if let Err(e) = apm_core::worktree::remove_worktree(root, &wt_path, false) {
354                eprintln!(
355                    "skipping {branch}: could not remove worktree at {}: {e}",
356                    wt_path.display()
357                );
358                continue;
359            }
360        }
361
362        // Delete local branch.
363        let del_local = std::process::Command::new("git")
364            .current_dir(root)
365            .args(["branch", "-d", branch])
366            .output()?;
367        if !del_local.status.success() {
368            eprintln!(
369                "error: failed to delete local branch {branch}: {}",
370                String::from_utf8_lossy(&del_local.stderr).trim()
371            );
372            continue;
373        }
374
375        // Delete remote branch; suppress "remote ref does not exist".
376        let del_remote = std::process::Command::new("git")
377            .current_dir(root)
378            .args(["push", "origin", "--delete", branch])
379            .output()?;
380        if !del_remote.status.success() {
381            let stderr = String::from_utf8_lossy(&del_remote.stderr);
382            if !stderr.contains("remote ref does not exist")
383                && !stderr.contains("error: unable to delete")
384            {
385                eprintln!(
386                    "warning: failed to delete remote {branch}: {}",
387                    stderr.trim()
388                );
389            }
390        }
391
392        println!("deleted {branch}");
393
394        // Remove the epic's entry from .apm/epics.toml.
395        if epics_path.exists() {
396            let raw = std::fs::read_to_string(&epics_path)?;
397            let mut doc: toml_edit::DocumentMut = raw.parse()?;
398            if doc.contains_key(&id) {
399                doc.remove(&id);
400                std::fs::write(&epics_path, doc.to_string())?;
401            }
402        }
403    }
404
405    Ok(())
406}
407
408#[cfg(test)]
409mod tests {
410    // Gate check logic tests
411    #[test]
412    fn gate_check_all_passing() {
413        use apm_core::config::WorkflowConfig;
414
415        let states = vec![
416            make_state("implemented", true, false),
417            make_state("closed", false, true),
418        ];
419        let wf = WorkflowConfig { states, ..Default::default() };
420
421        // Both states satisfy the gate
422        for s in &wf.states {
423            assert!(matches!(s.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) || s.terminal, "state {} should pass", s.id);
424        }
425    }
426
427    #[test]
428    fn gate_check_failing_state() {
429        use apm_core::config::WorkflowConfig;
430
431        let states = vec![
432            make_state("in_progress", false, false),
433            make_state("implemented", true, false),
434        ];
435        let wf = WorkflowConfig { states, ..Default::default() };
436
437        let in_prog = wf.states.iter().find(|s| s.id == "in_progress").unwrap();
438        assert!(!matches!(in_prog.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) && !in_prog.terminal);
439
440        let implemented = wf.states.iter().find(|s| s.id == "implemented").unwrap();
441        assert!(matches!(implemented.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) || implemented.terminal);
442    }
443
444    fn make_state(id: &str, satisfies_deps: bool, terminal: bool) -> apm_core::config::StateConfig {
445        apm_core::config::StateConfig {
446            id: id.to_string(),
447            label: id.to_string(),
448            description: String::new(),
449            terminal,
450            worker_end: false,
451            satisfies_deps: apm_core::config::SatisfiesDeps::Bool(satisfies_deps),
452            dep_requires: None,
453            transitions: vec![],
454            actionable: vec![],
455            instructions: None,
456        }
457    }
458}