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