1use anyhow::{bail, Context, Result};
2use std::collections::{HashMap, HashSet, VecDeque};
3use std::path::Path;
4
5use super::ticket_fmt::{parse_checklist, serialize_checklist, id_arg_prefixes, slugify, Frontmatter, Ticket, TicketDocument};
6
7impl Ticket {
8 pub fn score(&self, priority_weight: f64, effort_weight: f64, risk_weight: f64) -> f64 {
9 let fm = &self.frontmatter;
10 fm.priority as f64 * priority_weight
11 + fm.effort as f64 * effort_weight
12 + fm.risk as f64 * risk_weight
13 }
14}
15
16impl TicketDocument {
17 pub fn unchecked_tasks(&self, section_name: &str) -> Vec<usize> {
18 let val = self.sections.get(section_name).map(|s| s.as_str()).unwrap_or("");
19 parse_checklist(val).into_iter().enumerate()
20 .filter(|(_, c)| !c.checked)
21 .map(|(i, _)| i)
22 .collect()
23 }
24
25 pub fn toggle_criterion(&mut self, index: usize, checked: bool) -> Result<()> {
26 let val = self.sections.get("Acceptance criteria").cloned().unwrap_or_default();
27 let mut items = parse_checklist(&val);
28 if index >= items.len() {
29 anyhow::bail!("criterion index {index} out of range (have {})", items.len());
30 }
31 items[index].checked = checked;
32 self.sections.insert("Acceptance criteria".to_string(), serialize_checklist(&items));
33 Ok(())
34 }
35}
36
37pub fn build_reverse_index<'a>(tickets: &[&'a Ticket]) -> HashMap<&'a str, Vec<&'a Ticket>> {
41 let mut map: HashMap<&'a str, Vec<&'a Ticket>> = HashMap::new();
42 for &ticket in tickets {
43 if let Some(deps) = &ticket.frontmatter.depends_on {
44 for dep_id in deps {
45 map.entry(dep_id.as_str()).or_default().push(ticket);
46 }
47 }
48 }
49 map
50}
51
52pub fn effective_priority(ticket: &Ticket, reverse_index: &HashMap<&str, Vec<&Ticket>>) -> u8 {
56 let mut max_priority = ticket.frontmatter.priority;
57 let mut visited: HashSet<&str> = HashSet::new();
58 let mut queue: VecDeque<&str> = VecDeque::new();
59 let id = ticket.frontmatter.id.as_str();
60 queue.push_back(id);
61 visited.insert(id);
62 while let Some(cur_id) = queue.pop_front() {
63 if let Some(dependents) = reverse_index.get(cur_id) {
64 for &dep in dependents {
65 let dep_id = dep.frontmatter.id.as_str();
66 if visited.insert(dep_id) {
67 if dep.frontmatter.priority > max_priority {
68 max_priority = dep.frontmatter.priority;
69 }
70 queue.push_back(dep_id);
71 }
72 }
73 }
74 }
75 max_priority
76}
77
78pub fn sorted_actionable<'a>(
80 tickets: &'a [Ticket],
81 actionable: &[&str],
82 pw: f64,
83 ew: f64,
84 rw: f64,
85 _caller: Option<&str>,
86 owner_filter: Option<&str>,
87) -> Vec<&'a Ticket> {
88 let mut candidates: Vec<&Ticket> = tickets
89 .iter()
90 .filter(|t| actionable.contains(&t.frontmatter.state.as_str()))
91 .filter(|t| owner_filter.is_none_or(|f| t.frontmatter.owner.as_deref() == Some(f)))
92 .collect();
93 let rev_idx = build_reverse_index(&candidates);
94 candidates.sort_by(|a, b| {
95 let score_a = effective_priority(a, &rev_idx) as f64 * pw
96 + a.frontmatter.effort as f64 * ew
97 + a.frontmatter.risk as f64 * rw;
98 let score_b = effective_priority(b, &rev_idx) as f64 * pw
99 + b.frontmatter.effort as f64 * ew
100 + b.frontmatter.risk as f64 * rw;
101 score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal)
102 });
103 candidates
104}
105
106pub fn dep_satisfied(dep_state: &str, required_gate: Option<&str>, config: &crate::config::Config) -> bool {
111 use crate::config::SatisfiesDeps;
112 config.workflow.states.iter()
113 .find(|s| s.id == dep_state)
114 .map(|s| {
115 if s.terminal { return true; }
116 match &s.satisfies_deps {
117 SatisfiesDeps::Bool(true) => true,
118 SatisfiesDeps::Tag(tag) => required_gate == Some(tag.as_str()),
119 SatisfiesDeps::Bool(false) => false,
120 }
121 })
122 .unwrap_or(false)
123}
124
125#[allow(clippy::too_many_arguments)]
129pub fn pick_next<'a>(
130 tickets: &'a [Ticket],
131 actionable: &[&str],
132 startable: &[&str],
133 pw: f64,
134 ew: f64,
135 rw: f64,
136 config: &crate::config::Config,
137 caller: Option<&str>,
138 owner_filter: Option<&str>,
139) -> Option<&'a Ticket> {
140 sorted_actionable(tickets, actionable, pw, ew, rw, caller, owner_filter)
141 .into_iter()
142 .find(|t| {
143 let state = t.frontmatter.state.as_str();
144 if !startable.is_empty() && !startable.contains(&state) {
145 return false;
146 }
147 let required_gate = config.workflow.states.iter()
148 .find(|s| s.id == state)
149 .and_then(|s| s.dep_requires.as_deref());
150 if let Some(deps) = &t.frontmatter.depends_on {
151 for dep_id in deps {
152 if let Some(dep) = tickets.iter().find(|d| d.frontmatter.id == *dep_id) {
153 if !dep_satisfied(&dep.frontmatter.state, required_gate, config) {
154 return false;
155 }
156 }
157 }
158 }
159 true
160 })
161}
162
163pub fn load_all_from_git(root: &Path, tickets_dir_rel: &std::path::Path) -> Result<Vec<Ticket>> {
166 let branches = crate::git::ticket_branches(root)?;
167 let mut tickets = Vec::new();
168 for branch in &branches {
169 let suffix = branch.trim_start_matches("ticket/");
170 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
173 continue;
174 }
175 let filename = format!("{suffix}.md");
176 let rel_path = format!("{}/{}", tickets_dir_rel.to_string_lossy(), filename);
177 let dummy_path = root.join(&rel_path);
178 if let Ok(content) = crate::git::read_from_branch(root, branch, &rel_path) {
179 if let Ok(t) = Ticket::parse(&dummy_path, &content) {
180 tickets.push(t);
181 }
182 }
183 }
184 tickets.sort_by_key(|t| t.frontmatter.created_at);
185 Ok(tickets)
186}
187
188pub fn load_all_from_git_classified(root: &Path, tickets_dir_rel: &std::path::Path) -> Result<Vec<Ticket>> {
203 let branches = crate::git::ticket_branches(root)?;
204 let mut tickets = Vec::new();
205 for branch in &branches {
206 let suffix = branch.trim_start_matches("ticket/");
207 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
208 continue;
209 }
210 let filename = format!("{suffix}.md");
211 let rel_path = format!("{}/{}", tickets_dir_rel.to_string_lossy(), filename);
212 let dummy_path = root.join(&rel_path);
213 if let Ok((content, class)) = crate::git::read_from_branch_with_class(root, branch, &rel_path) {
214 if let Ok(mut t) = Ticket::parse(&dummy_path, &content) {
215 t.local_stale = matches!(class, crate::git::BranchClass::Behind);
216 t.local_diverged = matches!(class, crate::git::BranchClass::Diverged);
217 tickets.push(t);
218 }
219 }
220 }
221 tickets.sort_by_key(|t| t.frontmatter.created_at);
222 Ok(tickets)
223}
224
225pub fn state_from_branch(root: &Path, branch: &str, rel_path: &str) -> Option<String> {
227 let content = crate::git::read_from_branch(root, branch, rel_path).ok()?;
228 let dummy = root.join(rel_path);
229 Ticket::parse(&dummy, &content).ok().map(|t| t.frontmatter.state)
230}
231
232pub fn close(
236 root: &Path,
237 config: &crate::config::Config,
238 id_arg: &str,
239 reason: Option<&str>,
240 agent: &str,
241 aggressive: bool,
242) -> Result<Vec<String>> {
243 let mut output: Vec<String> = Vec::new();
244 let mut tickets = load_all_from_git(root, &config.tickets.dir)?;
245 let prefixes = id_arg_prefixes(id_arg)?;
246
247 let branch_matches: Vec<usize> = tickets.iter()
250 .enumerate()
251 .filter(|(_, t)| prefixes.iter().any(|p| t.frontmatter.id.starts_with(p.as_str())))
252 .map(|(i, _)| i)
253 .collect();
254 let branch_matches: Vec<usize> = {
256 let mut seen = std::collections::HashSet::new();
257 branch_matches.into_iter().filter(|&i| seen.insert(tickets[i].frontmatter.id.clone())).collect()
258 };
259
260 let mut from_default: Option<Ticket> = None;
261 let id: String = match branch_matches.len() {
262 1 => tickets[branch_matches[0]].frontmatter.id.clone(),
263 0 => {
264 let default_branch = &config.project.default_branch;
265 let mut found: Option<Ticket> = None;
266 if let Ok(files) = crate::git::list_files_on_branch(root, default_branch, &config.tickets.dir.to_string_lossy()) {
267 for rel_path in files {
268 if !rel_path.ends_with(".md") { continue; }
269 if let Ok(content) = crate::git::read_from_branch(root, default_branch, &rel_path) {
270 let dummy = root.join(&rel_path);
271 if let Ok(t) = Ticket::parse(&dummy, &content) {
272 if prefixes.iter().any(|p| t.frontmatter.id.starts_with(p.as_str())) {
273 found = Some(t);
274 break;
275 }
276 }
277 }
278 }
279 }
280 match found {
281 Some(t) => { let id = t.frontmatter.id.clone(); from_default = Some(t); id }
282 None => bail!("no ticket matches '{id_arg}'"),
283 }
284 }
285 _ => {
286 let names: Vec<String> = branch_matches.iter()
287 .map(|&i| tickets[i].frontmatter.id.clone())
288 .collect();
289 bail!("ambiguous prefix '{}', matches: {}", id_arg, names.join(", "));
290 }
291 };
292
293 let ticket_pos = tickets.iter().position(|t| t.frontmatter.id == id);
294 let t: &mut Ticket = match ticket_pos {
295 Some(pos) => &mut tickets[pos],
296 None => from_default.as_mut().ok_or_else(|| anyhow::anyhow!("ticket {id:?} not found"))?,
297 };
298
299 if t.frontmatter.state == "closed" {
300 anyhow::bail!("ticket {id:?} is already closed");
301 }
302
303 let now = chrono::Utc::now();
304 let prev = t.frontmatter.state.clone();
305 let when = now.format("%Y-%m-%dT%H:%MZ").to_string();
306 let by = match reason {
307 Some(r) => format!("{agent} (reason: {r})"),
308 None => agent.to_string(),
309 };
310
311 t.frontmatter.state = "closed".into();
312 t.frontmatter.updated_at = Some(now);
313
314 crate::state::append_history(&mut t.body, &prev, "closed", &when, &by);
315
316 let content = t.serialize()?;
317 let rel_path = format!(
318 "{}/{}",
319 config.tickets.dir.to_string_lossy(),
320 t.path.file_name().unwrap().to_string_lossy()
321 );
322 let branch = t.frontmatter.branch.clone()
323 .or_else(|| crate::ticket_fmt::branch_name_from_path(&t.path))
324 .unwrap_or_else(|| format!("ticket/{id}"));
325
326 crate::git::commit_to_branch(root, &branch, &rel_path, &content, &format!("ticket({id}): close"))?;
327 crate::logger::log("state_transition", &format!("{id:?} {prev} -> closed"));
328
329 let target = t.frontmatter.target_branch.as_deref()
330 .unwrap_or(config.project.default_branch.as_str());
331 if let Err(e) = crate::git::commit_to_branch(root, target, &rel_path, &content, &format!("ticket({id}): close")) {
332 output.push(format!("warning: commit closed state to {target} failed: {e:#}"));
333 }
334
335 if aggressive {
336 if let Err(e) = crate::git::push_branch(root, &branch) {
337 output.push(format!("warning: push failed for {branch}: {e:#}"));
338 }
339 }
340
341 output.push(format!("{id}: {prev} → closed"));
342 Ok(output)
343}
344
345#[allow(clippy::too_many_arguments)]
346pub fn create(
347 root: &std::path::Path,
348 config: &crate::config::Config,
349 title: String,
350 author: String,
351 actor: String,
352 context: Option<String>,
353 context_section: Option<String>,
354 aggressive: bool,
355 section_sets: Vec<(String, String)>,
356 epic: Option<String>,
357 target_branch: Option<String>,
358 depends_on: Option<Vec<String>>,
359 base_branch: Option<String>,
360 warnings: &mut Vec<String>,
361) -> Result<Ticket> {
362 let tickets_dir = root.join(&config.tickets.dir);
363 std::fs::create_dir_all(&tickets_dir)?;
364
365 let id = crate::ticket_fmt::gen_hex_id();
366 let slug = slugify(&title);
367 let filename = format!("{id}-{slug}.md");
368 let rel_path = format!("{}/{}", config.tickets.dir.to_string_lossy(), filename);
369 let branch = format!("ticket/{id}-{slug}");
370 let now = chrono::Utc::now();
371 let fm = Frontmatter {
372 id: id.clone(),
373 title: title.clone(),
374 state: "new".into(),
375 priority: 0,
376 effort: 0,
377 risk: 0,
378 author: Some(author.clone()),
379 owner: Some(author.clone()),
380 branch: Some(branch.clone()),
381 created_at: Some(now),
382 updated_at: Some(now),
383 focus_section: None,
384 epic,
385 target_branch,
386 depends_on,
387 agent: None,
388 agent_overrides: std::collections::HashMap::new(),
389 };
390 let when = now.format("%Y-%m-%dT%H:%MZ");
391 let by = if actor != author { format!("{actor}|{author}") } else { actor.clone() };
392 let history_footer = format!("## History\n\n| When | From | To | By |\n|------|------|----|----|\n| {when} | — | new | {by} |\n");
393 let body_template = {
394 let mut s = String::from("## Spec\n\n");
395 for sec in &config.ticket.sections {
396 let placeholder = sec.placeholder.as_deref().unwrap_or("");
397 s.push_str(&format!("### {}\n\n{}\n\n", sec.name, placeholder));
398 }
399 s.push_str(&history_footer);
400 s
401 };
402 let body = if let Some(ctx) = &context {
403 let transition_section = config.workflow.states.iter()
404 .find(|s| s.id == "new")
405 .and_then(|s| s.transitions.iter().find(|tr| tr.to == "in_design"))
406 .and_then(|tr| tr.context_section.clone());
407 let section = context_section
408 .clone()
409 .or(transition_section)
410 .unwrap_or_else(|| "Problem".to_string());
411 if !config.ticket.sections.is_empty()
412 && !config.has_section(§ion)
413 {
414 anyhow::bail!("section '### {section}' not found in ticket body template");
415 }
416 let mut doc = TicketDocument::parse(&body_template)?;
417 crate::spec::set_section(&mut doc, §ion, ctx.clone());
418 doc.serialize()
419 } else {
420 body_template
421 };
422 let path = tickets_dir.join(&filename);
423 let mut t = Ticket { frontmatter: fm, body, path, local_stale: false, local_diverged: false };
424
425 if !section_sets.is_empty() {
426 let mut doc = t.document()?;
427 for (name, value) in §ion_sets {
428 let trimmed = value.trim().to_string();
429 let formatted = if !config.ticket.sections.is_empty() {
430 let section_config = config.find_section(name)
431 .ok_or_else(|| anyhow::anyhow!("unknown section {:?}", name))?;
432 crate::spec::apply_section_type(§ion_config.type_, trimmed)
433 } else {
434 trimmed
435 };
436 crate::spec::set_section(&mut doc, name, formatted);
437 }
438 t.body = doc.serialize();
439 }
440
441 let content = t.serialize()?;
442
443 if let Some(base) = base_branch {
444 let sha = crate::git::resolve_branch_sha(root, &base)?;
445 crate::git::create_branch_at(root, &branch, &sha)?;
446 }
447
448 crate::git::commit_to_branch(
449 root,
450 &branch,
451 &rel_path,
452 &content,
453 &format!("ticket({id}): create {title}"),
454 )?;
455
456 if aggressive {
457 if let Err(e) = crate::git::push_branch_tracking(root, &branch) {
458 warnings.push(format!("warning: push failed: {e:#}"));
459 }
460 }
461
462 Ok(t)
463}
464
465#[allow(clippy::too_many_arguments)]
466pub fn list_filtered<'a>(
467 tickets: &'a [Ticket],
468 config: &crate::config::Config,
469 state_filter: Option<&str>,
470 unassigned: bool,
471 all: bool,
472 actionable_filter: Option<&str>,
473 author_filter: Option<&str>,
474 owner_filter: Option<&str>,
475 mine_user: Option<&str>,
476) -> Vec<&'a Ticket> {
477 let terminal = config.terminal_state_ids();
478
479 tickets.iter().filter(|t| {
480 let fm = &t.frontmatter;
481 let state_ok = state_filter.is_none_or(|s| fm.state == s);
482 let agent_ok = !unassigned || fm.author.as_deref() == Some("unassigned");
483 let state_is_terminal = state_filter.is_some_and(|s| terminal.contains(s));
484 let terminal_ok = all || state_is_terminal || !terminal.contains(fm.state.as_str());
485 let actionable_ok = actionable_filter.is_none_or(|actor| {
486 let state = config.workflow.states.iter().find(|s| s.id == fm.state);
487 match (actor, state) {
488 ("agent", Some(s)) => s.transitions.iter().any(|t| t.trigger == "command:start"),
489 ("supervisor", Some(s)) => !s.terminal
490 && !s.transitions.iter().any(|t| t.trigger == "command:start"),
491 _ => false,
492 }
493 });
494 let author_ok = author_filter.is_none_or(|a| fm.author.as_deref() == Some(a));
495 let owner_ok = owner_filter.is_none_or(|o| fm.owner.as_deref() == Some(o));
496 let mine_ok = mine_user.is_none_or(|me| {
497 fm.author.as_deref() == Some(me) || fm.owner.as_deref() == Some(me)
498 });
499 state_ok && agent_ok && terminal_ok && actionable_ok && author_ok && owner_ok && mine_ok
500 }).collect()
501}
502
503pub fn check_owner(root: &Path, ticket: &Ticket) -> anyhow::Result<()> {
504 let cfg = crate::config::Config::load(root)?;
505 let is_terminal = cfg.workflow.states.iter()
506 .find(|s| s.id == ticket.frontmatter.state)
507 .map(|s| s.terminal)
508 .unwrap_or(false);
509 if is_terminal {
510 anyhow::bail!("cannot change owner of a closed ticket");
511 }
512 let Some(o) = &ticket.frontmatter.owner else {
513 return Ok(());
514 };
515 let identity = crate::config::resolve_identity(root);
516 if identity == "unassigned" {
517 anyhow::bail!(
518 "cannot reassign: identity not configured (set local.user in .apm/local.toml or configure a GitHub token)"
519 );
520 }
521 if &identity != o {
522 anyhow::bail!("only the current owner ({o}) can reassign this ticket");
523 }
524 Ok(())
525}
526
527pub fn set_field(fm: &mut Frontmatter, field: &str, value: &str) -> anyhow::Result<()> {
528 match field {
529 "priority" => fm.priority = value.parse().map_err(|_| anyhow::anyhow!("priority must be 0–255"))?,
530 "effort" => fm.effort = value.parse().map_err(|_| anyhow::anyhow!("effort must be 0–255"))?,
531 "risk" => fm.risk = value.parse().map_err(|_| anyhow::anyhow!("risk must be 0–255"))?,
532 "author" => anyhow::bail!("author is immutable"),
533 "owner" => fm.owner = if value == "-" { None } else { Some(value.to_string()) },
534 "agent" => fm.agent = if value == "-" { None } else { Some(value.to_string()) },
535 "branch" => fm.branch = if value == "-" { None } else { Some(value.to_string()) },
536 "title" => fm.title = value.to_string(),
537 "depends_on" => {
538 if value == "-" {
539 fm.depends_on = None;
540 } else {
541 let ids: Vec<String> = value
542 .split(',')
543 .map(|s| s.trim().to_string())
544 .filter(|s| !s.is_empty())
545 .collect();
546 fm.depends_on = if ids.is_empty() { None } else { Some(ids) };
547 }
548 }
549 other => anyhow::bail!("unknown field: {other}"),
550 }
551 Ok(())
552}
553
554#[derive(serde::Serialize, Clone, Debug)]
555pub struct BlockingDep {
556 pub id: String,
557 pub state: String,
558}
559
560pub fn compute_blocking_deps(
561 ticket: &Ticket,
562 all_tickets: &[Ticket],
563 config: &crate::config::Config,
564) -> Vec<BlockingDep> {
565 let deps = match &ticket.frontmatter.depends_on {
566 Some(d) if !d.is_empty() => d,
567 _ => return vec![],
568 };
569 let state_map: std::collections::HashMap<&str, &str> = all_tickets
570 .iter()
571 .map(|t| (t.frontmatter.id.as_str(), t.frontmatter.state.as_str()))
572 .collect();
573 deps.iter()
574 .filter_map(|dep_id| {
575 state_map.get(dep_id.as_str()).and_then(|&s| {
576 if dep_satisfied(s, None, config) {
577 None
578 } else {
579 Some(BlockingDep { id: dep_id.clone(), state: s.to_string() })
580 }
581 })
582 })
583 .collect()
584}
585
586pub fn move_to_epic(
594 root: &Path,
595 config: &crate::config::Config,
596 ticket_id_arg: &str,
597 target: Option<&str>,
598) -> Result<String> {
599 let tickets = load_all_from_git(root, &config.tickets.dir)?;
601 let id = super::ticket_fmt::resolve_id_in_slice(&tickets, ticket_id_arg)?;
602 let ticket = tickets.iter().find(|t| t.frontmatter.id == id).unwrap();
603
604 let terminal = config.terminal_state_ids();
606 if terminal.contains(&ticket.frontmatter.state) {
607 bail!(
608 "cannot move ticket {}: it is in a terminal state ({})",
609 id,
610 ticket.frontmatter.state
611 );
612 }
613
614 let ticket_branch = ticket
616 .frontmatter
617 .branch
618 .clone()
619 .or_else(|| super::ticket_fmt::branch_name_from_path(&ticket.path))
620 .unwrap_or_else(|| format!("ticket/{id}"));
621
622 let old_base_ref = ticket
624 .frontmatter
625 .target_branch
626 .as_deref()
627 .unwrap_or("main")
628 .to_string();
629
630 let target_is_clear = target.is_none() || target == Some("-");
632
633 let (new_base_ref, new_epic, new_target_branch) = if target_is_clear {
634 if ticket.frontmatter.epic.is_none() {
635 return Ok(format!("ticket {id} is not in any epic; nothing to do"));
636 }
637 ("main".to_string(), None::<String>, None::<String>)
638 } else {
639 let epic_id_arg = target.unwrap();
640 let matches = crate::epic::find_epic_branches(root, epic_id_arg);
641 let epic_branch = match matches.len() {
642 0 => bail!("no epic found matching '{epic_id_arg}'"),
643 1 => matches.into_iter().next().unwrap(),
644 _ => bail!(
645 "ambiguous epic prefix '{}': matches {}",
646 epic_id_arg,
647 matches.join(", ")
648 ),
649 };
650 let epic_id = crate::epic::epic_id_from_branch(&epic_branch).to_string();
651
652 if ticket.frontmatter.epic.as_deref() == Some(&epic_id) {
653 return Ok(format!(
654 "ticket {id} is already in epic {epic_id}; nothing to do"
655 ));
656 }
657
658 (epic_branch.clone(), Some(epic_id), Some(epic_branch))
659 };
660
661 if crate::worktree::find_worktree_for_branch(root, &ticket_branch).is_some() {
663 bail!(
664 "branch {} is checked out in a worktree; close the worktree first",
665 ticket_branch
666 );
667 }
668
669 let old_base_sha = crate::git_util::resolve_branch_sha(root, &old_base_ref)
671 .with_context(|| format!("cannot resolve old base '{old_base_ref}'"))?;
672 let old_upstream_sha =
673 crate::git_util::merge_base(root, &ticket_branch, &old_base_sha)
674 .with_context(|| {
675 format!(
676 "cannot find merge-base of {ticket_branch} and {old_base_ref}"
677 )
678 })?;
679
680 let new_base_sha = crate::git_util::resolve_branch_sha(root, &new_base_ref)
682 .with_context(|| format!("cannot resolve new base '{new_base_ref}'"))?;
683
684 static MOVE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
686 let seq = MOVE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
687 let wt_path = std::env::temp_dir().join(format!(
688 "apm-move-{}-{}-{}",
689 std::process::id(),
690 seq,
691 ticket_branch.replace('/', "-"),
692 ));
693 let wt_path_str = wt_path.to_string_lossy().to_string();
694
695 let branch_sha = crate::git_util::run(root, &["rev-parse", &format!("refs/heads/{ticket_branch}")])?;
696 let branch_sha = branch_sha.trim().to_string();
697
698 crate::git_util::run(root, &["worktree", "add", "--detach", &wt_path_str, &branch_sha])?;
699 let _ = crate::git_util::run(&wt_path, &["checkout", "-B", &ticket_branch]);
700
701 let rebase_result = crate::git_util::run(
702 &wt_path,
703 &["rebase", "--onto", &new_base_sha, &old_upstream_sha],
704 );
705
706 if let Err(e) = rebase_result {
707 let _ = crate::git_util::run(&wt_path, &["rebase", "--abort"]);
708 let _ = crate::git_util::run(root, &["worktree", "remove", "--force", &wt_path_str]);
709 let _ = std::fs::remove_dir_all(&wt_path);
710 let err_str = e.to_string();
711 if err_str.contains("checked out") || err_str.contains("worktree") {
712 bail!(
713 "branch {} is checked out in a worktree; close the worktree first",
714 ticket_branch
715 );
716 }
717 bail!(
718 "rebase onto {new_base_ref} failed (conflicts or other error); \
719 resolve manually or create a new ticket with `apm new --epic`\n{e:#}"
720 );
721 }
722
723 let _ = crate::git_util::run(root, &["worktree", "remove", "--force", &wt_path_str]);
724 let _ = std::fs::remove_dir_all(&wt_path);
725
726 let rel_path = format!(
728 "{}/{}",
729 config.tickets.dir.to_string_lossy(),
730 ticket.path.file_name().unwrap().to_string_lossy()
731 );
732
733 let updated_content =
734 crate::git_util::read_from_branch(root, &ticket_branch, &rel_path)
735 .with_context(|| {
736 format!("cannot read ticket file from {ticket_branch} after rebase")
737 })?;
738 let mut updated_ticket = Ticket::parse(&ticket.path, &updated_content)?;
739
740 let now = chrono::Utc::now();
741 updated_ticket.frontmatter.epic = new_epic.clone();
742 updated_ticket.frontmatter.target_branch = new_target_branch;
743 updated_ticket.frontmatter.updated_at = Some(now);
744
745 let when = now.format("%Y-%m-%dT%H:%MZ").to_string();
746 let history_note = format!("move: {old_base_ref} \u{2192} {new_base_ref}");
747 crate::state::append_history(
748 &mut updated_ticket.body,
749 "\u{2014}",
750 "\u{2014}",
751 &when,
752 &history_note,
753 );
754
755 let content = updated_ticket.serialize()?;
756 crate::git_util::commit_to_branch(
757 root,
758 &ticket_branch,
759 &rel_path,
760 &content,
761 &format!("ticket({id}): move to {new_base_ref}"),
762 )?;
763
764 let msg = if target_is_clear {
765 format!("{id}: moved out of epic, rebased onto main")
766 } else {
767 format!(
768 "{id}: moved into epic {}",
769 new_epic.as_deref().unwrap_or("")
770 )
771 };
772
773 Ok(msg)
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use std::path::Path;
780
781 fn dummy_path() -> &'static Path {
782 Path::new("test.md")
783 }
784
785 fn full_body(ac: &str) -> String {
786 format!(
787 "## Spec\n\n### Problem\n\nSome problem.\n\n### Acceptance criteria\n\n{ac}\n\n### Out of scope\n\nNothing.\n\n### Approach\n\nDo it.\n\n## History\n\n| When | From | To | By |\n|------|------|----|----|"
788 )
789 }
790
791 fn make_simple_ticket(id: &str, state: &str, depends_on: Option<Vec<&str>>) -> Ticket {
794 let deps_line = match &depends_on {
795 None => String::new(),
796 Some(ids) => {
797 let items: Vec<String> = ids.iter().map(|i| format!("\"{}\"", i)).collect();
798 format!("depends_on = [{}]\n", items.join(", "))
799 }
800 };
801 let raw = format!(
802 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"{state}\"\n{deps_line}+++\n\nbody\n"
803 );
804 Ticket::parse(Path::new("test.md"), &raw).unwrap()
805 }
806
807 #[test]
808 fn compute_blocking_deps_no_depends_on_returns_empty() {
809 let config = test_config_with_states(&["closed"]);
810 let ticket = make_simple_ticket("aaaa0001", "new", None);
811 let all = vec![ticket.clone()];
812 let result = compute_blocking_deps(&ticket, &all, &config);
813 assert!(result.is_empty());
814 }
815
816 #[test]
817 fn compute_blocking_deps_dep_in_non_terminal_state_returns_it() {
818 let config = test_config_with_states(&["closed"]);
819 let dep = make_simple_ticket("bbbb0001", "new", None);
820 let ticket = make_simple_ticket("aaaa0001", "new", Some(vec!["bbbb0001"]));
821 let all = vec![dep.clone(), ticket.clone()];
822 let result = compute_blocking_deps(&ticket, &all, &config);
823 assert_eq!(result.len(), 1);
824 assert_eq!(result[0].id, "bbbb0001");
825 assert_eq!(result[0].state, "new");
826 }
827
828 #[test]
829 fn compute_blocking_deps_all_deps_satisfied_returns_empty() {
830 let config = test_config_with_states(&["closed"]);
831 let dep = make_simple_ticket("bbbb0001", "closed", None);
832 let ticket = make_simple_ticket("aaaa0001", "new", Some(vec!["bbbb0001"]));
833 let all = vec![dep.clone(), ticket.clone()];
834 let result = compute_blocking_deps(&ticket, &all, &config);
835 assert!(result.is_empty());
836 }
837
838 #[test]
839 fn document_toggle_criterion() {
840 let body = full_body("- [ ] item one\n- [ ] item two");
841 let mut doc = TicketDocument::parse(&body).unwrap();
842 let ac = doc.sections.get("Acceptance criteria").unwrap();
843 assert!(ac.contains("- [ ] item one"));
844 doc.toggle_criterion(0, true).unwrap();
845 let ac = doc.sections.get("Acceptance criteria").unwrap();
846 assert!(ac.contains("- [x] item one"));
847 }
848
849 #[test]
850 fn document_unchecked_tasks() {
851 let body = full_body("- [ ] one\n- [x] two\n- [ ] three");
852 let doc = TicketDocument::parse(&body).unwrap();
853 assert_eq!(doc.unchecked_tasks("Acceptance criteria"), vec![0, 2]);
854 }
855
856 fn test_config_with_states(terminal_states: &[&str]) -> crate::config::Config {
859 let mut states_toml = String::new();
860 for s in ["new", "ready", "in_progress"] {
861 states_toml.push_str(&format!(
862 "[[workflow.states]]\nid = \"{s}\"\nlabel = \"{s}\"\nterminal = false\n\n"
863 ));
864 }
865 for s in terminal_states {
866 states_toml.push_str(&format!(
867 "[[workflow.states]]\nid = \"{s}\"\nlabel = \"{s}\"\nterminal = true\n\n"
868 ));
869 }
870 let full = format!(
871 "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n{states_toml}"
872 );
873 toml::from_str(&full).unwrap()
874 }
875
876 fn make_ticket(id: &str, state: &str, agent: Option<&str>) -> Ticket {
877 let agent_line = agent.map(|a| format!("agent = \"{a}\"\n")).unwrap_or_default();
878 let raw = format!(
879 "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\n{agent_line}+++\n\n"
880 );
881 Ticket::parse(dummy_path(), &raw).unwrap()
882 }
883
884 #[test]
885 fn list_filtered_by_state() {
886 let config = test_config_with_states(&["closed"]);
887 let tickets = vec![
888 make_ticket("0001", "new", None),
889 make_ticket("0002", "ready", None),
890 make_ticket("0003", "new", None),
891 ];
892 let result = list_filtered(&tickets, &config, Some("new"), false, false, None, None, None, None);
893 assert_eq!(result.len(), 2);
894 assert!(result.iter().all(|t| t.frontmatter.state == "new"));
895 }
896
897 #[test]
898 fn list_filtered_terminal_hidden_by_default() {
899 let config = test_config_with_states(&["closed"]);
900 let tickets = vec![
901 make_ticket("0001", "new", None),
902 make_ticket("0002", "closed", None),
903 ];
904 let result = list_filtered(&tickets, &config, None, false, false, None, None, None, None);
906 assert_eq!(result.len(), 1);
907 assert_eq!(result[0].frontmatter.state, "new");
908
909 let result_all = list_filtered(&tickets, &config, None, false, true, None, None, None, None);
911 assert_eq!(result_all.len(), 2);
912
913 let result_filtered = list_filtered(&tickets, &config, Some("closed"), false, false, None, None, None, None);
915 assert_eq!(result_filtered.len(), 1);
916 assert_eq!(result_filtered[0].frontmatter.state, "closed");
917 }
918
919 #[test]
920 fn list_filtered_unassigned() {
921 let config = test_config_with_states(&[]);
922 let make_with_author = |id: &str, author: Option<&str>| {
923 let author_line = author.map(|a| format!("author = \"{a}\"\n")).unwrap_or_default();
924 let raw = format!(
925 "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"new\"\n{author_line}+++\n\n"
926 );
927 Ticket::parse(Path::new("test.md"), &raw).unwrap()
928 };
929 let tickets = vec![
930 make_with_author("0001", Some("unassigned")),
931 make_with_author("0002", Some("alice")),
932 make_with_author("0003", Some("unassigned")),
933 make_with_author("0004", None),
934 ];
935 let result = list_filtered(&tickets, &config, None, true, false, None, None, None, None);
936 assert_eq!(result.len(), 2);
937 assert!(result.iter().all(|t| t.frontmatter.author.as_deref() == Some("unassigned")));
938 }
939
940 fn make_ticket_with_author(id: &str, state: &str, author: Option<&str>) -> Ticket {
941 let author_line = author.map(|a| format!("author = \"{a}\"\n")).unwrap_or_default();
942 let raw = format!(
943 "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\n{author_line}+++\n\n"
944 );
945 Ticket::parse(dummy_path(), &raw).unwrap()
946 }
947
948 #[test]
949 fn list_filtered_by_author() {
950 let config = test_config_with_states(&[]);
951 let tickets = vec![
952 make_ticket_with_author("0001", "new", Some("alice")),
953 make_ticket_with_author("0002", "new", Some("bob")),
954 make_ticket_with_author("0003", "ready", Some("alice")),
955 ];
956 let result = list_filtered(&tickets, &config, None, false, false, None, Some("alice"), None, None);
957 assert_eq!(result.len(), 2);
958 assert!(result.iter().all(|t| t.frontmatter.author.as_deref() == Some("alice")));
959 }
960
961 #[test]
962 fn list_filtered_author_none() {
963 let config = test_config_with_states(&[]);
964 let tickets = vec![
965 make_ticket_with_author("0001", "new", Some("alice")),
966 make_ticket_with_author("0002", "new", Some("bob")),
967 ];
968 let result = list_filtered(&tickets, &config, None, false, false, None, None, None, None);
969 assert_eq!(result.len(), 2);
970 }
971
972 fn make_ticket_with_owner(id: &str, state: &str, author: Option<&str>, owner: Option<&str>) -> Ticket {
973 let author_line = author.map(|a| format!("author = \"{a}\"\n")).unwrap_or_default();
974 let owner_line = owner.map(|o| format!("owner = \"{o}\"\n")).unwrap_or_default();
975 let raw = format!(
976 "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\n{author_line}{owner_line}+++\n\n"
977 );
978 Ticket::parse(dummy_path(), &raw).unwrap()
979 }
980
981 #[test]
982 fn list_filtered_by_owner() {
983 let config = test_config_with_states(&[]);
984 let tickets = vec![
985 make_ticket_with_owner("0001", "new", Some("alice"), Some("alice")),
986 make_ticket_with_owner("0002", "new", Some("bob"), Some("bob")),
987 make_ticket_with_owner("0003", "new", Some("carol"), None),
988 ];
989 let result = list_filtered(&tickets, &config, None, false, false, None, None, Some("alice"), None);
990 assert_eq!(result.len(), 1);
991 assert_eq!(result[0].frontmatter.id, "0001");
992 }
993
994 #[test]
995 fn list_filtered_mine_matches_author() {
996 let config = test_config_with_states(&[]);
997 let tickets = vec![
998 make_ticket_with_owner("0001", "new", Some("alice"), Some("bob")),
999 make_ticket_with_owner("0002", "new", Some("bob"), Some("carol")),
1000 ];
1001 let result = list_filtered(&tickets, &config, None, false, false, None, None, None, Some("alice"));
1002 assert_eq!(result.len(), 1);
1003 assert_eq!(result[0].frontmatter.id, "0001");
1004 }
1005
1006 #[test]
1007 fn list_filtered_mine_matches_owner() {
1008 let config = test_config_with_states(&[]);
1009 let tickets = vec![
1010 make_ticket_with_owner("0001", "new", Some("bob"), Some("alice")),
1011 make_ticket_with_owner("0002", "new", Some("carol"), Some("bob")),
1012 ];
1013 let result = list_filtered(&tickets, &config, None, false, false, None, None, None, Some("alice"));
1014 assert_eq!(result.len(), 1);
1015 assert_eq!(result[0].frontmatter.id, "0001");
1016 }
1017
1018 #[test]
1019 fn list_filtered_mine_or_semantics() {
1020 let config = test_config_with_states(&[]);
1021 let tickets = vec![
1022 make_ticket_with_owner("0001", "new", Some("alice"), None),
1023 make_ticket_with_owner("0002", "new", Some("bob"), Some("alice")),
1024 make_ticket_with_owner("0003", "new", Some("carol"), Some("carol")),
1025 ];
1026 let result = list_filtered(&tickets, &config, None, false, false, None, None, None, Some("alice"));
1027 assert_eq!(result.len(), 2);
1028 let ids: Vec<&str> = result.iter().map(|t| t.frontmatter.id.as_str()).collect();
1029 assert!(ids.contains(&"0001"));
1030 assert!(ids.contains(&"0002"));
1031 }
1032
1033 fn make_frontmatter() -> Frontmatter {
1036 Frontmatter {
1037 id: "0001".to_string(),
1038 title: "Test".to_string(),
1039 state: "new".to_string(),
1040 priority: 0,
1041 effort: 0,
1042 risk: 0,
1043 author: None,
1044 owner: None,
1045 branch: None,
1046 created_at: None,
1047 updated_at: None,
1048 focus_section: None,
1049 epic: None,
1050 target_branch: None,
1051 depends_on: None,
1052 agent: None,
1053 agent_overrides: std::collections::HashMap::new(),
1054 }
1055 }
1056
1057 #[test]
1058 fn set_field_priority_valid() {
1059 let mut fm = make_frontmatter();
1060 set_field(&mut fm, "priority", "5").unwrap();
1061 assert_eq!(fm.priority, 5);
1062 }
1063
1064 #[test]
1065 fn set_field_priority_overflow() {
1066 let mut fm = make_frontmatter();
1067 let err = set_field(&mut fm, "priority", "256").unwrap_err();
1068 assert!(err.to_string().contains("priority must be 0"));
1069 }
1070
1071 #[test]
1072 fn set_field_author_immutable() {
1073 let mut fm = make_frontmatter();
1074 let err = set_field(&mut fm, "author", "alice").unwrap_err();
1075 assert!(err.to_string().contains("author is immutable"));
1076 }
1077
1078 #[test]
1079 fn set_field_unknown_field() {
1080 let mut fm = make_frontmatter();
1081 let err = set_field(&mut fm, "foo", "bar").unwrap_err();
1082 assert!(err.to_string().contains("unknown field: foo"));
1083 }
1084
1085 #[test]
1086 fn owner_round_trips_through_toml() {
1087 let toml_src = r#"id = "0001"
1088title = "T"
1089state = "new"
1090owner = "alice"
1091"#;
1092 let fm: Frontmatter = toml::from_str(toml_src).unwrap();
1093 assert_eq!(fm.owner, Some("alice".to_string()));
1094 let serialized = toml::to_string(&fm).unwrap();
1095 assert!(serialized.contains("owner = \"alice\""));
1096 }
1097
1098 #[test]
1099 fn owner_absent_deserializes_as_none() {
1100 let toml_src = r#"id = "0001"
1101title = "T"
1102state = "new"
1103"#;
1104 let fm: Frontmatter = toml::from_str(toml_src).unwrap();
1105 assert_eq!(fm.owner, None);
1106 }
1107
1108 #[test]
1109 fn set_field_owner_set() {
1110 let mut fm = make_frontmatter();
1111 set_field(&mut fm, "owner", "alice").unwrap();
1112 assert_eq!(fm.owner, Some("alice".to_string()));
1113 }
1114
1115 #[test]
1116 fn set_field_owner_clear() {
1117 let mut fm = make_frontmatter();
1118 fm.owner = Some("alice".to_string());
1119 set_field(&mut fm, "owner", "-").unwrap();
1120 assert_eq!(fm.owner, None);
1121 }
1122
1123 #[test]
1124 fn set_field_agent_set() {
1125 let mut fm = make_frontmatter();
1126 set_field(&mut fm, "agent", "pi").unwrap();
1127 assert_eq!(fm.agent, Some("pi".to_string()));
1128 }
1129
1130 #[test]
1131 fn set_field_agent_clear() {
1132 let mut fm = make_frontmatter();
1133 fm.agent = Some("pi".to_string());
1134 set_field(&mut fm, "agent", "-").unwrap();
1135 assert_eq!(fm.agent, None);
1136 }
1137
1138 fn config_with_dep_states() -> crate::config::Config {
1141 let toml = r#"
1142[project]
1143name = "test"
1144
1145[tickets]
1146dir = "tickets"
1147
1148[[workflow.states]]
1149id = "ready"
1150label = "Ready"
1151
1152[[workflow.states]]
1153id = "done"
1154label = "Done"
1155satisfies_deps = true
1156
1157[[workflow.states]]
1158id = "closed"
1159label = "Closed"
1160terminal = true
1161
1162[[workflow.states]]
1163id = "blocked"
1164label = "Blocked"
1165"#;
1166 toml::from_str(toml).unwrap()
1167 }
1168
1169 #[test]
1170 fn dep_satisfied_satisfies_deps_true() {
1171 let config = config_with_dep_states();
1172 assert!(dep_satisfied("done", None, &config));
1173 }
1174
1175 #[test]
1176 fn dep_satisfied_terminal_true() {
1177 let config = config_with_dep_states();
1178 assert!(dep_satisfied("closed", None, &config));
1179 }
1180
1181 #[test]
1182 fn dep_satisfied_both_false() {
1183 let config = config_with_dep_states();
1184 assert!(!dep_satisfied("blocked", None, &config));
1185 }
1186
1187 #[test]
1188 fn dep_satisfied_unknown_state() {
1189 let config = config_with_dep_states();
1190 assert!(!dep_satisfied("nonexistent", None, &config));
1191 }
1192
1193 fn config_with_spec_gate() -> crate::config::Config {
1194 let toml = r#"
1195[project]
1196name = "test"
1197
1198[tickets]
1199dir = "tickets"
1200
1201[[workflow.states]]
1202id = "groomed"
1203label = "Groomed"
1204dep_requires = "spec"
1205
1206[[workflow.states]]
1207id = "ready"
1208label = "Ready"
1209
1210[[workflow.states]]
1211id = "specd"
1212label = "Specd"
1213satisfies_deps = "spec"
1214
1215[[workflow.states]]
1216id = "in_progress"
1217label = "In Progress"
1218satisfies_deps = "spec"
1219
1220[[workflow.states]]
1221id = "implemented"
1222label = "Implemented"
1223satisfies_deps = true
1224
1225[[workflow.states]]
1226id = "closed"
1227label = "Closed"
1228terminal = true
1229"#;
1230 toml::from_str(toml).unwrap()
1231 }
1232
1233 #[test]
1234 fn dep_satisfied_tag_matches_required_gate() {
1235 let config = config_with_spec_gate();
1236 assert!(dep_satisfied("specd", Some("spec"), &config));
1237 }
1238
1239 #[test]
1240 fn dep_satisfied_tag_no_required_gate_is_false() {
1241 let config = config_with_spec_gate();
1242 assert!(!dep_satisfied("specd", None, &config));
1243 }
1244
1245 #[test]
1246 fn dep_satisfied_bool_true_with_no_gate() {
1247 let config = config_with_spec_gate();
1248 assert!(dep_satisfied("implemented", None, &config));
1249 }
1250
1251 #[test]
1252 fn pick_next_groomed_unblocked_when_dep_specd() {
1253 let config = config_with_spec_gate();
1254 let tickets = vec![
1255 make_ticket_with_deps("aaaa0001", "groomed", Some(vec!["bbbb0001"])),
1256 make_ticket_with_deps("bbbb0001", "specd", None),
1257 ];
1258 let result = pick_next(&tickets, &["groomed"], &[], 10.0, -2.0, -1.0, &config, None, None);
1259 assert_eq!(result.unwrap().frontmatter.id, "aaaa0001");
1260 }
1261
1262 #[test]
1263 fn pick_next_groomed_unblocked_when_dep_in_progress() {
1264 let config = config_with_spec_gate();
1265 let tickets = vec![
1266 make_ticket_with_deps("aaaa0001", "groomed", Some(vec!["bbbb0001"])),
1267 make_ticket_with_deps("bbbb0001", "in_progress", None),
1268 ];
1269 let result = pick_next(&tickets, &["groomed"], &[], 10.0, -2.0, -1.0, &config, None, None);
1270 assert_eq!(result.unwrap().frontmatter.id, "aaaa0001");
1271 }
1272
1273 #[test]
1274 fn pick_next_ready_blocked_when_dep_only_specd() {
1275 let config = config_with_spec_gate();
1276 let tickets = vec![
1277 make_ticket_with_deps("aaaa0001", "ready", Some(vec!["bbbb0001"])),
1278 make_ticket_with_deps("bbbb0001", "specd", None),
1279 ];
1280 let result = pick_next(&tickets, &["ready"], &[], 10.0, -2.0, -1.0, &config, None, None);
1281 assert!(result.is_none());
1282 }
1283
1284 fn make_ticket_with_deps(id: &str, state: &str, deps: Option<Vec<&str>>) -> Ticket {
1287 let deps_line = match &deps {
1288 None => String::new(),
1289 Some(v) => {
1290 let list: Vec<String> = v.iter().map(|d| format!("\"{d}\"")).collect();
1291 format!("depends_on = [{}]\n", list.join(", "))
1292 }
1293 };
1294 let raw = format!(
1295 "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\n{deps_line}+++\n\n"
1296 );
1297 Ticket::parse(dummy_path(), &raw).unwrap()
1298 }
1299
1300 #[test]
1301 fn pick_next_skips_dep_blocked_ticket() {
1302 let config = config_with_dep_states();
1303 let tickets = vec![
1304 make_ticket_with_deps("aaaa0001", "ready", Some(vec!["bbbb0001"])),
1305 make_ticket_with_deps("bbbb0001", "ready", None),
1306 make_ticket_with_deps("cccc0001", "ready", None),
1307 ];
1308 let result = pick_next(&tickets, &["ready"], &[], 10.0, -2.0, -1.0, &config, None, None);
1311 assert!(result.is_some());
1312 let id = &result.unwrap().frontmatter.id;
1313 assert_ne!(id, "aaaa0001", "dep-blocked ticket should be skipped");
1314 }
1315
1316 #[test]
1317 fn pick_next_returns_ticket_when_dep_satisfied() {
1318 let config = config_with_dep_states();
1319 let tickets = vec![
1320 make_ticket_with_deps("aaaa0001", "ready", Some(vec!["bbbb0001"])),
1321 make_ticket_with_deps("bbbb0001", "done", None),
1322 ];
1323 let result = pick_next(&tickets, &["ready"], &[], 10.0, -2.0, -1.0, &config, None, None);
1324 assert_eq!(result.unwrap().frontmatter.id, "aaaa0001");
1325 }
1326
1327 #[test]
1328 fn pick_next_unknown_dep_id_not_blocking() {
1329 let config = config_with_dep_states();
1330 let tickets = vec![
1331 make_ticket_with_deps("aaaa0001", "ready", Some(vec!["unknown1"])),
1332 ];
1333 let result = pick_next(&tickets, &["ready"], &[], 10.0, -2.0, -1.0, &config, None, None);
1334 assert_eq!(result.unwrap().frontmatter.id, "aaaa0001");
1335 }
1336
1337 #[test]
1338 fn pick_next_empty_depends_on_not_blocking() {
1339 let config = config_with_dep_states();
1340 let raw = "+++\nid = \"aaaa0001\"\ntitle = \"T\"\nstate = \"ready\"\ndepends_on = []\n+++\n\n";
1341 let t = Ticket::parse(dummy_path(), raw).unwrap();
1342 let tickets = vec![t];
1343 let result = pick_next(&tickets, &["ready"], &[], 10.0, -2.0, -1.0, &config, None, None);
1344 assert_eq!(result.unwrap().frontmatter.id, "aaaa0001");
1345 }
1346
1347 fn make_ticket_with_priority(id: &str, state: &str, priority: u8, deps: Option<Vec<&str>>) -> Ticket {
1350 let dep_line = match &deps {
1351 Some(d) => {
1352 let list: Vec<String> = d.iter().map(|s| format!("\"{s}\"")).collect();
1353 format!("depends_on = [{}]\n", list.join(", "))
1354 }
1355 None => String::new(),
1356 };
1357 let raw = format!(
1358 "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\npriority = {priority}\n{dep_line}+++\n\n"
1359 );
1360 Ticket::parse(Path::new("test.md"), &raw).unwrap()
1361 }
1362
1363 #[test]
1364 fn effective_priority_no_dependents_returns_own() {
1365 let a = make_ticket_with_priority("aaaa", "ready", 5, None);
1366 let tickets = vec![&a];
1367 let rev_idx = build_reverse_index(&tickets);
1368 assert_eq!(effective_priority(&a, &rev_idx), 5);
1369 }
1370
1371 #[test]
1372 fn effective_priority_single_hop_elevation() {
1373 let a = make_ticket_with_priority("aaaa", "ready", 2, None);
1375 let b = make_ticket_with_priority("bbbb", "ready", 9, Some(vec!["aaaa"]));
1376 let tickets = vec![&a, &b];
1377 let rev_idx = build_reverse_index(&tickets);
1378 assert_eq!(effective_priority(&a, &rev_idx), 9);
1379 assert_eq!(effective_priority(&b, &rev_idx), 9);
1380 }
1381
1382 #[test]
1383 fn effective_priority_transitive_elevation() {
1384 let a = make_ticket_with_priority("aaaa", "ready", 2, None);
1386 let b = make_ticket_with_priority("bbbb", "ready", 5, Some(vec!["aaaa"]));
1387 let c = make_ticket_with_priority("cccc", "ready", 9, Some(vec!["bbbb"]));
1388 let tickets = vec![&a, &b, &c];
1389 let rev_idx = build_reverse_index(&tickets);
1390 assert_eq!(effective_priority(&a, &rev_idx), 9);
1391 assert_eq!(effective_priority(&b, &rev_idx), 9);
1392 assert_eq!(effective_priority(&c, &rev_idx), 9);
1393 }
1394
1395 #[test]
1396 fn effective_priority_cycle_does_not_panic() {
1397 let a = make_ticket_with_priority("aaaa", "ready", 3, Some(vec!["bbbb"]));
1399 let b = make_ticket_with_priority("bbbb", "ready", 7, Some(vec!["aaaa"]));
1400 let tickets = vec![&a, &b];
1401 let rev_idx = build_reverse_index(&tickets);
1402 let ep_a = effective_priority(&a, &rev_idx);
1404 let ep_b = effective_priority(&b, &rev_idx);
1405 assert_eq!(ep_a, 7);
1406 assert_eq!(ep_b, 7);
1407 }
1408
1409 #[test]
1410 fn effective_priority_closed_dependent_excluded() {
1411 let a = make_ticket_with_priority("aaaa", "ready", 2, None);
1413 let tickets_active = vec![&a];
1415 let rev_idx = build_reverse_index(&tickets_active);
1416 assert_eq!(effective_priority(&a, &rev_idx), 2);
1417 }
1418
1419 #[test]
1420 fn sorted_actionable_low_priority_blocker_elevated() {
1421 let a = make_ticket_with_priority("aaaa", "ready", 2, None);
1424 let b = make_ticket_with_priority("bbbb", "ready", 9, Some(vec!["aaaa"]));
1425 let tickets = vec![a, b];
1426 let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, None);
1427 assert_eq!(result.len(), 2);
1428 let ids: Vec<&str> = result.iter().map(|t| t.frontmatter.id.as_str()).collect();
1429 assert!(ids.contains(&"aaaa"), "A must appear in results");
1430 assert!(ids.contains(&"bbbb"), "B must appear in results");
1431 }
1435
1436 #[test]
1437 fn sorted_actionable_blocker_before_independent_higher_raw() {
1438 let a = make_ticket_with_priority("aaaa", "ready", 2, None);
1442 let b = make_ticket_with_priority("bbbb", "ready", 7, None);
1443 let c = make_ticket_with_priority("cccc", "ready", 9, Some(vec!["aaaa"]));
1444 let tickets = vec![a, b, c];
1445 let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, None);
1446 assert_eq!(result.len(), 3);
1447 let ids: Vec<&str> = result.iter().map(|t| t.frontmatter.id.as_str()).collect();
1448 let a_pos = ids.iter().position(|&id| id == "aaaa").unwrap();
1449 let b_pos = ids.iter().position(|&id| id == "bbbb").unwrap();
1450 assert!(a_pos < b_pos, "A (ep=9) should sort before B (ep=7)");
1451 }
1452
1453 #[test]
1454 fn sorted_actionable_no_deps_unchanged() {
1455 let a = make_ticket_with_priority("aaaa", "ready", 3, None);
1456 let b = make_ticket_with_priority("bbbb", "ready", 7, None);
1457 let tickets = vec![a, b];
1458 let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, None);
1459 assert_eq!(result[0].frontmatter.id, "bbbb");
1460 assert_eq!(result[1].frontmatter.id, "aaaa");
1461 }
1462
1463 fn make_ticket_with_owner_field(id: &str, state: &str, owner: Option<&str>) -> Ticket {
1464 let owner_line = owner.map(|o| format!("owner = \"{o}\"\n")).unwrap_or_default();
1465 let raw = format!(
1466 "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\n{owner_line}+++\n\n"
1467 );
1468 Ticket::parse(Path::new("test.md"), &raw).unwrap()
1469 }
1470
1471 #[test]
1472 fn sorted_actionable_excludes_ticket_owned_by_other() {
1473 let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1474 let tickets = vec![t];
1475 let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, Some("bob"));
1476 assert!(result.is_empty(), "ticket owned by alice should not appear for bob");
1477 }
1478
1479 #[test]
1480 fn sorted_actionable_includes_ticket_owned_by_caller() {
1481 let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1482 let tickets = vec![t];
1483 let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, Some("alice"));
1484 assert_eq!(result.len(), 1);
1485 assert_eq!(result[0].frontmatter.id, "aaaa");
1486 }
1487
1488 #[test]
1489 fn sorted_actionable_includes_unowned_ticket() {
1490 let t = make_ticket_with_owner_field("aaaa", "ready", None);
1491 let tickets = vec![t];
1492 let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, Some("bob"));
1493 assert!(result.is_empty(), "unowned ticket should be excluded when owner_filter is set");
1494 }
1495
1496 #[test]
1497 fn sorted_actionable_no_owner_filter_shows_all() {
1498 let t1 = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1499 let t2 = make_ticket_with_owner_field("bbbb", "ready", Some("bob"));
1500 let tickets = vec![t1, t2];
1501 let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, None);
1502 assert_eq!(result.len(), 2);
1503 }
1504
1505 #[test]
1506 fn pick_next_skips_unowned_ticket_when_owner_filter_set() {
1507 let config = config_with_dep_states();
1508 let t = make_ticket_with_owner_field("aaaa", "ready", None);
1509 let tickets = vec![t];
1510 let result = pick_next(&tickets, &["ready"], &[], 1.0, 0.0, 0.0, &config, None, Some("alice"));
1511 assert!(result.is_none(), "unowned ticket should be skipped when owner_filter is set");
1512 }
1513
1514 #[test]
1515 fn pick_next_skips_ticket_owned_by_other() {
1516 let config = config_with_dep_states();
1517 let t = make_ticket_with_owner_field("aaaa", "ready", Some("bob"));
1518 let tickets = vec![t];
1519 let result = pick_next(&tickets, &["ready"], &[], 1.0, 0.0, 0.0, &config, None, Some("alice"));
1520 assert!(result.is_none(), "ticket owned by bob should be skipped for alice");
1521 }
1522
1523 #[test]
1524 fn pick_next_picks_ticket_owned_by_current_user() {
1525 let config = config_with_dep_states();
1526 let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1527 let tickets = vec![t];
1528 let result = pick_next(&tickets, &["ready"], &[], 1.0, 0.0, 0.0, &config, None, Some("alice"));
1529 assert!(result.is_some(), "ticket owned by alice should be picked");
1530 assert_eq!(result.unwrap().frontmatter.id, "aaaa");
1531 }
1532
1533 #[test]
1534 fn check_owner_passes_when_identity_matches_owner() {
1535 let tmp = tempfile::tempdir().unwrap();
1536 let apm_dir = tmp.path().join(".apm");
1537 std::fs::create_dir_all(&apm_dir).unwrap();
1538 std::fs::write(apm_dir.join("config.toml"), "[project]\nname = \"test\"\n").unwrap();
1539 std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
1540 let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1541 assert!(check_owner(tmp.path(), &t).is_ok());
1542 }
1543
1544 #[test]
1545 fn check_owner_fails_when_identity_does_not_match_owner() {
1546 let tmp = tempfile::tempdir().unwrap();
1547 let apm_dir = tmp.path().join(".apm");
1548 std::fs::create_dir_all(&apm_dir).unwrap();
1549 std::fs::write(apm_dir.join("config.toml"), "[project]\nname = \"test\"\n").unwrap();
1550 std::fs::write(apm_dir.join("local.toml"), "username = \"bob\"\n").unwrap();
1551 let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1552 let err = check_owner(tmp.path(), &t).unwrap_err();
1553 assert!(err.to_string().contains("alice"), "error should mention the owner");
1554 }
1555
1556 #[test]
1557 fn check_owner_fails_when_identity_is_unassigned() {
1558 let tmp = tempfile::tempdir().unwrap();
1559 std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
1560 std::fs::write(tmp.path().join(".apm/config.toml"), "[project]\nname = \"test\"\n").unwrap();
1561 let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1562 let err = check_owner(tmp.path(), &t).unwrap_err();
1563 assert!(err.to_string().contains("identity not configured"));
1564 }
1565
1566 #[test]
1567 fn check_owner_passes_when_ticket_has_no_owner() {
1568 let tmp = tempfile::tempdir().unwrap();
1569 std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
1570 std::fs::write(tmp.path().join(".apm/config.toml"), "[project]\nname = \"test\"\n").unwrap();
1571 let t = make_ticket_with_owner_field("aaaa", "ready", None);
1572 assert!(check_owner(tmp.path(), &t).is_ok());
1573 }
1574
1575 #[test]
1576 fn check_owner_rejects_owner_change_on_terminal_state() {
1577 let tmp = tempfile::tempdir().unwrap();
1578 let cfg_toml = concat!(
1579 "[project]\nname = \"test\"\n\n",
1580 "[[workflow.states]]\nid = \"open\"\nlabel = \"Open\"\nterminal = false\n\n",
1581 "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
1582 );
1583 std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
1584 std::fs::write(tmp.path().join(".apm/config.toml"), cfg_toml).unwrap();
1585 let t = make_ticket_with_owner_field("aaaa", "closed", Some("alice"));
1586 let err = check_owner(tmp.path(), &t).unwrap_err();
1587 assert!(
1588 err.to_string().contains("cannot change owner of a closed ticket"),
1589 "unexpected error: {err}"
1590 );
1591 }
1592
1593 #[test]
1594 fn check_owner_allows_owner_change_on_non_terminal_state() {
1595 let tmp = tempfile::tempdir().unwrap();
1596 let apm_dir = tmp.path().join(".apm");
1597 std::fs::create_dir_all(&apm_dir).unwrap();
1598 let cfg_toml = concat!(
1599 "[project]\nname = \"test\"\n\n",
1600 "[[workflow.states]]\nid = \"open\"\nlabel = \"Open\"\nterminal = false\n\n",
1601 "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
1602 );
1603 std::fs::write(apm_dir.join("config.toml"), cfg_toml).unwrap();
1604 std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
1605 let t = make_ticket_with_owner_field("aaaa", "open", Some("alice"));
1606 assert!(check_owner(tmp.path(), &t).is_ok());
1607 }
1608}