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. Check whether the epic branch is already fully merged into default.
92    let default_branch = &config.project.default_branch;
93    let ahead = std::process::Command::new("git")
94        .current_dir(root)
95        .args(["log", "--oneline", &format!("{default_branch}..{epic_branch}")])
96        .output()?;
97    if String::from_utf8_lossy(&ahead.stdout).trim().is_empty() {
98        // Branch has no commits ahead of default — already merged.
99        println!("epic/{epic_id} is already merged into {default_branch}; skipping PR");
100        delete_epic_branch(root, &epic_branch)?;
101        return Ok(());
102    }
103
104    // 6. Push the epic branch and create or reuse an open PR.
105    apm_core::git::push_branch_tracking(root, &epic_branch)?;
106    let mut messages = vec![];
107    apm_core::github::gh_pr_create_or_update(
108        root,
109        &epic_branch,
110        default_branch,
111        epic_id,
112        &pr_title,
113        &format!("Epic: {epic_branch}"),
114        &mut messages,
115    )?;
116    for m in &messages {
117        println!("{m}");
118    }
119    Ok(())
120}
121
122pub fn run_refresh_epic(root: &Path, id_arg: &str) -> Result<()> {
123    let config = CmdContext::load_config_only(root)?;
124
125    let matches = apm_core::epic::find_epic_branches(root, id_arg);
126    let epic_branch = match matches.len() {
127        0 => anyhow::bail!("no epic branch found matching '{id_arg}'"),
128        1 => matches.into_iter().next().unwrap(),
129        _ => anyhow::bail!(
130            "ambiguous id '{id_arg}': matches {}\n  {}",
131            matches.len(),
132            matches.join("\n  ")
133        ),
134    };
135
136    let epic_id = epic_id_from_branch(&epic_branch);
137
138    let worktrees = apm_core::worktree::list_ticket_worktrees(root)?;
139    let blockers = apm_core::epic::epic_is_quiescent(root, epic_id, &config, &worktrees)?;
140    if !blockers.is_empty() {
141        anyhow::bail!(
142            "cannot refresh epic: the following tickets are not quiescent:\n{}",
143            blockers.join("\n")
144        );
145    }
146
147    let default_branch = &config.project.default_branch;
148
149    let log_out = std::process::Command::new("git")
150        .current_dir(root)
151        .args(["log", "--oneline", "--no-decorate", &format!("{epic_branch}..{default_branch}")])
152        .output()?;
153    if !log_out.status.success() {
154        anyhow::bail!("git log failed: {}", String::from_utf8_lossy(&log_out.stderr).trim());
155    }
156    let log_str = String::from_utf8_lossy(&log_out.stdout);
157    let log_str = log_str.trim();
158
159    if log_str.is_empty() {
160        println!("epic branch is up to date with {default_branch}");
161        return Ok(());
162    }
163
164    let pr_title = format!("{epic_id}: refresh from {default_branch}");
165    let pr_body = log_str.to_string();
166
167    apm_core::git::push_branch_tracking(root, &epic_branch)?;
168
169    let mut messages = vec![];
170    apm_core::github::gh_pr_create_or_update_between(
171        root,
172        default_branch,
173        &epic_branch,
174        &pr_title,
175        &pr_body,
176        &mut messages,
177    )?;
178    for m in &messages {
179        println!("{m}");
180    }
181    Ok(())
182}
183
184pub fn run_show(root: &std::path::Path, id_arg: &str, no_aggressive: bool) -> anyhow::Result<()> {
185    let ctx = CmdContext::load(root, no_aggressive)?;
186
187    let matches = apm_core::epic::find_epic_branches(root, id_arg);
188    let branch = match matches.len() {
189        0 => anyhow::bail!("no epic matching '{id_arg}'"),
190        1 => matches.into_iter().next().unwrap(),
191        _ => anyhow::bail!(
192            "ambiguous prefix '{id_arg}', matches:\n  {}",
193            matches.join("\n  ")
194        ),
195    };
196
197    let epic_id = epic_id_from_branch(&branch);
198    let title = branch_to_title(&branch);
199
200    let epic_tickets: Vec<_> = ctx.tickets
201        .iter()
202        .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
203        .collect();
204
205    let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
206        .iter()
207        .filter_map(|t| ctx.config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
208        .collect();
209
210    let derived = apm_core::epic::derive_epic_state(&state_configs);
211
212    println!("Epic:   {title}");
213    println!("Branch: {branch}");
214    println!("State:  {derived}");
215
216    if epic_tickets.is_empty() {
217        println!();
218        println!("(no tickets)");
219        return Ok(());
220    }
221
222    // Column widths
223    let id_w = 8usize;
224    let state_w = 13usize;
225    let title_w = 32usize;
226
227    println!();
228    println!(
229        "{:<id_w$}  {:<state_w$}  {:<title_w$}  {}",
230        "ID", "State", "Title", "Depends on"
231    );
232    println!(
233        "{:-<id_w$}  {:-<state_w$}  {:-<title_w$}  {}",
234        "", "", "", "----------"
235    );
236
237    for t in &epic_tickets {
238        let fm = &t.frontmatter;
239        let deps = fm
240            .depends_on
241            .as_deref()
242            .map(|d| d.join(", "))
243            .unwrap_or_else(|| "-".to_string());
244        println!(
245            "{:<id_w$}  {:<state_w$}  {:<title_w$}  {}",
246            fm.id, fm.state, fm.title, deps
247        );
248    }
249
250    Ok(())
251}
252
253pub fn run_set(root: &std::path::Path, id_arg: &str, field: &str, value: &str) -> anyhow::Result<()> {
254    if field != "owner" {
255        anyhow::bail!("unknown field {field:?}; valid fields: owner");
256    }
257
258    // Validate the epic exists.
259    let matches = apm_core::epic::find_epic_branches(root, id_arg);
260    if matches.is_empty() {
261        eprintln!("error: no epic branch found matching '{id_arg}'");
262        std::process::exit(1);
263    }
264    if matches.len() > 1 {
265        anyhow::bail!(
266            "ambiguous id '{id_arg}': matches {}\n  {}",
267            matches.len(),
268            matches.join("\n  ")
269        );
270    }
271    let branch = &matches[0];
272    let epic_id = epic_id_from_branch(branch).to_string();
273
274    let config = apm_core::config::Config::load(root)?;
275
276    // Pre-flight: validate the new owner
277    let local = apm_core::config::LocalConfig::load(root);
278    apm_core::validate::validate_owner(&config, &local, value)?;
279
280    let (changed, skipped) = apm_core::epic::set_epic_owner(root, &epic_id, value, &config)?;
281    println!("updated {changed} ticket(s), skipped {skipped} terminal ticket(s)");
282    Ok(())
283}
284
285
286fn delete_epic_branch(root: &Path, branch: &str) -> Result<()> {
287    if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
288        apm_core::worktree::remove_worktree(root, &wt_path, false)?;
289    }
290    let del = std::process::Command::new("git")
291        .current_dir(root)
292        .args(["branch", "-d", branch])
293        .output()?;
294    if del.status.success() {
295        println!("deleted local branch {branch}");
296    } else {
297        eprintln!("warning: could not delete local branch {branch}: {}", String::from_utf8_lossy(&del.stderr).trim());
298    }
299    let del_remote = std::process::Command::new("git")
300        .current_dir(root)
301        .args(["push", "origin", "--delete", branch])
302        .output()?;
303    if !del_remote.status.success() {
304        let stderr = String::from_utf8_lossy(&del_remote.stderr);
305        if !stderr.contains("remote ref does not exist") && !stderr.contains("error: unable to delete") {
306            eprintln!("warning: could not delete remote branch {branch}: {}", stderr.trim());
307        }
308    }
309    Ok(())
310}
311
312pub(crate) fn run_epic_clean(
313    root: &Path,
314    config: &apm_core::config::Config,
315    dry_run: bool,
316    yes: bool,
317) -> Result<()> {
318    // Get local epic branches.
319    let local_output = std::process::Command::new("git")
320        .current_dir(root)
321        .args(["branch", "--list", "epic/*"])
322        .output()?;
323
324    let local_branches: Vec<String> = String::from_utf8_lossy(&local_output.stdout)
325        .lines()
326        .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
327        .filter(|l| !l.is_empty())
328        .collect();
329
330    // Load all tickets.
331    let tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
332
333    // Find epic branches whose derived state is "done", or "empty" and already
334    // merged into the default branch (tickets closed, branches deleted post-merge).
335    let default_branch = &config.project.default_branch;
336    let mut candidates: Vec<String> = Vec::new();
337    for branch in &local_branches {
338        let id = apm_core::epic::epic_id_from_branch(branch);
339
340        let epic_tickets: Vec<_> = tickets
341            .iter()
342            .filter(|t| t.frontmatter.epic.as_deref() == Some(id))
343            .collect();
344
345        let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
346            .iter()
347            .filter_map(|t| config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
348            .collect();
349
350        let derived = apm_core::epic::derive_epic_state(&state_configs);
351        let is_merged = || -> bool {
352            let out = std::process::Command::new("git")
353                .current_dir(root)
354                .args(["log", "--oneline", &format!("{default_branch}..{branch}")])
355                .output()
356                .ok();
357            out.map(|o| String::from_utf8_lossy(&o.stdout).trim().is_empty()).unwrap_or(false)
358        };
359
360        if derived == "done" || (derived == "empty" && is_merged()) {
361            candidates.push(branch.clone());
362        }
363    }
364
365    if candidates.is_empty() {
366        println!("Nothing to clean.");
367        return Ok(());
368    }
369
370    // Print candidate list.
371    println!("Epics to delete ({}):", candidates.len());
372    for branch in &candidates {
373        let id = apm_core::epic::epic_id_from_branch(branch);
374        let title = apm_core::epic::branch_to_title(branch);
375        println!("  {id}  {title}");
376    }
377
378    if dry_run {
379        println!("Dry run — no changes made.");
380        return Ok(());
381    }
382
383    // Confirmation gate.
384    if !yes {
385        if std::io::stdout().is_terminal() {
386            if !crate::util::prompt_yes_no(&format!("Delete {} epic(s)? [y/N] ", candidates.len()))? {
387                println!("Aborted.");
388                return Ok(());
389            }
390        } else {
391            println!("Skipping — non-interactive terminal. Use --yes to confirm.");
392            return Ok(());
393        }
394    }
395
396    // Delete each candidate.
397    for branch in &candidates {
398        // Remove active worktree before attempting branch deletion.
399        if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
400            if let Err(e) = apm_core::worktree::remove_worktree(root, &wt_path, false) {
401                eprintln!(
402                    "skipping {branch}: could not remove worktree at {}: {e}",
403                    wt_path.display()
404                );
405                continue;
406            }
407        }
408
409        // Delete local branch.
410        let del_local = std::process::Command::new("git")
411            .current_dir(root)
412            .args(["branch", "-d", branch])
413            .output()?;
414        if !del_local.status.success() {
415            eprintln!(
416                "error: failed to delete local branch {branch}: {}",
417                String::from_utf8_lossy(&del_local.stderr).trim()
418            );
419            continue;
420        }
421
422        // Delete remote branch; suppress "remote ref does not exist".
423        let del_remote = std::process::Command::new("git")
424            .current_dir(root)
425            .args(["push", "origin", "--delete", branch])
426            .output()?;
427        if !del_remote.status.success() {
428            let stderr = String::from_utf8_lossy(&del_remote.stderr);
429            if !stderr.contains("remote ref does not exist")
430                && !stderr.contains("error: unable to delete")
431            {
432                eprintln!(
433                    "warning: failed to delete remote {branch}: {}",
434                    stderr.trim()
435                );
436            }
437        }
438
439        println!("deleted {branch}");
440    }
441
442    Ok(())
443}