1use anyhow::Result;
2use std::path::Path;
3
4use crate::config::StateConfig;
5use crate::{git_util, worktree};
6
7pub fn epic_is_quiescent(
8 root: &Path,
9 epic_id: &str,
10 config: &crate::config::Config,
11 worktrees: &[(std::path::PathBuf, String)],
12) -> Result<Vec<String>> {
13 let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
14 let mut blockers = Vec::new();
15
16 for t in all_tickets.iter().filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id)) {
17 let id = &t.frontmatter.id;
18 let title = &t.frontmatter.title;
19 let state_id = &t.frontmatter.state;
20
21 let state_cfg = config.workflow.states.iter().find(|s| &s.id == state_id);
22 let is_terminal = state_cfg.map(|s| s.terminal).unwrap_or(false);
23 let is_worker_end = state_cfg.map(|s| s.worker_end).unwrap_or(false);
24
25 let state_blocks = !is_terminal && !is_worker_end;
26 if state_blocks {
27 blockers.push(format!(" {id} — {title} (state: {state_id})"));
28 continue;
29 }
30
31 let ticket_branch = t.frontmatter.branch.clone()
32 .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path));
33 if let Some(branch) = ticket_branch {
34 if let Some((wt_path, _)) = worktrees.iter().find(|(_, b)| b == &branch) {
35 let pid_file = wt_path.join(".apm-worker.pid");
36 if pid_file.exists() {
37 if let Ok((pid, _)) = crate::worker::read_pid_file(&pid_file) {
38 if crate::worker::is_alive(pid) {
39 blockers.push(format!(" {id} — {title} (live worker)"));
40 }
41 }
42 }
43 }
44 }
45 }
46
47 Ok(blockers)
48}
49
50pub fn derive_epic_state(states: &[&StateConfig]) -> &'static str {
60 if states.is_empty() {
61 return "empty";
62 }
63 if states.iter().any(|s| !matches!(s.satisfies_deps, crate::config::SatisfiesDeps::Bool(true)) && !s.terminal) {
64 return "in_progress";
65 }
66 if states.iter().all(|s| s.terminal) {
67 return "done";
68 }
69 "implemented"
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use crate::config::StateConfig;
76
77 #[test]
78 fn branch_to_title_basic() {
79 assert_eq!(branch_to_title("epic/ab12cd34-user-authentication"), "User Authentication");
80 }
81
82 #[test]
83 fn branch_to_title_single_word() {
84 assert_eq!(branch_to_title("epic/ab12cd34-dashboard"), "Dashboard");
85 }
86
87 #[test]
88 fn branch_to_title_many_words() {
89 assert_eq!(branch_to_title("epic/ab12cd34-add-oauth-login-flow"), "Add Oauth Login Flow");
90 }
91
92 #[test]
93 fn branch_to_title_no_slug() {
94 assert_eq!(branch_to_title("epic/ab12cd34"), "Ab12cd34");
95 }
96
97 #[test]
98 fn epic_id_from_branch_happy_path() {
99 assert_eq!(epic_id_from_branch("epic/57bce963-refactor-apm-core"), "57bce963");
100 }
101
102 #[test]
103 fn epic_id_from_branch_no_epic_prefix() {
104 assert_eq!(epic_id_from_branch("57bce963-refactor"), "57bce963");
105 }
106
107 #[test]
108 fn epic_id_from_branch_no_dash() {
109 assert_eq!(epic_id_from_branch("nodash"), "nodash");
110 }
111
112 fn git_cmd(dir: &std::path::Path, args: &[&str]) {
113 std::process::Command::new("git")
114 .args(args)
115 .current_dir(dir)
116 .env("GIT_AUTHOR_NAME", "test")
117 .env("GIT_AUTHOR_EMAIL", "test@test.com")
118 .env("GIT_COMMITTER_NAME", "test")
119 .env("GIT_COMMITTER_EMAIL", "test@test.com")
120 .output()
121 .unwrap();
122 }
123
124 fn setup_repo() -> tempfile::TempDir {
125 let tmp = tempfile::tempdir().unwrap();
126 let p = tmp.path();
127 git_cmd(p, &["init", "-q", "-b", "main"]);
128 git_cmd(p, &["config", "user.email", "test@test.com"]);
129 git_cmd(p, &["config", "user.name", "test"]);
130 std::fs::write(p.join("README.md"), "init\n").unwrap();
132 git_cmd(p, &["add", "README.md"]);
133 git_cmd(p, &["commit", "-m", "init"]);
134 tmp
135 }
136
137 const TOML_WITH_STATES: &str = concat!(
138 "[project]\nname = \"test\"\n\n",
139 "[tickets]\ndir = \"tickets\"\n\n",
140 "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\n\n",
141 "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
142 );
143
144 fn make_ticket_content(id: &str, state: &str, epic: &str) -> String {
145 format!(
146 "+++\nid = \"{id}\"\ntitle = \"Ticket {id}\"\nstate = \"{state}\"\nepic = \"{epic}\"\n+++\n\nBody.\n"
147 )
148 }
149
150 #[test]
151 fn set_epic_owner_updates_non_terminal_skips_terminal() {
152 let tmp = setup_repo();
153 let p = tmp.path();
154 std::fs::write(p.join("apm.toml"), TOML_WITH_STATES).unwrap();
155 std::fs::create_dir_all(p.join(".apm")).unwrap();
156 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
157
158 let config = crate::config::Config::load(p).unwrap();
159
160 let content_a = make_ticket_content("aaaa1234", "ready", "epic1234");
162 crate::git::commit_to_branch(p, "ticket/aaaa1234-t1", "tickets/aaaa1234-t1.md", &content_a, "add t1").unwrap();
163
164 let content_b = make_ticket_content("bbbb5678", "closed", "epic1234");
166 crate::git::commit_to_branch(p, "ticket/bbbb5678-t2", "tickets/bbbb5678-t2.md", &content_b, "add t2").unwrap();
167
168 let content_c = make_ticket_content("cccc9012", "ready", "other123");
170 crate::git::commit_to_branch(p, "ticket/cccc9012-t3", "tickets/cccc9012-t3.md", &content_c, "add t3").unwrap();
171
172 let (changed, skipped) = set_epic_owner(p, "epic1234", "alice", &config).unwrap();
173 assert_eq!(changed, 1, "one non-terminal ticket should be changed");
174 assert_eq!(skipped, 1, "one terminal ticket should be skipped");
175 }
176
177 #[test]
178 fn set_epic_owner_all_terminal_returns_zero_changed() {
179 let tmp = setup_repo();
180 let p = tmp.path();
181 std::fs::write(p.join("apm.toml"), TOML_WITH_STATES).unwrap();
182
183 let config = crate::config::Config::load(p).unwrap();
184
185 let content_a = make_ticket_content("dddd1111", "closed", "epic5678");
186 crate::git::commit_to_branch(p, "ticket/dddd1111-t4", "tickets/dddd1111-t4.md", &content_a, "add t4").unwrap();
187 let content_b = make_ticket_content("eeee2222", "closed", "epic5678");
188 crate::git::commit_to_branch(p, "ticket/eeee2222-t5", "tickets/eeee2222-t5.md", &content_b, "add t5").unwrap();
189
190 let (changed, skipped) = set_epic_owner(p, "epic5678", "bob", &config).unwrap();
191 assert_eq!(changed, 0);
192 assert_eq!(skipped, 2);
193 }
194
195 const TOML_WITH_WORKER_END: &str = concat!(
196 "[project]\nname = \"test\"\n\n",
197 "[tickets]\ndir = \"tickets\"\n\n",
198 "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\nworker_end = false\n\n",
199 "[[workflow.states]]\nid = \"implemented\"\nlabel = \"Implemented\"\nterminal = false\nworker_end = true\n\n",
200 "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
201 );
202
203 #[test]
204 fn epic_is_quiescent_all_done() {
205 let tmp = setup_repo();
206 let p = tmp.path();
207 std::fs::write(p.join("apm.toml"), TOML_WITH_WORKER_END).unwrap();
208 std::fs::create_dir_all(p.join(".apm")).unwrap();
209 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
210
211 let config = crate::config::Config::load(p).unwrap();
212
213 let closed = make_ticket_content("aaaa0001", "closed", "epic0001");
214 crate::git::commit_to_branch(p, "ticket/aaaa0001-t1", "tickets/aaaa0001-t1.md", &closed, "add t1").unwrap();
215
216 let implemented = make_ticket_content("bbbb0002", "implemented", "epic0001");
217 crate::git::commit_to_branch(p, "ticket/bbbb0002-t2", "tickets/bbbb0002-t2.md", &implemented, "add t2").unwrap();
218
219 let blockers = epic_is_quiescent(p, "epic0001", &config, &[]).unwrap();
220 assert!(blockers.is_empty(), "expected no blockers, got: {blockers:?}");
221 }
222
223 #[test]
224 fn epic_is_quiescent_state_blocker() {
225 let tmp = setup_repo();
226 let p = tmp.path();
227 std::fs::write(p.join("apm.toml"), TOML_WITH_WORKER_END).unwrap();
228 std::fs::create_dir_all(p.join(".apm")).unwrap();
229 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
230
231 let config = crate::config::Config::load(p).unwrap();
232
233 let content = make_ticket_content("cccc0003", "ready", "epic0002");
234 crate::git::commit_to_branch(p, "ticket/cccc0003-t3", "tickets/cccc0003-t3.md", &content, "add t3").unwrap();
235
236 let blockers = epic_is_quiescent(p, "epic0002", &config, &[]).unwrap();
237 assert_eq!(blockers.len(), 1);
238 assert!(blockers[0].contains("cccc0003"));
239 assert!(blockers[0].contains("(state: ready)"));
240 }
241
242 #[test]
243 fn epic_is_quiescent_live_worker_blocker() {
244 let tmp = setup_repo();
245 let p = tmp.path();
246 std::fs::write(p.join("apm.toml"), TOML_WITH_WORKER_END).unwrap();
247 std::fs::create_dir_all(p.join(".apm")).unwrap();
248 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
249
250 let config = crate::config::Config::load(p).unwrap();
251
252 let content = make_ticket_content("dddd0004", "implemented", "epic0003");
254 crate::git::commit_to_branch(p, "ticket/dddd0004-t4", "tickets/dddd0004-t4.md", &content, "add t4").unwrap();
255
256 let wt_path = tmp.path().join("fake-worktree-dddd0004");
258 std::fs::create_dir_all(&wt_path).unwrap();
259 let pid = std::process::id();
260 std::fs::write(
261 wt_path.join(".apm-worker.pid"),
262 format!(r#"{{"pid":{pid},"ticket_id":"dddd0004","started_at":"2026-01-01T00:00:00Z"}}"#),
263 ).unwrap();
264
265 let worktrees = vec![(wt_path, "ticket/dddd0004-t4".to_string())];
266 let blockers = epic_is_quiescent(p, "epic0003", &config, &worktrees).unwrap();
267 assert_eq!(blockers.len(), 1);
268 assert!(blockers[0].contains("dddd0004"));
269 assert!(blockers[0].contains("(live worker)"));
270 }
271
272 fn make_state(terminal: bool, satisfies_deps: bool, actionable: Vec<&str>) -> StateConfig {
273 StateConfig {
274 id: "x".to_string(),
275 label: "x".to_string(),
276 description: String::new(),
277 terminal,
278 worker_end: false,
279 satisfies_deps: crate::config::SatisfiesDeps::Bool(satisfies_deps),
280 dep_requires: None,
281 transitions: vec![],
282 actionable: actionable.into_iter().map(|s| s.to_string()).collect(),
283 instructions: None,
284 }
285 }
286
287 #[test]
288 fn empty_slice_is_empty() {
289 assert_eq!(derive_epic_state(&[]), "empty");
290 }
291
292 #[test]
293 fn all_terminal_is_done() {
294 let a = make_state(true, false, vec![]);
295 let b = make_state(true, false, vec![]);
296 assert_eq!(derive_epic_state(&[&a, &b]), "done");
297 }
298
299 #[test]
300 fn all_satisfies_deps_not_all_terminal_is_implemented() {
301 let a = make_state(false, true, vec![]);
302 let b = make_state(true, false, vec![]);
303 assert_eq!(derive_epic_state(&[&a, &b]), "implemented");
304 }
305
306 #[test]
307 fn any_neither_satisfies_nor_terminal_is_in_progress() {
308 let a = make_state(false, false, vec![]);
309 let b = make_state(true, false, vec![]);
310 assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
311 }
312
313 #[test]
314 fn mixed_non_terminal_non_satisfies_is_in_progress() {
315 let a = make_state(false, false, vec![]);
316 let b = make_state(true, false, vec![]);
317 assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
318 }
319}
320
321pub fn create(root: &Path, title: &str, config: &crate::config::Config) -> Result<String> {
322 let id = crate::ticket_fmt::gen_hex_id();
323 let slug = crate::ticket::slugify(title);
324 let branch = format!("epic/{id}-{slug}");
325
326 let default_branch = &config.project.default_branch;
327 git_util::fetch_branch(root, default_branch)?;
328
329 let unique = std::time::SystemTime::now()
330 .duration_since(std::time::UNIX_EPOCH)
331 .map(|d| d.subsec_nanos())
332 .unwrap_or(0);
333 let wt_path = std::env::temp_dir().join(format!(
334 "apm-{}-{}-{}",
335 std::process::id(),
336 unique,
337 branch.replace('/', "-"),
338 ));
339
340 let wt_path_str = wt_path.to_string_lossy();
341 git_util::run(root, &["worktree", "add", "-b", &branch, &wt_path_str, &format!("origin/{default_branch}")])?;
342
343 let result = (|| -> Result<()> {
344 let epic_md = wt_path.join("EPIC.md");
345 std::fs::write(&epic_md, format!("# {title}\n"))?;
346
347 git_util::stage_files(&wt_path, &["EPIC.md"])?;
348
349 let commit_msg = format!("epic({id}): create {title}");
350 git_util::commit(&wt_path, &commit_msg)?;
351 Ok(())
352 })();
353
354 let _ = worktree::remove_worktree(root, &wt_path, true);
355 let _ = std::fs::remove_dir_all(&wt_path);
356
357 result?;
358
359 crate::git::push_branch_tracking(root, &branch)?;
360
361 Ok(branch)
362}
363
364pub fn find_epic_branch(root: &Path, short_id: &str) -> Option<String> {
365 let pattern = format!("epic/{short_id}-*");
366 let local = crate::git_util::run(root, &["branch", "--list", &pattern]).ok()?;
367 for b in local.lines().map(|l| l.trim().trim_start_matches(['*', '+']).trim()) {
368 if !b.is_empty() {
369 return Some(b.to_string());
370 }
371 }
372 let remote_pattern = format!("origin/epic/{short_id}-*");
373 let remote = crate::git_util::run(root, &["branch", "-r", "--list", &remote_pattern]).ok()?;
374 for b in remote.lines().map(|l| l.trim()) {
375 if !b.is_empty() {
376 return Some(b.trim_start_matches("origin/").to_string());
377 }
378 }
379 None
380}
381
382pub fn find_epic_branches(root: &Path, id_prefix: &str) -> Vec<String> {
383 let mut seen = std::collections::HashSet::new();
384 let mut result = Vec::new();
385
386 let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
387 for b in local.lines()
388 .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
389 .filter(|l| !l.is_empty())
390 {
391 let id_part = b.trim_start_matches("epic/").split('-').next().unwrap_or("");
392 if id_part.starts_with(id_prefix) && seen.insert(b.to_string()) {
393 result.push(b.to_string());
394 }
395 }
396
397 let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
398 for b in remote.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
399 let short = b.trim_start_matches("origin/");
400 let id_part = short.trim_start_matches("epic/").split('-').next().unwrap_or("");
401 if id_part.starts_with(id_prefix) && seen.insert(short.to_string()) {
402 result.push(short.to_string());
403 }
404 }
405
406 result
407}
408
409pub fn epic_branches(root: &Path) -> Result<Vec<String>> {
410 let mut seen = std::collections::HashSet::new();
411 let mut branches = Vec::new();
412
413 let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
414 for b in local.lines()
415 .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
416 .filter(|l| !l.is_empty())
417 {
418 if seen.insert(b.to_string()) {
419 branches.push(b.to_string());
420 }
421 }
422
423 let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
424 for b in remote.lines()
425 .map(|l| l.trim().trim_start_matches("origin/").to_string())
426 .filter(|l| !l.is_empty())
427 {
428 if seen.insert(b.clone()) {
429 branches.push(b);
430 }
431 }
432
433 branches.sort();
434 Ok(branches)
435}
436
437pub fn branch_to_title(branch: &str) -> String {
438 let rest = branch.trim_start_matches("epic/");
439 let slug = match rest.find('-') {
440 Some(pos) => &rest[pos + 1..],
441 None => rest,
442 };
443 slug.split('-')
444 .map(|word| {
445 let mut chars = word.chars();
446 match chars.next() {
447 None => String::new(),
448 Some(c) => c.to_uppercase().to_string() + chars.as_str(),
449 }
450 })
451 .collect::<Vec<_>>()
452 .join(" ")
453}
454
455pub fn epic_id_from_branch(branch: &str) -> &str {
456 let rest = branch.trim_start_matches("epic/");
457 match rest.find('-') {
458 Some(pos) => &rest[..pos],
459 None => rest,
460 }
461}
462
463pub fn set_epic_owner(
464 root: &Path,
465 epic_id: &str,
466 new_owner: &str,
467 config: &crate::config::Config,
468) -> Result<(usize, usize)> {
469 let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
470 let terminal = config.terminal_state_ids();
471
472 let (mut to_change, skipped): (Vec<_>, Vec<_>) = all_tickets
473 .into_iter()
474 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
475 .partition(|t| !terminal.contains(&t.frontmatter.state));
476
477 for t in &to_change {
478 crate::ticket::check_owner(root, t)?;
479 }
480
481 for t in &mut to_change {
482 crate::ticket::set_field(&mut t.frontmatter, "owner", new_owner)?;
483 let content = t.serialize()?;
484 let rel_path = format!(
485 "{}/{}",
486 config.tickets.dir.to_string_lossy(),
487 t.path.file_name().unwrap().to_string_lossy()
488 );
489 let ticket_branch = t.frontmatter.branch.clone()
490 .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path))
491 .unwrap_or_else(|| format!("ticket/{}", t.frontmatter.id));
492 crate::git::commit_to_branch(
493 root,
494 &ticket_branch,
495 &rel_path,
496 &content,
497 &format!("ticket({}): bulk set owner = {}", t.frontmatter.id, new_owner),
498 )?;
499 }
500
501 Ok((to_change.len(), skipped.len()))
502}
503
504pub fn create_epic_branch(root: &Path, title: &str, config: &crate::config::Config) -> Result<(String, String)> {
505 let id = crate::ticket_fmt::gen_hex_id();
506 let slug = crate::ticket::slugify(title);
507 let branch = format!("epic/{id}-{slug}");
508 let default_branch = &config.project.default_branch;
509 let _ = crate::git_util::run(root, &["fetch", "origin", default_branch]);
510 if crate::git_util::run(root, &["branch", &branch, &format!("origin/{default_branch}")]).is_err() {
511 crate::git_util::run(root, &["branch", &branch, default_branch])?;
512 }
513 crate::git_util::commit_to_branch(root, &branch, "EPIC.md", &format!("# {title}\n"), "epic: init")?;
514 let _ = crate::git_util::push_branch(root, &branch);
515 Ok((id, branch))
516}