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