Skip to main content

apm/cmd/
epic.rs

1use anyhow::{Context as _, 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, close_all: bool, merge: bool, _pr: bool, auto_mode: bool) -> 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. Unified quiescence check using classify_epic_quiescence.
92    let worktrees = apm_core::worktree::list_ticket_worktrees(root)?;
93    let result = apm_core::epic::classify_epic_quiescence(
94        root, epic_id, &config, &worktrees, &epic_branch,
95    )?;
96
97    // Unsafe tickets always block — no flag overrides this.
98    if !result.unsafe_tickets.is_empty() {
99        let rows = result.unsafe_tickets.iter()
100            .map(|t| format!("  {:<8}  {:<13}  {}", t.id, t.state, t.title))
101            .collect::<Vec<_>>()
102            .join("\n");
103        anyhow::bail!(
104            "cannot close epic: the following tickets require manual resolution:\n{}\nResolve them manually, then retry.",
105            rows
106        );
107    }
108
109    // Merged but not yet closed: offer auto-close.
110    // --close-all closes without prompt; on TTY ask interactively; on non-TTY treat as blocker.
111    let mut remaining: Vec<&apm_core::epic::EpicTicketInfo> =
112        result.genuine_blockers.iter().collect();
113    if !result.auto_closeable.is_empty() {
114        let should_close = close_all || (std::io::stdout().is_terminal() && {
115            let n = result.auto_closeable.len();
116            println!("\nTickets merged but not yet closed ({n}):");
117            for t in &result.auto_closeable {
118                println!("  {}  {}", t.id, t.title);
119            }
120            crate::util::prompt_yes_no(&format!("\nClose {n} merged ticket(s)? [y/N] "))?
121        });
122        if should_close {
123            let actor = format!("{}(apm-epic-close)", apm_core::config::resolve_caller_name());
124            for t in &result.auto_closeable {
125                match apm_core::ticket::close(root, &config, &t.id, None, &actor, false) {
126                    Ok(msgs) => msgs.iter().for_each(|m| println!("{m}")),
127                    Err(e) => eprintln!("warning: could not close {}: {e:#}", t.id),
128                }
129            }
130        } else {
131            // User declined or non-TTY — treat declined tickets as blockers.
132            remaining.extend(result.auto_closeable.iter());
133        }
134    }
135
136    // Genuine blockers (unmerged tickets, live-worker tickets, any declined auto-closeables).
137    if !remaining.is_empty() {
138        if !close_all {
139            let rows = remaining.iter()
140                .map(|t| format!("  {:<8}  {:<13}  {}", t.id, t.state, t.title))
141                .collect::<Vec<_>>()
142                .join("\n");
143            anyhow::bail!(
144                "epic has {} non-terminal ticket(s):\n{}\nRe-run with --close-all to cascade close, or close them manually first.",
145                remaining.len(),
146                rows
147            );
148        }
149        let actor = format!("{}(apm-epic-close)", apm_core::config::resolve_caller_name());
150        for t in &remaining {
151            print!("closing ticket #{} ... ", t.id);
152            apm_core::ticket::close(root, &config, &t.id, None, &actor, false)
153                .with_context(|| format!("failed to close ticket #{}", t.id))?;
154            println!("done");
155        }
156    }
157
158    // 4. Derive a human-readable title from the branch name.
159    let pr_title = branch_to_title(&epic_branch);
160
161    // 5. Check whether the epic branch is already fully merged into default.
162    let default_branch = &config.project.default_branch;
163    let ahead = std::process::Command::new("git")
164        .current_dir(root)
165        .args(["log", "--oneline", &format!("{default_branch}..{epic_branch}")])
166        .output()?;
167    if String::from_utf8_lossy(&ahead.stdout).trim().is_empty() {
168        // Branch has no commits ahead of default — already merged.
169        println!("epic/{epic_id} is already merged into {default_branch}; skipping PR");
170        delete_epic_branch(root, &epic_branch)?;
171        return Ok(());
172    }
173
174    // 6. Merge locally or push+PR based on flags.
175    let do_merge = merge || (auto_mode && {
176        let s = apm_core::epic::merge_tree_status(root, default_branch, &epic_branch)?;
177        s.clean
178    });
179
180    if do_merge {
181        let main_root = apm_core::git_util::main_worktree_root(root)
182            .unwrap_or_else(|| root.to_path_buf());
183        let head_out = std::process::Command::new("git")
184            .current_dir(&main_root)
185            .args(["symbolic-ref", "--short", "HEAD"])
186            .output()?;
187        let head = String::from_utf8_lossy(&head_out.stdout);
188        if head.trim() != default_branch {
189            anyhow::bail!(
190                "cannot merge: main worktree is on '{}', not '{default_branch}'. \
191                 Check out {default_branch} first, or use --pr.",
192                head.trim()
193            );
194        }
195        let mut messages = vec![];
196        match apm_core::git_util::merge_ref(&main_root, &epic_branch, &mut messages) {
197            Some(msg) => {
198                for m in &messages { println!("{m}"); }
199                println!("{msg}");
200            }
201            None => anyhow::bail!(
202                "merge conflict — resolve manually after checking out {default_branch}, \
203                 or use --pr to open a PR instead"
204            ),
205        }
206    } else {
207        apm_core::git::push_branch_tracking(root, &epic_branch)?;
208        let mut messages = vec![];
209        apm_core::github::gh_pr_create_or_update(
210            root,
211            &epic_branch,
212            default_branch,
213            epic_id,
214            &pr_title,
215            &format!("Epic: {epic_branch}"),
216            &mut messages,
217        )?;
218        for m in &messages { println!("{m}"); }
219    }
220    Ok(())
221}
222
223pub fn run_refresh_epic(root: &Path, id_arg: &str, merge: bool, pr: bool, auto_mode: bool, push: bool, no_push: bool) -> Result<()> {
224    let config = CmdContext::load_config_only(root)?;
225
226    let matches = apm_core::epic::find_epic_branches(root, id_arg);
227    let epic_branch = match matches.len() {
228        0 => anyhow::bail!("no epic branch found matching '{id_arg}'"),
229        1 => matches.into_iter().next().unwrap(),
230        _ => anyhow::bail!(
231            "ambiguous id '{id_arg}': matches {}\n  {}",
232            matches.len(),
233            matches.join("\n  ")
234        ),
235    };
236
237    let epic_id = epic_id_from_branch(&epic_branch);
238    let default_branch = &config.project.default_branch;
239
240    let status = apm_core::epic::merge_tree_status(root, default_branch, &epic_branch)?;
241
242    let acting = merge || pr || auto_mode;
243
244    if !acting {
245        if status.ahead == 0 {
246            println!("epic branch is up to date with {default_branch}");
247        } else {
248            let cleanliness = if status.clean { "clean" } else { "conflicted" };
249            println!("{} commit(s) ahead on {default_branch}; merge would be {cleanliness}", status.ahead);
250        }
251        return Ok(());
252    }
253
254    let worktrees = apm_core::worktree::list_ticket_worktrees(root)?;
255    let blockers = apm_core::epic::epic_is_quiescent(root, epic_id, &config, &worktrees)?;
256    if !blockers.is_empty() {
257        anyhow::bail!(
258            "cannot refresh epic: the following tickets are not quiescent:\n{}",
259            blockers.join("\n")
260        );
261    }
262
263    if status.ahead == 0 {
264        println!("epic branch is up to date with {default_branch}");
265        return Ok(());
266    }
267
268    let do_merge = merge || (auto_mode && status.clean);
269
270    if do_merge {
271        let main_root = apm_core::git_util::main_worktree_root(root)
272            .unwrap_or_else(|| root.to_path_buf());
273        let worktrees_base = main_root.join(&config.worktrees.dir);
274        let epic_wt_path = apm_core::worktree::find_worktree_for_branch(root, &epic_branch)
275            .map(Ok)
276            .unwrap_or_else(|| apm_core::worktree::ensure_worktree(root, &worktrees_base, &epic_branch))?;
277        let mut messages = vec![];
278        match apm_core::git_util::merge_ref(&epic_wt_path, default_branch, &mut messages) {
279            Some(msg) => {
280                for m in &messages {
281                    println!("{m}");
282                }
283                println!("{msg}");
284            }
285            None => {
286                anyhow::bail!(
287                    "merge conflict — resolve manually after checking out {epic_branch}, or use --pr to open a PR instead"
288                );
289            }
290        }
291
292        let should_push = if push {
293            true
294        } else if no_push {
295            false
296        } else if std::io::stdout().is_terminal() {
297            crate::util::prompt_yes_no_default_yes("Push refreshed epic to origin? [Y/n] ")?
298        } else {
299            false
300        };
301
302        if should_push {
303            apm_core::git::push_branch_tracking(root, &epic_branch)?;
304            println!("pushed {epic_branch} to origin");
305        } else {
306            eprintln!(
307                "warning: {epic_branch} was not pushed; \
308                 downstream `apm start` will read stale origin content until pushed manually"
309            );
310        }
311    } else {
312        let log_out = std::process::Command::new("git")
313            .current_dir(root)
314            .args(["log", "--oneline", "--no-decorate", &format!("{epic_branch}..{default_branch}")])
315            .output()?;
316        let pr_body = String::from_utf8_lossy(&log_out.stdout).trim().to_string();
317        let pr_title = format!("{epic_id}: refresh from {default_branch}");
318
319        apm_core::git::push_branch_tracking(root, &epic_branch)?;
320
321        let mut messages = vec![];
322        apm_core::github::gh_pr_create_or_update_between(
323            root,
324            default_branch,
325            &epic_branch,
326            &pr_title,
327            &pr_body,
328            &mut messages,
329        )?;
330        for m in &messages {
331            println!("{m}");
332        }
333    }
334
335    Ok(())
336}
337
338pub fn run_show(root: &std::path::Path, id_arg: &str, no_aggressive: bool) -> anyhow::Result<()> {
339    let ctx = CmdContext::load(root, no_aggressive)?;
340
341    let matches = apm_core::epic::find_epic_branches(root, id_arg);
342    let branch = match matches.len() {
343        0 => anyhow::bail!("no epic matching '{id_arg}'"),
344        1 => matches.into_iter().next().unwrap(),
345        _ => anyhow::bail!(
346            "ambiguous prefix '{id_arg}', matches:\n  {}",
347            matches.join("\n  ")
348        ),
349    };
350
351    let epic_id = epic_id_from_branch(&branch);
352    let title = branch_to_title(&branch);
353
354    let epic_tickets: Vec<_> = ctx.tickets
355        .iter()
356        .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
357        .collect();
358
359    let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
360        .iter()
361        .filter_map(|t| ctx.config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
362        .collect();
363
364    let derived = apm_core::epic::derive_epic_state(&state_configs);
365
366    let s = apm_core::epic::merge_tree_status(root, &ctx.config.project.default_branch, &branch)
367        .unwrap_or(MergeStatus { ahead: 0, clean: true });
368
369    println!("Epic:   {title}");
370    println!("Branch: {branch}");
371    println!("State:  {derived}");
372    println!("Freshness: {}", freshness_label(s.ahead, s.clean));
373
374    if epic_tickets.is_empty() {
375        println!();
376        println!("(no tickets)");
377        return Ok(());
378    }
379
380    // Column widths
381    let id_w = 8usize;
382    let state_w = 13usize;
383    let title_w = 32usize;
384
385    println!();
386    println!(
387        "{:<id_w$}  {:<state_w$}  {:<title_w$}  Depends on",
388        "ID", "State", "Title"
389    );
390    println!(
391        "{:-<id_w$}  {:-<state_w$}  {:-<title_w$}  ----------",
392        "", "", ""
393    );
394
395    for t in &epic_tickets {
396        let fm = &t.frontmatter;
397        let deps = fm
398            .depends_on
399            .as_deref()
400            .map(|d| d.join(", "))
401            .unwrap_or_else(|| "-".to_string());
402        println!(
403            "{:<id_w$}  {:<state_w$}  {:<title_w$}  {}",
404            fm.id, fm.state, fm.title, deps
405        );
406    }
407
408    Ok(())
409}
410
411pub fn run_set(root: &std::path::Path, id_arg: &str, field: &str, value: &str) -> anyhow::Result<()> {
412    if field != "owner" {
413        anyhow::bail!("unknown field {field:?}; valid fields: owner");
414    }
415
416    // Validate the epic exists.
417    let matches = apm_core::epic::find_epic_branches(root, id_arg);
418    if matches.is_empty() {
419        eprintln!("error: no epic branch found matching '{id_arg}'");
420        std::process::exit(1);
421    }
422    if matches.len() > 1 {
423        anyhow::bail!(
424            "ambiguous id '{id_arg}': matches {}\n  {}",
425            matches.len(),
426            matches.join("\n  ")
427        );
428    }
429    let branch = &matches[0];
430    let epic_id = epic_id_from_branch(branch).to_string();
431
432    let config = apm_core::config::Config::load(root)?;
433
434    // Pre-flight: validate the new owner
435    let local = apm_core::config::LocalConfig::load(root);
436    apm_core::validate::validate_owner(&config, &local, value)?;
437
438    let (changed, skipped) = apm_core::epic::set_epic_owner(root, &epic_id, value, &config)?;
439    println!("updated {changed} ticket(s), skipped {skipped} terminal ticket(s)");
440    Ok(())
441}
442
443
444fn delete_epic_branch(root: &Path, branch: &str) -> Result<()> {
445    if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
446        apm_core::worktree::remove_worktree(root, &wt_path, false)?;
447    }
448    let del = std::process::Command::new("git")
449        .current_dir(root)
450        .args(["branch", "-d", branch])
451        .output()?;
452    if del.status.success() {
453        println!("deleted local branch {branch}");
454    } else {
455        eprintln!("warning: could not delete local branch {branch}: {}", String::from_utf8_lossy(&del.stderr).trim());
456    }
457    let del_remote = std::process::Command::new("git")
458        .current_dir(root)
459        .args(["push", "origin", "--delete", branch])
460        .output()?;
461    if !del_remote.status.success() {
462        let stderr = String::from_utf8_lossy(&del_remote.stderr);
463        if !stderr.contains("remote ref does not exist") && !stderr.contains("error: unable to delete") {
464            eprintln!("warning: could not delete remote branch {branch}: {}", stderr.trim());
465        }
466    }
467    Ok(())
468}
469
470pub(crate) fn run_epic_clean(
471    root: &Path,
472    config: &apm_core::config::Config,
473    dry_run: bool,
474    yes: bool,
475) -> Result<()> {
476    // Get local epic branches.
477    let local_output = std::process::Command::new("git")
478        .current_dir(root)
479        .args(["branch", "--list", "epic/*"])
480        .output()?;
481
482    let local_branches: Vec<String> = String::from_utf8_lossy(&local_output.stdout)
483        .lines()
484        .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
485        .filter(|l| !l.is_empty())
486        .collect();
487
488    // Load all tickets.
489    let tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
490
491    // Find epic branches whose derived state is "done", or "empty" and already
492    // merged into the default branch (tickets closed, branches deleted post-merge).
493    let default_branch = &config.project.default_branch;
494    let mut candidates: Vec<String> = Vec::new();
495    for branch in &local_branches {
496        let id = apm_core::epic::epic_id_from_branch(branch);
497
498        let epic_tickets: Vec<_> = tickets
499            .iter()
500            .filter(|t| t.frontmatter.epic.as_deref() == Some(id))
501            .collect();
502
503        let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
504            .iter()
505            .filter_map(|t| config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
506            .collect();
507
508        let derived = apm_core::epic::derive_epic_state(&state_configs);
509        let is_merged = || -> bool {
510            let out = std::process::Command::new("git")
511                .current_dir(root)
512                .args(["log", "--oneline", &format!("{default_branch}..{branch}")])
513                .output()
514                .ok();
515            out.map(|o| String::from_utf8_lossy(&o.stdout).trim().is_empty()).unwrap_or(false)
516        };
517
518        if derived == "done" || (derived == "empty" && is_merged()) {
519            candidates.push(branch.clone());
520        }
521    }
522
523    if candidates.is_empty() {
524        println!("Nothing to clean.");
525        return Ok(());
526    }
527
528    // Print candidate list.
529    println!("Epics to delete ({}):", candidates.len());
530    for branch in &candidates {
531        let id = apm_core::epic::epic_id_from_branch(branch);
532        let title = apm_core::epic::branch_to_title(branch);
533        println!("  {id}  {title}");
534    }
535
536    if dry_run {
537        println!("Dry run — no changes made.");
538        return Ok(());
539    }
540
541    // Confirmation gate.
542    if !yes {
543        if std::io::stdout().is_terminal() {
544            if !crate::util::prompt_yes_no(&format!("Delete {} epic(s)? [y/N] ", candidates.len()))? {
545                println!("Aborted.");
546                return Ok(());
547            }
548        } else {
549            println!("Skipping — non-interactive terminal. Use --yes to confirm.");
550            return Ok(());
551        }
552    }
553
554    // Delete each candidate.
555    for branch in &candidates {
556        // Remove active worktree before attempting branch deletion.
557        if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
558            if let Err(e) = apm_core::worktree::remove_worktree(root, &wt_path, false) {
559                eprintln!(
560                    "skipping {branch}: could not remove worktree at {}: {e}",
561                    wt_path.display()
562                );
563                continue;
564            }
565        }
566
567        // Delete local branch.
568        let del_local = std::process::Command::new("git")
569            .current_dir(root)
570            .args(["branch", "-d", branch])
571            .output()?;
572        if !del_local.status.success() {
573            eprintln!(
574                "error: failed to delete local branch {branch}: {}",
575                String::from_utf8_lossy(&del_local.stderr).trim()
576            );
577            continue;
578        }
579
580        // Delete remote branch; suppress "remote ref does not exist".
581        let del_remote = std::process::Command::new("git")
582            .current_dir(root)
583            .args(["push", "origin", "--delete", branch])
584            .output()?;
585        if !del_remote.status.success() {
586            let stderr = String::from_utf8_lossy(&del_remote.stderr);
587            if !stderr.contains("remote ref does not exist")
588                && !stderr.contains("error: unable to delete")
589            {
590                eprintln!(
591                    "warning: failed to delete remote {branch}: {}",
592                    stderr.trim()
593                );
594            }
595        }
596
597        println!("deleted {branch}");
598    }
599
600    Ok(())
601}