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 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 tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
80 let epic_tickets: Vec<_> = tickets
81 .iter()
82 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
83 .collect();
84
85 let mut not_ready: Vec<String> = Vec::new();
87 for t in &epic_tickets {
88 let state_id = &t.frontmatter.state;
89 let passes = config
90 .workflow
91 .states
92 .iter()
93 .find(|s| &s.id == state_id)
94 .map(|s| matches!(s.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) || s.terminal)
95 .unwrap_or(false);
96 if !passes {
97 not_ready.push(format!(" {} — {} (state: {})", t.frontmatter.id, t.frontmatter.title, state_id));
98 }
99 }
100 if !not_ready.is_empty() {
101 anyhow::bail!(
102 "cannot close epic: the following tickets are not ready:\n{}",
103 not_ready.join("\n")
104 );
105 }
106
107 let pr_title = branch_to_title(&epic_branch);
109
110 let default_branch = &config.project.default_branch;
112 apm_core::git::push_branch_tracking(root, &epic_branch)?;
113 let mut messages = vec![];
114 apm_core::github::gh_pr_create_or_update(
115 root,
116 &epic_branch,
117 default_branch,
118 epic_id,
119 &pr_title,
120 &format!("Epic: {epic_branch}"),
121 &mut messages,
122 )?;
123 for m in &messages {
124 println!("{m}");
125 }
126 Ok(())
127}
128
129pub fn run_show(root: &std::path::Path, id_arg: &str, no_aggressive: bool) -> anyhow::Result<()> {
130 let ctx = CmdContext::load(root, no_aggressive)?;
131
132 let matches = apm_core::epic::find_epic_branches(root, id_arg);
133 let branch = match matches.len() {
134 0 => anyhow::bail!("no epic matching '{id_arg}'"),
135 1 => matches.into_iter().next().unwrap(),
136 _ => anyhow::bail!(
137 "ambiguous prefix '{id_arg}', matches:\n {}",
138 matches.join("\n ")
139 ),
140 };
141
142 let epic_id = epic_id_from_branch(&branch);
143 let title = branch_to_title(&branch);
144
145 let epic_tickets: Vec<_> = ctx.tickets
146 .iter()
147 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
148 .collect();
149
150 let state_configs: Vec<&apm_core::config::StateConfig> = epic_tickets
151 .iter()
152 .filter_map(|t| ctx.config.workflow.states.iter().find(|s| s.id == t.frontmatter.state))
153 .collect();
154
155 let derived = apm_core::epic::derive_epic_state(&state_configs);
156
157 println!("Epic: {title}");
158 println!("Branch: {branch}");
159 println!("State: {derived}");
160 if let Some(limit) = ctx.config.epic_max_workers(epic_id) {
161 println!("Max workers: {limit}");
162 }
163
164 if epic_tickets.is_empty() {
165 println!();
166 println!("(no tickets)");
167 return Ok(());
168 }
169
170 let id_w = 8usize;
172 let state_w = 13usize;
173 let title_w = 32usize;
174
175 println!();
176 println!(
177 "{:<id_w$} {:<state_w$} {:<title_w$} {}",
178 "ID", "State", "Title", "Depends on"
179 );
180 println!(
181 "{:-<id_w$} {:-<state_w$} {:-<title_w$} {}",
182 "", "", "", "----------"
183 );
184
185 for t in &epic_tickets {
186 let fm = &t.frontmatter;
187 let deps = fm
188 .depends_on
189 .as_deref()
190 .map(|d| d.join(", "))
191 .unwrap_or_else(|| "-".to_string());
192 println!(
193 "{:<id_w$} {:<state_w$} {:<title_w$} {}",
194 fm.id, fm.state, fm.title, deps
195 );
196 }
197
198 Ok(())
199}
200
201pub fn run_set(root: &std::path::Path, id_arg: &str, field: &str, value: &str) -> anyhow::Result<()> {
202 if field != "max_workers" && field != "owner" {
203 anyhow::bail!("unknown field {field:?}; valid fields: max_workers, owner");
204 }
205
206 let matches = apm_core::epic::find_epic_branches(root, id_arg);
208 if matches.is_empty() {
209 eprintln!("error: no epic branch found matching '{id_arg}'");
210 std::process::exit(1);
211 }
212 if matches.len() > 1 {
213 anyhow::bail!(
214 "ambiguous id '{id_arg}': matches {}\n {}",
215 matches.len(),
216 matches.join("\n ")
217 );
218 }
219 let branch = &matches[0];
220 let epic_id = epic_id_from_branch(branch).to_string();
221
222 if field == "owner" {
223 let config = apm_core::config::Config::load(root)?;
224
225 let local = apm_core::config::LocalConfig::load(root);
227 apm_core::validate::validate_owner(&config, &local, value)?;
228
229 let (changed, skipped) = apm_core::epic::set_epic_owner(root, &epic_id, value, &config)?;
230 println!("updated {changed} ticket(s), skipped {skipped} terminal ticket(s)");
231 return Ok(());
232 }
233
234 let apm_dir = root.join(".apm");
235 let epics_path = apm_dir.join("epics.toml");
236
237 let raw = if epics_path.exists() {
238 std::fs::read_to_string(&epics_path)
239 .with_context(|| format!("cannot read {}", epics_path.display()))?
240 } else {
241 String::new()
242 };
243 let mut doc: toml_edit::DocumentMut = raw.parse()
244 .with_context(|| format!("cannot parse {}", epics_path.display()))?;
245
246 if value == "-" {
247 if let Some(epic_tbl) = doc.get_mut(&epic_id) {
249 if let Some(t) = epic_tbl.as_table_mut() {
250 t.remove("max_workers");
251 }
252 }
253 } else {
254 let n: i64 = value.parse().map_err(|_| anyhow::anyhow!("max_workers must be a positive integer, got {value:?}"))?;
255 if n <= 0 {
256 eprintln!("error: max_workers must be ≥ 1, got {n}");
257 std::process::exit(1);
258 }
259
260 if doc.get(&epic_id).is_none() {
262 doc.insert(&epic_id, toml_edit::Item::Table(toml_edit::Table::new()));
263 }
264 doc[&epic_id]["max_workers"] = toml_edit::value(n);
265 }
266
267 std::fs::create_dir_all(&apm_dir)?;
268 std::fs::write(&epics_path, doc.to_string())
269 .with_context(|| format!("cannot write {}", epics_path.display()))?;
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 let epics_path = root.join(".apm").join("epics.toml");
348 for branch in &candidates {
349 let id = apm_core::epic::epic_id_from_branch(branch).to_string();
350
351 if let Some(wt_path) = apm_core::worktree::find_worktree_for_branch(root, branch) {
353 if let Err(e) = apm_core::worktree::remove_worktree(root, &wt_path, false) {
354 eprintln!(
355 "skipping {branch}: could not remove worktree at {}: {e}",
356 wt_path.display()
357 );
358 continue;
359 }
360 }
361
362 let del_local = std::process::Command::new("git")
364 .current_dir(root)
365 .args(["branch", "-d", branch])
366 .output()?;
367 if !del_local.status.success() {
368 eprintln!(
369 "error: failed to delete local branch {branch}: {}",
370 String::from_utf8_lossy(&del_local.stderr).trim()
371 );
372 continue;
373 }
374
375 let del_remote = std::process::Command::new("git")
377 .current_dir(root)
378 .args(["push", "origin", "--delete", branch])
379 .output()?;
380 if !del_remote.status.success() {
381 let stderr = String::from_utf8_lossy(&del_remote.stderr);
382 if !stderr.contains("remote ref does not exist")
383 && !stderr.contains("error: unable to delete")
384 {
385 eprintln!(
386 "warning: failed to delete remote {branch}: {}",
387 stderr.trim()
388 );
389 }
390 }
391
392 println!("deleted {branch}");
393
394 if epics_path.exists() {
396 let raw = std::fs::read_to_string(&epics_path)?;
397 let mut doc: toml_edit::DocumentMut = raw.parse()?;
398 if doc.contains_key(&id) {
399 doc.remove(&id);
400 std::fs::write(&epics_path, doc.to_string())?;
401 }
402 }
403 }
404
405 Ok(())
406}
407
408#[cfg(test)]
409mod tests {
410 #[test]
412 fn gate_check_all_passing() {
413 use apm_core::config::WorkflowConfig;
414
415 let states = vec![
416 make_state("implemented", true, false),
417 make_state("closed", false, true),
418 ];
419 let wf = WorkflowConfig { states, ..Default::default() };
420
421 for s in &wf.states {
423 assert!(matches!(s.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) || s.terminal, "state {} should pass", s.id);
424 }
425 }
426
427 #[test]
428 fn gate_check_failing_state() {
429 use apm_core::config::WorkflowConfig;
430
431 let states = vec![
432 make_state("in_progress", false, false),
433 make_state("implemented", true, false),
434 ];
435 let wf = WorkflowConfig { states, ..Default::default() };
436
437 let in_prog = wf.states.iter().find(|s| s.id == "in_progress").unwrap();
438 assert!(!matches!(in_prog.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) && !in_prog.terminal);
439
440 let implemented = wf.states.iter().find(|s| s.id == "implemented").unwrap();
441 assert!(matches!(implemented.satisfies_deps, apm_core::config::SatisfiesDeps::Bool(true)) || implemented.terminal);
442 }
443
444 fn make_state(id: &str, satisfies_deps: bool, terminal: bool) -> apm_core::config::StateConfig {
445 apm_core::config::StateConfig {
446 id: id.to_string(),
447 label: id.to_string(),
448 description: String::new(),
449 terminal,
450 worker_end: false,
451 satisfies_deps: apm_core::config::SatisfiesDeps::Bool(satisfies_deps),
452 dep_requires: None,
453 transitions: vec![],
454 actionable: vec![],
455 instructions: None,
456 }
457 }
458}