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".
334    let mut candidates: Vec<String> = Vec::new();
335    for branch in &local_branches {
336        let id = apm_core::epic::epic_id_from_branch(branch);
337
338        let epic_tickets: Vec<_> = tickets
339            .iter()
340            .filter(|t| t.frontmatter.epic.as_deref() == Some(id))
341            .collect();
342
343        let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
344            .iter()
345            .filter_map(|t| config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
346            .collect();
347
348        if apm_core::epic::derive_epic_state(&state_configs) == "done" {
349            candidates.push(branch.clone());
350        }
351    }
352
353    if candidates.is_empty() {
354        println!("Nothing to clean.");
355        return Ok(());
356    }
357
358    // Print candidate list.
359    println!("Would delete {} epic(s):", candidates.len());
360    for branch in &candidates {
361        let id = apm_core::epic::epic_id_from_branch(branch);
362        let title = apm_core::epic::branch_to_title(branch);
363        println!("  {id}  {title}");
364    }
365
366    if dry_run {
367        println!("Dry run — no changes made.");
368        return Ok(());
369    }
370
371    // Confirmation gate.
372    if !yes {
373        if std::io::stdout().is_terminal() {
374            if !crate::util::prompt_yes_no(&format!("Delete {} epic(s)? [y/N] ", candidates.len()))? {
375                println!("Aborted.");
376                return Ok(());
377            }
378        } else {
379            println!("Skipping — non-interactive terminal. Use --yes to confirm.");
380            return Ok(());
381        }
382    }
383
384    // Delete each candidate.
385    for branch in &candidates {
386        // Remove active worktree before attempting branch deletion.
387        if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
388            if let Err(e) = apm_core::worktree::remove_worktree(root, &wt_path, false) {
389                eprintln!(
390                    "skipping {branch}: could not remove worktree at {}: {e}",
391                    wt_path.display()
392                );
393                continue;
394            }
395        }
396
397        // Delete local branch.
398        let del_local = std::process::Command::new("git")
399            .current_dir(root)
400            .args(["branch", "-d", branch])
401            .output()?;
402        if !del_local.status.success() {
403            eprintln!(
404                "error: failed to delete local branch {branch}: {}",
405                String::from_utf8_lossy(&del_local.stderr).trim()
406            );
407            continue;
408        }
409
410        // Delete remote branch; suppress "remote ref does not exist".
411        let del_remote = std::process::Command::new("git")
412            .current_dir(root)
413            .args(["push", "origin", "--delete", branch])
414            .output()?;
415        if !del_remote.status.success() {
416            let stderr = String::from_utf8_lossy(&del_remote.stderr);
417            if !stderr.contains("remote ref does not exist")
418                && !stderr.contains("error: unable to delete")
419            {
420                eprintln!(
421                    "warning: failed to delete remote {branch}: {}",
422                    stderr.trim()
423                );
424            }
425        }
426
427        println!("deleted {branch}");
428    }
429
430    Ok(())
431}