1use anyhow::Result;
2use std::path::Path;
3
4use crate::config::StateConfig;
5use crate::git_util;
6
7#[derive(Debug)]
8pub struct EpicTicketInfo {
9 pub id: String,
10 pub state: String,
11 pub title: String,
12}
13
14pub struct EpicQuiescenceResult {
15 pub unsafe_tickets: Vec<EpicTicketInfo>,
16 pub auto_closeable: Vec<EpicTicketInfo>,
17 pub genuine_blockers: Vec<EpicTicketInfo>,
18}
19
20pub fn classify_epic_quiescence(
21 root: &Path,
22 epic_id: &str,
23 config: &crate::config::Config,
24 worktrees: &[(std::path::PathBuf, String)],
25 epic_branch: &str,
26) -> Result<EpicQuiescenceResult> {
27 let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
28 let terminal = config.terminal_state_ids();
29 let default_branch = &config.project.default_branch;
30
31 let mut unsafe_tickets = Vec::new();
32 let mut auto_closeable = Vec::new();
33 let mut genuine_blockers = Vec::new();
34
35 for t in all_tickets
36 .iter()
37 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
38 .filter(|t| !terminal.contains(&t.frontmatter.state))
39 {
40 let info = EpicTicketInfo {
41 id: t.frontmatter.id.clone(),
42 state: t.frontmatter.state.clone(),
43 title: t.frontmatter.title.clone(),
44 };
45
46 if info.state == "blocked" || info.state == "question" {
48 unsafe_tickets.push(info);
49 continue;
50 }
51
52 let ticket_branch = t.frontmatter.branch.clone()
54 .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path));
55 let has_live_worker = ticket_branch.as_ref().and_then(|branch| {
56 worktrees.iter().find(|(_, b)| b == branch)
57 }).map(|(wt_path, _)| {
58 let pid_file = wt_path.join(".apm-worker.pid");
59 if pid_file.exists() {
60 if let Ok((pid, _)) = crate::worker::read_pid_file(&pid_file) {
61 return crate::worker::is_alive(pid);
62 }
63 }
64 false
65 }).unwrap_or(false);
66
67 if has_live_worker {
68 genuine_blockers.push(info);
69 continue;
70 }
71
72 let is_merged = ticket_branch.as_ref().map(|branch| {
74 let merged_into_epic = git_util::is_branch_merged_into(root, branch, epic_branch)
75 .unwrap_or(false);
76 let merged_into_default = git_util::is_branch_merged_into(root, branch, default_branch)
77 .unwrap_or(false);
78 merged_into_epic || merged_into_default
79 }).unwrap_or(false);
80
81 if is_merged {
82 auto_closeable.push(info);
83 } else {
84 genuine_blockers.push(info);
85 }
86 }
87
88 unsafe_tickets.sort_by(|a, b| a.id.cmp(&b.id));
89 auto_closeable.sort_by(|a, b| a.id.cmp(&b.id));
90 genuine_blockers.sort_by(|a, b| a.id.cmp(&b.id));
91
92 Ok(EpicQuiescenceResult { unsafe_tickets, auto_closeable, genuine_blockers })
93}
94
95pub struct MergeStatus {
96 pub ahead: usize,
97 pub clean: bool,
98}
99
100pub fn merge_tree_status(root: &Path, default_branch: &str, epic_branch: &str) -> Result<MergeStatus> {
101 let log_out = git_util::run(root, &[
102 "log", "--oneline", "--no-decorate",
103 &format!("{epic_branch}..{default_branch}"),
104 ])?;
105 let ahead = log_out.lines().filter(|l| !l.is_empty()).count();
106 if ahead == 0 {
107 return Ok(MergeStatus { ahead: 0, clean: true });
108 }
109 let merge_base = git_util::run(root, &["merge-base", epic_branch, default_branch])?;
110 let merge_base = merge_base.trim();
111 let merge_tree_out = git_util::run(root, &[
112 "merge-tree", merge_base, default_branch, epic_branch,
113 ])?;
114 let clean = !merge_tree_out.contains("<<<<<<< ");
115 Ok(MergeStatus { ahead, clean })
116}
117
118pub fn epic_is_quiescent(
119 root: &Path,
120 epic_id: &str,
121 config: &crate::config::Config,
122 worktrees: &[(std::path::PathBuf, String)],
123) -> Result<Vec<String>> {
124 let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
125 let mut blockers = Vec::new();
126 let impl_states = config.implementation_state_ids();
127 let terminal_states = config.terminal_state_ids();
128
129 for t in all_tickets.iter().filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id)) {
130 let id = &t.frontmatter.id;
131 let title = &t.frontmatter.title;
132 let state_id = &t.frontmatter.state;
133
134 let has_reached_impl = impl_states.contains(state_id.as_str())
135 || crate::ticket_fmt::history_target_states(&t.body)
136 .iter()
137 .any(|s| impl_states.contains(s.as_str()));
138 if has_reached_impl && !terminal_states.contains(state_id.as_str()) {
139 blockers.push(format!(" {id} — {title} (state: {state_id})"));
140 continue;
141 }
142
143 let ticket_branch = t.frontmatter.branch.clone()
144 .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path));
145 if let Some(branch) = ticket_branch {
146 if let Some((wt_path, _)) = worktrees.iter().find(|(_, b)| b == &branch) {
147 let pid_file = wt_path.join(".apm-worker.pid");
148 if pid_file.exists() {
149 if let Ok((pid, _)) = crate::worker::read_pid_file(&pid_file) {
150 if crate::worker::is_alive(pid) {
151 blockers.push(format!(" {id} — {title} (live worker)"));
152 }
153 }
154 }
155 }
156 }
157 }
158
159 Ok(blockers)
160}
161
162pub fn derive_epic_state(states: &[&StateConfig]) -> &'static str {
172 if states.is_empty() {
173 return "empty";
174 }
175 if states.iter().any(|s| !matches!(s.satisfies_deps, crate::config::SatisfiesDeps::Bool(true)) && !s.terminal) {
176 return "in_progress";
177 }
178 if states.iter().all(|s| s.terminal) {
179 return "done";
180 }
181 "implemented"
182}
183
184pub fn create(root: &Path, title: &str, config: &crate::config::Config) -> Result<String> {
185 let id = crate::ticket_fmt::gen_hex_id();
186 let slug = crate::ticket::slugify(title);
187 let branch = format!("epic/{id}-{slug}");
188 let default_branch = &config.project.default_branch;
189 let _ = git_util::fetch_branch(root, default_branch);
190 if git_util::run(root, &["branch", &branch, &format!("origin/{default_branch}")]).is_err()
191 && git_util::run(root, &["branch", &branch, default_branch]).is_err()
192 {
193 crate::git::commit_to_branch(root, &branch, "tickets/.gitkeep", "", "epic: init")?;
194 }
195 let _ = crate::git::push_branch_tracking(root, &branch);
196 Ok(branch)
197}
198
199pub fn find_epic_branch(root: &Path, short_id: &str) -> Option<String> {
200 let pattern = format!("epic/{short_id}-*");
201 let local = crate::git_util::run(root, &["branch", "--list", &pattern]).ok()?;
202 for b in local.lines().map(|l| l.trim().trim_start_matches(['*', '+']).trim()) {
203 if !b.is_empty() {
204 return Some(b.to_string());
205 }
206 }
207 let remote_pattern = format!("origin/epic/{short_id}-*");
208 let remote = crate::git_util::run(root, &["branch", "-r", "--list", &remote_pattern]).ok()?;
209 for b in remote.lines().map(|l| l.trim()) {
210 if !b.is_empty() {
211 return Some(b.trim_start_matches("origin/").to_string());
212 }
213 }
214 None
215}
216
217pub fn find_epic_branches(root: &Path, id_prefix: &str) -> Vec<String> {
218 let mut seen = std::collections::HashSet::new();
219 let mut result = Vec::new();
220
221 let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
222 for b in local.lines()
223 .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
224 .filter(|l| !l.is_empty())
225 {
226 let id_part = b.trim_start_matches("epic/").split('-').next().unwrap_or("");
227 if id_part.starts_with(id_prefix) && seen.insert(b.to_string()) {
228 result.push(b.to_string());
229 }
230 }
231
232 let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
233 for b in remote.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
234 let short = b.trim_start_matches("origin/");
235 let id_part = short.trim_start_matches("epic/").split('-').next().unwrap_or("");
236 if id_part.starts_with(id_prefix) && seen.insert(short.to_string()) {
237 result.push(short.to_string());
238 }
239 }
240
241 result
242}
243
244pub fn epic_branches(root: &Path) -> Result<Vec<String>> {
245 let mut seen = std::collections::HashSet::new();
246 let mut branches = Vec::new();
247
248 let local = crate::git_util::run(root, &["branch", "--list", "epic/*"]).unwrap_or_default();
249 for b in local.lines()
250 .map(|l| l.trim().trim_start_matches(['*', '+']).trim())
251 .filter(|l| !l.is_empty())
252 {
253 if seen.insert(b.to_string()) {
254 branches.push(b.to_string());
255 }
256 }
257
258 let remote = crate::git_util::run(root, &["branch", "-r", "--list", "origin/epic/*"]).unwrap_or_default();
259 for b in remote.lines()
260 .map(|l| l.trim().trim_start_matches("origin/").to_string())
261 .filter(|l| !l.is_empty())
262 {
263 if seen.insert(b.clone()) {
264 branches.push(b);
265 }
266 }
267
268 branches.sort();
269 Ok(branches)
270}
271
272pub fn branch_to_title(branch: &str) -> String {
273 let rest = branch.trim_start_matches("epic/");
274 let slug = match rest.find('-') {
275 Some(pos) => &rest[pos + 1..],
276 None => rest,
277 };
278 slug.split('-')
279 .map(|word| {
280 let mut chars = word.chars();
281 match chars.next() {
282 None => String::new(),
283 Some(c) => c.to_uppercase().to_string() + chars.as_str(),
284 }
285 })
286 .collect::<Vec<_>>()
287 .join(" ")
288}
289
290pub fn epic_id_from_branch(branch: &str) -> &str {
291 let rest = branch.trim_start_matches("epic/");
292 match rest.find('-') {
293 Some(pos) => &rest[..pos],
294 None => rest,
295 }
296}
297
298pub fn set_epic_owner(
299 root: &Path,
300 epic_id: &str,
301 new_owner: &str,
302 config: &crate::config::Config,
303) -> Result<(usize, usize)> {
304 let all_tickets = crate::ticket::load_all_from_git(root, &config.tickets.dir)?;
305 let terminal = config.terminal_state_ids();
306
307 let (mut to_change, skipped): (Vec<_>, Vec<_>) = all_tickets
308 .into_iter()
309 .filter(|t| t.frontmatter.epic.as_deref() == Some(epic_id))
310 .partition(|t| !terminal.contains(&t.frontmatter.state));
311
312 for t in &to_change {
313 crate::ticket::check_owner(root, t)?;
314 }
315
316 for t in &mut to_change {
317 crate::ticket::set_field(&mut t.frontmatter, "owner", new_owner)?;
318 let content = t.serialize()?;
319 let rel_path = format!(
320 "{}/{}",
321 config.tickets.dir.to_string_lossy(),
322 t.path.file_name().unwrap().to_string_lossy()
323 );
324 let ticket_branch = t.frontmatter.branch.clone()
325 .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path))
326 .unwrap_or_else(|| format!("ticket/{}", t.frontmatter.id));
327 crate::git::commit_to_branch(
328 root,
329 &ticket_branch,
330 &rel_path,
331 &content,
332 &format!("ticket({}): bulk set owner = {}", t.frontmatter.id, new_owner),
333 )?;
334 }
335
336 Ok((to_change.len(), skipped.len()))
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::config::StateConfig;
343
344 #[test]
345 fn branch_to_title_basic() {
346 assert_eq!(branch_to_title("epic/ab12cd34-user-authentication"), "User Authentication");
347 }
348
349 #[test]
350 fn branch_to_title_single_word() {
351 assert_eq!(branch_to_title("epic/ab12cd34-dashboard"), "Dashboard");
352 }
353
354 #[test]
355 fn branch_to_title_many_words() {
356 assert_eq!(branch_to_title("epic/ab12cd34-add-oauth-login-flow"), "Add Oauth Login Flow");
357 }
358
359 #[test]
360 fn branch_to_title_no_slug() {
361 assert_eq!(branch_to_title("epic/ab12cd34"), "Ab12cd34");
362 }
363
364 #[test]
365 fn epic_id_from_branch_happy_path() {
366 assert_eq!(epic_id_from_branch("epic/57bce963-refactor-apm-core"), "57bce963");
367 }
368
369 #[test]
370 fn epic_id_from_branch_no_epic_prefix() {
371 assert_eq!(epic_id_from_branch("57bce963-refactor"), "57bce963");
372 }
373
374 #[test]
375 fn epic_id_from_branch_no_dash() {
376 assert_eq!(epic_id_from_branch("nodash"), "nodash");
377 }
378
379 fn git_cmd(dir: &std::path::Path, args: &[&str]) {
380 std::process::Command::new("git")
381 .args(args)
382 .current_dir(dir)
383 .env("GIT_AUTHOR_NAME", "test")
384 .env("GIT_AUTHOR_EMAIL", "test@test.com")
385 .env("GIT_COMMITTER_NAME", "test")
386 .env("GIT_COMMITTER_EMAIL", "test@test.com")
387 .output()
388 .unwrap();
389 }
390
391 fn setup_repo() -> tempfile::TempDir {
392 let tmp = tempfile::tempdir().unwrap();
393 let p = tmp.path();
394 git_cmd(p, &["init", "-q", "-b", "main"]);
395 git_cmd(p, &["config", "user.email", "test@test.com"]);
396 git_cmd(p, &["config", "user.name", "test"]);
397 std::fs::write(p.join("README.md"), "init\n").unwrap();
399 git_cmd(p, &["add", "README.md"]);
400 git_cmd(p, &["commit", "-m", "init"]);
401 tmp
402 }
403
404 const TOML_WITH_STATES: &str = concat!(
405 "[project]\nname = \"test\"\n\n",
406 "[tickets]\ndir = \"tickets\"\n\n",
407 "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\n\n",
408 "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
409 );
410
411 fn make_ticket_content(id: &str, state: &str, epic: &str) -> String {
412 format!(
413 "+++\nid = \"{id}\"\ntitle = \"Ticket {id}\"\nstate = \"{state}\"\nepic = \"{epic}\"\n+++\n\nBody.\n"
414 )
415 }
416
417 #[test]
418 fn set_epic_owner_updates_non_terminal_skips_terminal() {
419 let tmp = setup_repo();
420 let p = tmp.path();
421 std::fs::create_dir_all(p.join(".apm")).unwrap();
422 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
423 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
424
425 let config = crate::config::Config::load(p).unwrap();
426
427 let content_a = make_ticket_content("aaaa1234", "ready", "epic1234");
429 crate::git::commit_to_branch(p, "ticket/aaaa1234-t1", "tickets/aaaa1234-t1.md", &content_a, "add t1").unwrap();
430
431 let content_b = make_ticket_content("bbbb5678", "closed", "epic1234");
433 crate::git::commit_to_branch(p, "ticket/bbbb5678-t2", "tickets/bbbb5678-t2.md", &content_b, "add t2").unwrap();
434
435 let content_c = make_ticket_content("cccc9012", "ready", "other123");
437 crate::git::commit_to_branch(p, "ticket/cccc9012-t3", "tickets/cccc9012-t3.md", &content_c, "add t3").unwrap();
438
439 let (changed, skipped) = set_epic_owner(p, "epic1234", "alice", &config).unwrap();
440 assert_eq!(changed, 1, "one non-terminal ticket should be changed");
441 assert_eq!(skipped, 1, "one terminal ticket should be skipped");
442 }
443
444 #[test]
445 fn set_epic_owner_all_terminal_returns_zero_changed() {
446 let tmp = setup_repo();
447 let p = tmp.path();
448 std::fs::create_dir_all(p.join(".apm")).unwrap();
449 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
450
451 let config = crate::config::Config::load(p).unwrap();
452
453 let content_a = make_ticket_content("dddd1111", "closed", "epic5678");
454 crate::git::commit_to_branch(p, "ticket/dddd1111-t4", "tickets/dddd1111-t4.md", &content_a, "add t4").unwrap();
455 let content_b = make_ticket_content("eeee2222", "closed", "epic5678");
456 crate::git::commit_to_branch(p, "ticket/eeee2222-t5", "tickets/eeee2222-t5.md", &content_b, "add t5").unwrap();
457
458 let (changed, skipped) = set_epic_owner(p, "epic5678", "bob", &config).unwrap();
459 assert_eq!(changed, 0);
460 assert_eq!(skipped, 2);
461 }
462
463 #[test]
464 fn classify_epic_quiescence_all_closed_returns_empty() {
465 let tmp = setup_repo();
466 let p = tmp.path();
467 std::fs::create_dir_all(p.join(".apm")).unwrap();
468 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
469
470 let config = crate::config::Config::load(p).unwrap();
471
472 git_cmd(p, &["checkout", "-b", "epic/qq110000-test"]);
474 git_cmd(p, &["checkout", "main"]);
475
476 let content_a = make_ticket_content("qq110001", "closed", "qq110000");
477 crate::git::commit_to_branch(p, "ticket/qq110001-ta", "tickets/qq110001-ta.md", &content_a, "add ta").unwrap();
478 let content_b = make_ticket_content("qq110002", "closed", "qq110000");
479 crate::git::commit_to_branch(p, "ticket/qq110002-tb", "tickets/qq110002-tb.md", &content_b, "add tb").unwrap();
480
481 let result = classify_epic_quiescence(p, "qq110000", &config, &[], "epic/qq110000-test").unwrap();
482 assert!(result.unsafe_tickets.is_empty(), "unsafe should be empty");
483 assert!(result.auto_closeable.is_empty(), "auto_closeable should be empty");
484 assert!(result.genuine_blockers.is_empty(), "genuine_blockers should be empty");
485 }
486
487 #[test]
488 fn classify_epic_quiescence_ignores_other_epics() {
489 let tmp = setup_repo();
490 let p = tmp.path();
491 std::fs::create_dir_all(p.join(".apm")).unwrap();
492 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
493
494 let config = crate::config::Config::load(p).unwrap();
495
496 git_cmd(p, &["checkout", "-b", "epic/qq120000-test"]);
497 git_cmd(p, &["checkout", "main"]);
498
499 let content_a = make_ticket_content("qq120001", "ready", "qq120000");
501 crate::git::commit_to_branch(p, "ticket/qq120001-ta", "tickets/qq120001-ta.md", &content_a, "add ta").unwrap();
502 let content_b = make_ticket_content("qq120002", "ready", "otherep0");
504 crate::git::commit_to_branch(p, "ticket/qq120002-tb", "tickets/qq120002-tb.md", &content_b, "add tb").unwrap();
505
506 let result = classify_epic_quiescence(p, "qq120000", &config, &[], "epic/qq120000-test").unwrap();
507 let all: Vec<_> = result.unsafe_tickets.iter()
508 .chain(result.auto_closeable.iter())
509 .chain(result.genuine_blockers.iter())
510 .collect();
511 assert_eq!(all.len(), 1, "only ticket qq120001 should be classified");
512 assert_eq!(all[0].id, "qq120001");
513 }
514
515 #[test]
516 fn classify_epic_quiescence_three_buckets() {
517 let tmp = setup_repo();
518 let p = tmp.path();
519 std::fs::create_dir_all(p.join(".apm")).unwrap();
520 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_STATES).unwrap();
521
522 let config = crate::config::Config::load(p).unwrap();
523
524 git_cmd(p, &["checkout", "-b", "epic/qq130000-test"]);
526 git_cmd(p, &["checkout", "main"]);
527
528 let content_blocked = make_ticket_content("qq130001", "blocked", "qq130000");
530 crate::git::commit_to_branch(p, "ticket/qq130001-t-blocked", "tickets/qq130001-t-blocked.md", &content_blocked, "add blocked").unwrap();
531
532 let content_merged = make_ticket_content("qq130002", "ready", "qq130000");
534 crate::git::commit_to_branch(p, "ticket/qq130002-t-merged", "tickets/qq130002-t-merged.md", &content_merged, "add merged").unwrap();
535 git_cmd(p, &["checkout", "epic/qq130000-test"]);
537 git_cmd(p, &["merge", "--no-ff", "ticket/qq130002-t-merged", "-m", "merge ticket qq130002"]);
538 git_cmd(p, &["checkout", "main"]);
539
540 let content_unmerged = make_ticket_content("qq130003", "ready", "qq130000");
542 crate::git::commit_to_branch(p, "ticket/qq130003-t-unmerged", "tickets/qq130003-t-unmerged.md", &content_unmerged, "add unmerged").unwrap();
543
544 let result = classify_epic_quiescence(p, "qq130000", &config, &[], "epic/qq130000-test").unwrap();
545 assert_eq!(result.unsafe_tickets.len(), 1, "one unsafe ticket expected");
546 assert_eq!(result.unsafe_tickets[0].id, "qq130001");
547 assert_eq!(result.auto_closeable.len(), 1, "one auto_closeable ticket expected");
548 assert_eq!(result.auto_closeable[0].id, "qq130002");
549 assert_eq!(result.genuine_blockers.len(), 1, "one genuine blocker expected");
550 assert_eq!(result.genuine_blockers[0].id, "qq130003");
551 }
552
553 const TOML_WITH_WORKER_END: &str = concat!(
554 "[project]\nname = \"test\"\n\n",
555 "[tickets]\ndir = \"tickets\"\n\n",
556 "[[workflow.states]]\nid = \"ready\"\nlabel = \"Ready\"\nterminal = false\nworker_end = false\n\n",
557 "[[workflow.states]]\nid = \"implemented\"\nlabel = \"Implemented\"\nterminal = false\nworker_end = true\n\n",
558 "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
559 );
560
561 const TOML_WITH_IMPL_STATES: &str = r#"
562[project]
563name = "test"
564
565[tickets]
566dir = "tickets"
567
568[[workflow.states]]
569id = "ready"
570label = "Ready"
571
572 [[workflow.states.transitions]]
573 to = "in_progress"
574 trigger = "command:start"
575
576[[workflow.states]]
577id = "in_progress"
578label = "In Progress"
579worker_profile = "claude/coder"
580
581 [[workflow.states.transitions]]
582 to = "implemented"
583 trigger = "manual"
584 completion = "pr_or_epic_merge"
585
586[[workflow.states]]
587id = "implemented"
588label = "Implemented"
589
590[[workflow.states]]
591id = "ammend"
592label = "Ammend"
593
594[[workflow.states]]
595id = "closed"
596label = "Closed"
597terminal = true
598"#;
599
600 const TOML_WITH_IMPL_STATES_REVERSED: &str = r#"
601[project]
602name = "test"
603
604[tickets]
605dir = "tickets"
606
607[[workflow.states]]
608id = "closed"
609label = "Closed"
610terminal = true
611
612[[workflow.states]]
613id = "ammend"
614label = "Ammend"
615
616[[workflow.states]]
617id = "implemented"
618label = "Implemented"
619
620[[workflow.states]]
621id = "in_progress"
622label = "In Progress"
623worker_profile = "claude/coder"
624
625 [[workflow.states.transitions]]
626 to = "implemented"
627 trigger = "manual"
628 completion = "pr_or_epic_merge"
629
630[[workflow.states]]
631id = "ready"
632label = "Ready"
633
634 [[workflow.states.transitions]]
635 to = "in_progress"
636 trigger = "command:start"
637"#;
638
639 fn make_ticket_content_with_history(id: &str, state: &str, epic: &str, history: &[(&str, &str)]) -> String {
640 let mut s = format!(
641 "+++\nid = \"{id}\"\ntitle = \"Ticket {id}\"\nstate = \"{state}\"\nepic = \"{epic}\"\n+++\n\nBody.\n"
642 );
643 s.push_str("\n## History\n\n| When | From | To | By |\n|------|------|----|----|\n");
644 for (from, to) in history {
645 s.push_str(&format!("| 2026-01-01T00:00Z | {from} | {to} | test |\n"));
646 }
647 s
648 }
649
650 #[test]
651 fn epic_is_quiescent_all_done() {
652 let tmp = setup_repo();
653 let p = tmp.path();
654 std::fs::create_dir_all(p.join(".apm")).unwrap();
655 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_WORKER_END).unwrap();
656 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
657
658 let config = crate::config::Config::load(p).unwrap();
659
660 let closed = make_ticket_content("aaaa0001", "closed", "epic0001");
661 crate::git::commit_to_branch(p, "ticket/aaaa0001-t1", "tickets/aaaa0001-t1.md", &closed, "add t1").unwrap();
662
663 let implemented = make_ticket_content("bbbb0002", "implemented", "epic0001");
664 crate::git::commit_to_branch(p, "ticket/bbbb0002-t2", "tickets/bbbb0002-t2.md", &implemented, "add t2").unwrap();
665
666 let blockers = epic_is_quiescent(p, "epic0001", &config, &[]).unwrap();
667 assert!(blockers.is_empty(), "expected no blockers, got: {blockers:?}");
668 }
669
670 #[test]
671 fn epic_is_quiescent_state_blocker() {
672 let tmp = setup_repo();
673 let p = tmp.path();
674 std::fs::create_dir_all(p.join(".apm")).unwrap();
675 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
676 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
677
678 let config = crate::config::Config::load(p).unwrap();
679
680 let content = make_ticket_content("cccc0003", "in_progress", "epic0002");
681 crate::git::commit_to_branch(p, "ticket/cccc0003-t3", "tickets/cccc0003-t3.md", &content, "add t3").unwrap();
682
683 let blockers = epic_is_quiescent(p, "epic0002", &config, &[]).unwrap();
684 assert_eq!(blockers.len(), 1);
685 assert!(blockers[0].contains("cccc0003"));
686 assert!(blockers[0].contains("(state: in_progress)"));
687 }
688
689 #[test]
690 fn epic_is_quiescent_live_worker_blocker() {
691 let tmp = setup_repo();
692 let p = tmp.path();
693 std::fs::create_dir_all(p.join(".apm")).unwrap();
694 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_WORKER_END).unwrap();
695 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
696
697 let config = crate::config::Config::load(p).unwrap();
698
699 let content = make_ticket_content("dddd0004", "implemented", "epic0003");
701 crate::git::commit_to_branch(p, "ticket/dddd0004-t4", "tickets/dddd0004-t4.md", &content, "add t4").unwrap();
702
703 let wt_path = tmp.path().join("fake-worktree-dddd0004");
705 std::fs::create_dir_all(&wt_path).unwrap();
706 let pid = std::process::id();
707 std::fs::write(
708 wt_path.join(".apm-worker.pid"),
709 format!(r#"{{"pid":{pid},"ticket_id":"dddd0004","started_at":"2026-01-01T00:00:00Z"}}"#),
710 ).unwrap();
711
712 let worktrees = vec![(wt_path, "ticket/dddd0004-t4".to_string())];
713 let blockers = epic_is_quiescent(p, "epic0003", &config, &worktrees).unwrap();
714 assert_eq!(blockers.len(), 1);
715 assert!(blockers[0].contains("dddd0004"));
716 assert!(blockers[0].contains("(live worker)"));
717 }
718
719 #[test]
720 fn epic_is_quiescent_ready_no_history_does_not_block() {
721 let tmp = setup_repo();
722 let p = tmp.path();
723 std::fs::create_dir_all(p.join(".apm")).unwrap();
724 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
725 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
726
727 let config = crate::config::Config::load(p).unwrap();
728
729 let content = make_ticket_content("eeee0005", "ready", "epic0005");
730 crate::git::commit_to_branch(p, "ticket/eeee0005-t5", "tickets/eeee0005-t5.md", &content, "add t5").unwrap();
731
732 let blockers = epic_is_quiescent(p, "epic0005", &config, &[]).unwrap();
733 assert!(blockers.is_empty(), "expected no blockers for ready ticket with no history, got: {blockers:?}");
734 }
735
736 #[test]
737 fn epic_is_quiescent_ammend_with_impl_history_blocks() {
738 let tmp = setup_repo();
739 let p = tmp.path();
740 std::fs::create_dir_all(p.join(".apm")).unwrap();
741 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
742 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
743
744 let config = crate::config::Config::load(p).unwrap();
745
746 let content = make_ticket_content_with_history(
747 "ffff0006", "ammend", "epic0006",
748 &[("groomed", "in_progress"), ("in_progress", "ammend")],
749 );
750 crate::git::commit_to_branch(p, "ticket/ffff0006-t6", "tickets/ffff0006-t6.md", &content, "add t6").unwrap();
751
752 let blockers = epic_is_quiescent(p, "epic0006", &config, &[]).unwrap();
753 assert_eq!(blockers.len(), 1, "expected ammend ticket with impl history to block");
754 assert!(blockers[0].contains("ffff0006"));
755 }
756
757 #[test]
758 fn epic_is_quiescent_closed_with_impl_history_does_not_block() {
759 let tmp = setup_repo();
760 let p = tmp.path();
761 std::fs::create_dir_all(p.join(".apm")).unwrap();
762 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
763 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
764
765 let config = crate::config::Config::load(p).unwrap();
766
767 let content = make_ticket_content_with_history(
768 "gggg0007", "closed", "epic0007",
769 &[("in_progress", "implemented"), ("implemented", "closed")],
770 );
771 crate::git::commit_to_branch(p, "ticket/gggg0007-t7", "tickets/gggg0007-t7.md", &content, "add t7").unwrap();
772
773 let blockers = epic_is_quiescent(p, "epic0007", &config, &[]).unwrap();
774 assert!(blockers.is_empty(), "expected closed ticket with impl history not to block, got: {blockers:?}");
775 }
776
777 #[test]
778 fn epic_is_quiescent_order_invariant() {
779 let tmp = setup_repo();
780 let p = tmp.path();
781 std::fs::create_dir_all(p.join(".apm")).unwrap();
782 std::fs::write(p.join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
783
784 let blocking = make_ticket_content("hhhh0008", "in_progress", "epic0008");
786 crate::git::commit_to_branch(p, "ticket/hhhh0008-t8", "tickets/hhhh0008-t8.md", &blocking, "add t8").unwrap();
787 let non_blocking = make_ticket_content("iiii0009", "ready", "epic0008");
788 crate::git::commit_to_branch(p, "ticket/iiii0009-t9", "tickets/iiii0009-t9.md", &non_blocking, "add t9").unwrap();
789
790 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES).unwrap();
791 let config1 = crate::config::Config::load(p).unwrap();
792 let blockers1 = epic_is_quiescent(p, "epic0008", &config1, &[]).unwrap();
793
794 std::fs::write(p.join(".apm/config.toml"), TOML_WITH_IMPL_STATES_REVERSED).unwrap();
795 let config2 = crate::config::Config::load(p).unwrap();
796 let blockers2 = epic_is_quiescent(p, "epic0008", &config2, &[]).unwrap();
797
798 let mut b1 = blockers1.clone();
799 let mut b2 = blockers2.clone();
800 b1.sort();
801 b2.sort();
802 assert_eq!(b1, b2, "epic_is_quiescent must be invariant to [[workflow.states]] order");
803 assert_eq!(b1.len(), 1, "expected exactly one blocker (the in_progress ticket)");
804 }
805
806 fn make_state(terminal: bool, satisfies_deps: bool) -> StateConfig {
807 StateConfig {
808 id: "x".to_string(),
809 label: "x".to_string(),
810 description: String::new(),
811 terminal,
812 worker_end: false,
813 satisfies_deps: crate::config::SatisfiesDeps::Bool(satisfies_deps),
814 dep_requires: None,
815 worker_profile: None,
816 transitions: vec![],
817 }
818 }
819
820 #[test]
821 fn empty_slice_is_empty() {
822 assert_eq!(derive_epic_state(&[]), "empty");
823 }
824
825 #[test]
826 fn all_terminal_is_done() {
827 let a = make_state(true, false);
828 let b = make_state(true, false);
829 assert_eq!(derive_epic_state(&[&a, &b]), "done");
830 }
831
832 #[test]
833 fn all_satisfies_deps_not_all_terminal_is_implemented() {
834 let a = make_state(false, true);
835 let b = make_state(true, false);
836 assert_eq!(derive_epic_state(&[&a, &b]), "implemented");
837 }
838
839 #[test]
840 fn any_neither_satisfies_nor_terminal_is_in_progress() {
841 let a = make_state(false, false);
842 let b = make_state(true, false);
843 assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
844 }
845
846 #[test]
847 fn mixed_non_terminal_non_satisfies_is_in_progress() {
848 let a = make_state(false, false);
849 let b = make_state(true, false);
850 assert_eq!(derive_epic_state(&[&a, &b]), "in_progress");
851 }
852
853 #[test]
854 fn merge_tree_status_up_to_date() {
855 let tmp = setup_repo();
856 let p = tmp.path();
857 git_cmd(p, &["checkout", "-b", "epic/aa000001-test"]);
858 git_cmd(p, &["checkout", "main"]);
859
860 let status = super::merge_tree_status(p, "main", "epic/aa000001-test").unwrap();
861 assert_eq!(status.ahead, 0);
862 assert!(status.clean);
863 }
864
865 #[test]
866 fn merge_tree_status_clean_merge() {
867 let tmp = setup_repo();
868 let p = tmp.path();
869 git_cmd(p, &["checkout", "-b", "epic/bb000002-test"]);
870 git_cmd(p, &["checkout", "main"]);
871 std::fs::write(p.join("main_only.md"), "content\n").unwrap();
872 git_cmd(p, &["add", "main_only.md"]);
873 git_cmd(p, &["commit", "-m", "add main-only file"]);
874
875 let status = super::merge_tree_status(p, "main", "epic/bb000002-test").unwrap();
876 assert!(status.ahead > 0, "expected main to be ahead");
877 assert!(status.clean, "expected clean merge");
878 }
879
880 #[test]
881 fn merge_tree_status_conflict() {
882 let tmp = setup_repo();
883 let p = tmp.path();
884 std::fs::write(p.join("shared.md"), "original line\n").unwrap();
885 git_cmd(p, &["add", "shared.md"]);
886 git_cmd(p, &["commit", "-m", "add shared file"]);
887
888 git_cmd(p, &["checkout", "-b", "epic/cc000003-test"]);
889 std::fs::write(p.join("shared.md"), "epic branch content\n").unwrap();
890 git_cmd(p, &["add", "shared.md"]);
891 git_cmd(p, &["commit", "-m", "modify shared on epic"]);
892
893 git_cmd(p, &["checkout", "main"]);
894 std::fs::write(p.join("shared.md"), "main branch content\n").unwrap();
895 git_cmd(p, &["add", "shared.md"]);
896 git_cmd(p, &["commit", "-m", "modify shared on main"]);
897
898 let status = super::merge_tree_status(p, "main", "epic/cc000003-test").unwrap();
899 assert!(status.ahead > 0, "expected main to be ahead");
900 assert!(!status.clean, "expected conflicted merge");
901 }
902}
903