Skip to main content

apm/cmd/
epic.rs

1use anyhow::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. Quiescence check: no ticket may be in an active state or have a live worker.
79    let worktrees = apm_core::worktree::list_ticket_worktrees(root)?;
80    let blockers = apm_core::epic::epic_is_quiescent(root, epic_id, &config, &worktrees)?;
81    if !blockers.is_empty() {
82        anyhow::bail!(
83            "cannot close epic: the following tickets are not quiescent:\n{}",
84            blockers.join("\n")
85        );
86    }
87
88    // 4. Derive a human-readable title from the branch name.
89    let pr_title = branch_to_title(&epic_branch);
90
91    // 5. Push the epic branch and create or reuse an open PR.
92    let default_branch = &config.project.default_branch;
93    apm_core::git::push_branch_tracking(root, &epic_branch)?;
94    let mut messages = vec![];
95    apm_core::github::gh_pr_create_or_update(
96        root,
97        &epic_branch,
98        default_branch,
99        epic_id,
100        &pr_title,
101        &format!("Epic: {epic_branch}"),
102        &mut messages,
103    )?;
104    for m in &messages {
105        println!("{m}");
106    }
107    Ok(())
108}
109
110pub fn run_refresh_epic(root: &Path, id_arg: &str) -> Result<()> {
111    let config = CmdContext::load_config_only(root)?;
112
113    let matches = apm_core::epic::find_epic_branches(root, id_arg);
114    let epic_branch = match matches.len() {
115        0 => anyhow::bail!("no epic branch found matching '{id_arg}'"),
116        1 => matches.into_iter().next().unwrap(),
117        _ => anyhow::bail!(
118            "ambiguous id '{id_arg}': matches {}\n  {}",
119            matches.len(),
120            matches.join("\n  ")
121        ),
122    };
123
124    let epic_id = epic_id_from_branch(&epic_branch);
125
126    let worktrees = apm_core::worktree::list_ticket_worktrees(root)?;
127    let blockers = apm_core::epic::epic_is_quiescent(root, epic_id, &config, &worktrees)?;
128    if !blockers.is_empty() {
129        anyhow::bail!(
130            "cannot refresh epic: the following tickets are not quiescent:\n{}",
131            blockers.join("\n")
132        );
133    }
134
135    let default_branch = &config.project.default_branch;
136
137    let log_out = std::process::Command::new("git")
138        .current_dir(root)
139        .args(["log", "--oneline", "--no-decorate", &format!("{epic_branch}..{default_branch}")])
140        .output()?;
141    if !log_out.status.success() {
142        anyhow::bail!("git log failed: {}", String::from_utf8_lossy(&log_out.stderr).trim());
143    }
144    let log_str = String::from_utf8_lossy(&log_out.stdout);
145    let log_str = log_str.trim();
146
147    if log_str.is_empty() {
148        println!("epic branch is up to date with {default_branch}");
149        return Ok(());
150    }
151
152    let pr_title = format!("{epic_id}: refresh from {default_branch}");
153    let pr_body = log_str.to_string();
154
155    apm_core::git::push_branch_tracking(root, &epic_branch)?;
156
157    let mut messages = vec![];
158    apm_core::github::gh_pr_create_or_update_between(
159        root,
160        default_branch,
161        &epic_branch,
162        &pr_title,
163        &pr_body,
164        &mut messages,
165    )?;
166    for m in &messages {
167        println!("{m}");
168    }
169    Ok(())
170}
171
172pub fn run_show(root: &std::path::Path, id_arg: &str, no_aggressive: bool) -> anyhow::Result<()> {
173    let ctx = CmdContext::load(root, no_aggressive)?;
174
175    let matches = apm_core::epic::find_epic_branches(root, id_arg);
176    let branch = match matches.len() {
177        0 => anyhow::bail!("no epic matching '{id_arg}'"),
178        1 => matches.into_iter().next().unwrap(),
179        _ => anyhow::bail!(
180            "ambiguous prefix '{id_arg}', matches:\n  {}",
181            matches.join("\n  ")
182        ),
183    };
184
185    let epic_id = epic_id_from_branch(&branch);
186    let title = branch_to_title(&branch);
187
188    let epic_tickets: Vec<_> = ctx.tickets
189        .iter()
190        .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
191        .collect();
192
193    let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
194        .iter()
195        .filter_map(|t| ctx.config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
196        .collect();
197
198    let derived = apm_core::epic::derive_epic_state(&state_configs);
199
200    println!("Epic:   {title}");
201    println!("Branch: {branch}");
202    println!("State:  {derived}");
203
204    if epic_tickets.is_empty() {
205        println!();
206        println!("(no tickets)");
207        return Ok(());
208    }
209
210    // Column widths
211    let id_w = 8usize;
212    let state_w = 13usize;
213    let title_w = 32usize;
214
215    println!();
216    println!(
217        "{:<id_w$}  {:<state_w$}  {:<title_w$}  {}",
218        "ID", "State", "Title", "Depends on"
219    );
220    println!(
221        "{:-<id_w$}  {:-<state_w$}  {:-<title_w$}  {}",
222        "", "", "", "----------"
223    );
224
225    for t in &epic_tickets {
226        let fm = &t.frontmatter;
227        let deps = fm
228            .depends_on
229            .as_deref()
230            .map(|d| d.join(", "))
231            .unwrap_or_else(|| "-".to_string());
232        println!(
233            "{:<id_w$}  {:<state_w$}  {:<title_w$}  {}",
234            fm.id, fm.state, fm.title, deps
235        );
236    }
237
238    Ok(())
239}
240
241pub fn run_set(root: &std::path::Path, id_arg: &str, field: &str, value: &str) -> anyhow::Result<()> {
242    if field != "owner" {
243        anyhow::bail!("unknown field {field:?}; valid fields: owner");
244    }
245
246    // Validate the epic exists.
247    let matches = apm_core::epic::find_epic_branches(root, id_arg);
248    if matches.is_empty() {
249        eprintln!("error: no epic branch found matching '{id_arg}'");
250        std::process::exit(1);
251    }
252    if matches.len() > 1 {
253        anyhow::bail!(
254            "ambiguous id '{id_arg}': matches {}\n  {}",
255            matches.len(),
256            matches.join("\n  ")
257        );
258    }
259    let branch = &matches[0];
260    let epic_id = epic_id_from_branch(branch).to_string();
261
262    let config = apm_core::config::Config::load(root)?;
263
264    // Pre-flight: validate the new owner
265    let local = apm_core::config::LocalConfig::load(root);
266    apm_core::validate::validate_owner(&config, &local, value)?;
267
268    let (changed, skipped) = apm_core::epic::set_epic_owner(root, &epic_id, value, &config)?;
269    println!("updated {changed} ticket(s), skipped {skipped} terminal ticket(s)");
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    for branch in &candidates {
348        // Remove active worktree before attempting branch deletion.
349        if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
350            if let Err(e) = apm_core::worktree::remove_worktree(root, &wt_path, false) {
351                eprintln!(
352                    "skipping {branch}: could not remove worktree at {}: {e}",
353                    wt_path.display()
354                );
355                continue;
356            }
357        }
358
359        // Delete local branch.
360        let del_local = std::process::Command::new("git")
361            .current_dir(root)
362            .args(["branch", "-d", branch])
363            .output()?;
364        if !del_local.status.success() {
365            eprintln!(
366                "error: failed to delete local branch {branch}: {}",
367                String::from_utf8_lossy(&del_local.stderr).trim()
368            );
369            continue;
370        }
371
372        // Delete remote branch; suppress "remote ref does not exist".
373        let del_remote = std::process::Command::new("git")
374            .current_dir(root)
375            .args(["push", "origin", "--delete", branch])
376            .output()?;
377        if !del_remote.status.success() {
378            let stderr = String::from_utf8_lossy(&del_remote.stderr);
379            if !stderr.contains("remote ref does not exist")
380                && !stderr.contains("error: unable to delete")
381            {
382                eprintln!(
383                    "warning: failed to delete remote {branch}: {}",
384                    stderr.trim()
385                );
386            }
387        }
388
389        println!("deleted {branch}");
390    }
391
392    Ok(())
393}