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 let epic_tickets: Vec<_> = tickets
23 .iter()
24 .filter(|t| t.frontmatter.epic.as_deref() == Some(id))
25 .collect();
26
27 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 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 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 let epic_id = epic_id_from_branch(&epic_branch);
77
78 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 let pr_title = branch_to_title(&epic_branch);
90
91 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 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 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 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 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 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 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 let tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
332
333 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 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 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 for branch in &candidates {
386 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 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 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}