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