1use anyhow::{Context, 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 branch = apm_core::epic::create(root, &title)?;
55 println!("{branch}");
56 Ok(())
57}
58
59pub fn run_close(root: &Path, id_arg: &str) -> Result<()> {
60 let config = CmdContext::load_config_only(root)?;
61
62 let matches = apm_core::epic::find_epic_branches(root, id_arg);
64 let epic_branch = match matches.len() {
65 0 => anyhow::bail!("no epic branch found matching '{id_arg}'"),
66 1 => matches.into_iter().next().unwrap(),
67 _ => anyhow::bail!(
68 "ambiguous id '{id_arg}': matches {}\n {}",
69 matches.len(),
70 matches.join("\n ")
71 ),
72 };
73
74 let epic_id = epic_id_from_branch(&epic_branch);
76
77 let tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
79 let epic_tickets: Vec<_> = tickets
80 .iter()
81 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
82 .collect();
83
84 let mut not_ready: Vec<String> = Vec::new();
86 for t in &epic_tickets {
87 let state_id = &t.frontmatter.state;
88 let passes = config
89 .workflow
90 .states
91 .iter()
92 .find(|s| &s.id == state_id)
93 .map(|s| matches!(s.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) || s.terminal)
94 .unwrap_or(false);
95 if !passes {
96 not_ready.push(format!(" {} — {} (state: {})", t.frontmatter.id, t.frontmatter.title, state_id));
97 }
98 }
99 if !not_ready.is_empty() {
100 anyhow::bail!(
101 "cannot close epic: the following tickets are not ready:\n{}",
102 not_ready.join("\n")
103 );
104 }
105
106 let pr_title = branch_to_title(&epic_branch);
108
109 let default_branch = &config.project.default_branch;
111 apm_core::git::push_branch_tracking(root, &epic_branch)?;
112 let mut messages = vec![];
113 apm_core::github::gh_pr_create_or_update(
114 root,
115 &epic_branch,
116 default_branch,
117 epic_id,
118 &pr_title,
119 &format!("Epic: {epic_branch}"),
120 &mut messages,
121 )?;
122 for m in &messages {
123 println!("{m}");
124 }
125 Ok(())
126}
127
128pub fn run_show(root: &std::path::Path, id_arg: &str, no_aggressive: bool) -> anyhow::Result<()> {
129 let ctx = CmdContext::load(root, no_aggressive)?;
130
131 let matches = apm_core::epic::find_epic_branches(root, id_arg);
132 let branch = match matches.len() {
133 0 => anyhow::bail!("no epic matching '{id_arg}'"),
134 1 => matches.into_iter().next().unwrap(),
135 _ => anyhow::bail!(
136 "ambiguous prefix '{id_arg}', matches:\n {}",
137 matches.join("\n ")
138 ),
139 };
140
141 let epic_id = epic_id_from_branch(&branch);
142 let title = branch_to_title(&branch);
143
144 let epic_tickets: Vec<_> = ctx.tickets
145 .iter()
146 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
147 .collect();
148
149 let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
150 .iter()
151 .filter_map(|t| ctx.config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
152 .collect();
153
154 let derived = apm_core::epic::derive_epic_state(&state_configs);
155
156 println!("Epic: {title}");
157 println!("Branch: {branch}");
158 println!("State: {derived}");
159 if let Some(limit) = ctx.config.epic_max_workers(epic_id) {
160 println!("Max workers: {limit}");
161 }
162
163 if epic_tickets.is_empty() {
164 println!();
165 println!("(no tickets)");
166 return Ok(());
167 }
168
169 let id_w = 8usize;
171 let state_w = 13usize;
172 let title_w = 32usize;
173
174 println!();
175 println!(
176 "{:<id_w$} {:<state_w$} {:<title_w$} {}",
177 "ID", "State", "Title", "Depends on"
178 );
179 println!(
180 "{:-<id_w$} {:-<state_w$} {:-<title_w$} {}",
181 "", "", "", "----------"
182 );
183
184 for t in &epic_tickets {
185 let fm = &t.frontmatter;
186 let deps = fm
187 .depends_on
188 .as_deref()
189 .map(|d| d.join(", "))
190 .unwrap_or_else(|| "-".to_string());
191 println!(
192 "{:<id_w$} {:<state_w$} {:<title_w$} {}",
193 fm.id, fm.state, fm.title, deps
194 );
195 }
196
197 Ok(())
198}
199
200pub fn run_set(root: &std::path::Path, id_arg: &str, field: &str, value: &str) -> anyhow::Result<()> {
201 if field != "max_workers" && field != "owner" {
202 anyhow::bail!("unknown field {field:?}; valid fields: max_workers, owner");
203 }
204
205 let matches = apm_core::epic::find_epic_branches(root, id_arg);
207 if matches.is_empty() {
208 eprintln!("error: no epic branch found matching '{id_arg}'");
209 std::process::exit(1);
210 }
211 if matches.len() > 1 {
212 anyhow::bail!(
213 "ambiguous id '{id_arg}': matches {}\n {}",
214 matches.len(),
215 matches.join("\n ")
216 );
217 }
218 let branch = &matches[0];
219 let epic_id = epic_id_from_branch(branch).to_string();
220
221 if field == "owner" {
222 let config = apm_core::config::Config::load(root)?;
223
224 let local = apm_core::config::LocalConfig::load(root);
226 apm_core::validate::validate_owner(&config, &local, value)?;
227
228 let (changed, skipped) = apm_core::epic::set_epic_owner(root, &epic_id, value, &config)?;
229 println!("updated {changed} ticket(s), skipped {skipped} terminal ticket(s)");
230 return Ok(());
231 }
232
233 let apm_dir = root.join(".apm");
234 let epics_path = apm_dir.join("epics.toml");
235
236 let raw = if epics_path.exists() {
237 std::fs::read_to_string(&epics_path)
238 .with_context(|| format!("cannot read {}", epics_path.display()))?
239 } else {
240 String::new()
241 };
242 let mut doc: toml_edit::DocumentMut = raw.parse()
243 .with_context(|| format!("cannot parse {}", epics_path.display()))?;
244
245 if value == "-" {
246 if let Some(epic_tbl) = doc.get_mut(&epic_id) {
248 if let Some(t) = epic_tbl.as_table_mut() {
249 t.remove("max_workers");
250 }
251 }
252 } else {
253 let n: i64 = value.parse().map_err(|_| anyhow::anyhow!("max_workers must be a positive integer, got {value:?}"))?;
254 if n <= 0 {
255 eprintln!("error: max_workers must be ≥ 1, got {n}");
256 std::process::exit(1);
257 }
258
259 if doc.get(&epic_id).is_none() {
261 doc.insert(&epic_id, toml_edit::Item::Table(toml_edit::Table::new()));
262 }
263 doc[&epic_id]["max_workers"] = toml_edit::value(n);
264 }
265
266 std::fs::create_dir_all(&apm_dir)?;
267 std::fs::write(&epics_path, doc.to_string())
268 .with_context(|| format!("cannot write {}", epics_path.display()))?;
269 Ok(())
270}
271
272
273pub(crate) fn run_epic_clean(
274 root: &Path,
275 config: &apm_core::config::Config,
276 dry_run: bool,
277 yes: bool,
278) -> Result<()> {
279 let local_output = std::process::Command::new("git")
281 .current_dir(root)
282 .args(["branch", "--list", "epic/*"])
283 .output()?;
284
285 let local_branches: Vec<String> = String::from_utf8_lossy(&local_output.stdout)
286 .lines()
287 .map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
288 .filter(|l| !l.is_empty())
289 .collect();
290
291 let tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
293
294 let mut candidates: Vec<String> = Vec::new();
296 for branch in &local_branches {
297 let id = apm_core::epic::epic_id_from_branch(branch);
298
299 let epic_tickets: Vec<_> = tickets
300 .iter()
301 .filter(|t| t.frontmatter.epic.as_deref() == Some(id))
302 .collect();
303
304 let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
305 .iter()
306 .filter_map(|t| config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
307 .collect();
308
309 if apm_core::epic::derive_epic_state(&state_configs) == "done" {
310 candidates.push(branch.clone());
311 }
312 }
313
314 if candidates.is_empty() {
315 println!("Nothing to clean.");
316 return Ok(());
317 }
318
319 println!("Would delete {} epic(s):", candidates.len());
321 for branch in &candidates {
322 let id = apm_core::epic::epic_id_from_branch(branch);
323 let title = apm_core::epic::branch_to_title(branch);
324 println!(" {id} {title}");
325 }
326
327 if dry_run {
328 println!("Dry run — no changes made.");
329 return Ok(());
330 }
331
332 if !yes {
334 if std::io::stdout().is_terminal() {
335 if !crate::util::prompt_yes_no(&format!("Delete {} epic(s)? [y/N] ", candidates.len()))? {
336 println!("Aborted.");
337 return Ok(());
338 }
339 } else {
340 println!("Skipping — non-interactive terminal. Use --yes to confirm.");
341 return Ok(());
342 }
343 }
344
345 let epics_path = root.join(".apm").join("epics.toml");
347 for branch in &candidates {
348 let id = apm_core::epic::epic_id_from_branch(branch).to_string();
349
350 if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
352 if let Err(e) = apm_core::worktree::remove_worktree(root, &wt_path, false) {
353 eprintln!(
354 "skipping {branch}: could not remove worktree at {}: {e}",
355 wt_path.display()
356 );
357 continue;
358 }
359 }
360
361 let del_local = std::process::Command::new("git")
363 .current_dir(root)
364 .args(["branch", "-d", branch])
365 .output()?;
366 if !del_local.status.success() {
367 eprintln!(
368 "error: failed to delete local branch {branch}: {}",
369 String::from_utf8_lossy(&del_local.stderr).trim()
370 );
371 continue;
372 }
373
374 let del_remote = std::process::Command::new("git")
376 .current_dir(root)
377 .args(["push", "origin", "--delete", branch])
378 .output()?;
379 if !del_remote.status.success() {
380 let stderr = String::from_utf8_lossy(&del_remote.stderr);
381 if !stderr.contains("remote ref does not exist")
382 && !stderr.contains("error: unable to delete")
383 {
384 eprintln!(
385 "warning: failed to delete remote {branch}: {}",
386 stderr.trim()
387 );
388 }
389 }
390
391 println!("deleted {branch}");
392
393 if epics_path.exists() {
395 let raw = std::fs::read_to_string(&epics_path)?;
396 let mut doc: toml_edit::DocumentMut = raw.parse()?;
397 if doc.contains_key(&id) {
398 doc.remove(&id);
399 std::fs::write(&epics_path, doc.to_string())?;
400 }
401 }
402 }
403
404 Ok(())
405}
406
407#[cfg(test)]
408mod tests {
409 #[test]
411 fn gate_check_all_passing() {
412 use apm_core::config::WorkflowConfig;
413
414 let states = vec![
415 make_state("implemented", true, false),
416 make_state("closed", false, true),
417 ];
418 let wf = WorkflowConfig { states, ..Default::default() };
419
420 for s in &wf.states {
422 assert!(matches!(s.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) || s.terminal, "state {} should pass", s.id);
423 }
424 }
425
426 #[test]
427 fn gate_check_failing_state() {
428 use apm_core::config::WorkflowConfig;
429
430 let states = vec![
431 make_state("in_progress", false, false),
432 make_state("implemented", true, false),
433 ];
434 let wf = WorkflowConfig { states, ..Default::default() };
435
436 let in_prog = wf.states.iter().find(|s| s.id == "in_progress").unwrap();
437 assert!(!matches!(in_prog.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) && !in_prog.terminal);
438
439 let implemented = wf.states.iter().find(|s| s.id == "implemented").unwrap();
440 assert!(matches!(implemented.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) || implemented.terminal);
441 }
442
443 fn make_state(id: &str, satisfies_deps: bool, terminal: bool) -> apm_core::config::StateConfig {
444 apm_core::config::StateConfig {
445 id: id.to_string(),
446 label: id.to_string(),
447 description: String::new(),
448 terminal,
449 worker_end: false,
450 satisfies_deps: apm_core::config::SatisfiesDeps::Bool(satisfies_deps),
451 dep_requires: None,
452 transitions: vec![],
453 actionable: vec![],
454 instructions: None,
455 }
456 }
457}