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