Skip to main content

apm_core/ticket/
ticket_util.rs

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
37/// Build a reverse dependency index: for each ticket ID, collect the tickets
38/// that directly depend on it.  Pass only non-terminal, non-satisfies_deps
39/// tickets so that closed work does not inflate effective priority.
40pub 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
52/// Return the effective priority of a ticket: the max of its own priority and
53/// the priority of all direct and transitive dependents reachable via the
54/// reverse index.  Uses a visited set to handle cycles safely.
55pub 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
78/// Return all agent-actionable tickets sorted by descending score.
79pub 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
106/// Returns true if a ticket in `dep_state` satisfies the dependency gate
107/// required by the dependent ticket.  `required_gate` is `Some("tag")` when
108/// the dependent's state has `dep_requires = "tag"`, or `None` for the
109/// default (requires `satisfies_deps = true` or `terminal = true`).
110pub 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/// Return the highest-scoring ticket from `tickets` whose state is in
126/// `actionable` and (if `startable` is non-empty) also in `startable`,
127/// and whose `depends_on` deps are all satisfied.
128#[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
163/// Load all tickets by reading directly from their git branches.
164/// No filesystem cache is involved.
165pub 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        // Skip bare short-ID refs (e.g. ticket/268f5694) created by fetch operations.
171        // A real ticket branch always has a slug after the ID: ticket/<id>-<slug>.
172        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
188/// Read a ticket's state from a specific branch by relative path.
189pub 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
195/// Close a ticket from any state.  Commits the change to the ticket branch,
196/// pushes it (non-fatal if no remote), then merges into the default branch
197/// so that `apm clean` can detect and remove the worktree.
198pub 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    // Search ticket branches first, then fall back to the default branch.
211    // This handles stale "implemented" tickets whose branch was deleted.
212    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    // Deduplicate in case both prefixes matched the same ticket.
218    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(&section)
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, &section, 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 &section_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(&section_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        "branch"   => fm.branch   = if value == "-" { None } else { Some(value.to_string()) },
496        "title"    => fm.title    = value.to_string(),
497        "depends_on" => {
498            if value == "-" {
499                fm.depends_on = None;
500            } else {
501                let ids: Vec<String> = value
502                    .split(',')
503                    .map(|s| s.trim().to_string())
504                    .filter(|s| !s.is_empty())
505                    .collect();
506                fm.depends_on = if ids.is_empty() { None } else { Some(ids) };
507            }
508        }
509        other => anyhow::bail!("unknown field: {other}"),
510    }
511    Ok(())
512}
513
514#[derive(serde::Serialize, Clone, Debug)]
515pub struct BlockingDep {
516    pub id: String,
517    pub state: String,
518}
519
520pub fn compute_blocking_deps(
521    ticket: &Ticket,
522    all_tickets: &[Ticket],
523    config: &crate::config::Config,
524) -> Vec<BlockingDep> {
525    let deps = match &ticket.frontmatter.depends_on {
526        Some(d) if !d.is_empty() => d,
527        _ => return vec![],
528    };
529    let state_map: std::collections::HashMap<&str, &str> = all_tickets
530        .iter()
531        .map(|t| (t.frontmatter.id.as_str(), t.frontmatter.state.as_str()))
532        .collect();
533    deps.iter()
534        .filter_map(|dep_id| {
535            state_map.get(dep_id.as_str()).and_then(|&s| {
536                if dep_satisfied(s, None, config) {
537                    None
538                } else {
539                    Some(BlockingDep { id: dep_id.clone(), state: s.to_string() })
540                }
541            })
542        })
543        .collect()
544}
545
546/// Move a ticket into (or out of) an epic by rebasing its branch onto the new base.
547///
548/// `target` values:
549/// - `Some(epic_id_prefix)` — move into the named epic; resolves by prefix match
550/// - `None` or `Some("-")` — remove from any epic, rebase onto main
551///
552/// Returns an informational message on success (including no-op cases).
553pub fn move_to_epic(
554    root: &Path,
555    config: &crate::config::Config,
556    ticket_id_arg: &str,
557    target: Option<&str>,
558) -> Result<String> {
559    // 1. Load tickets and resolve the ticket by prefix.
560    let tickets = load_all_from_git(root, &config.tickets.dir)?;
561    let id = super::ticket_fmt::resolve_id_in_slice(&tickets, ticket_id_arg)?;
562    let ticket = tickets.iter().find(|t| t.frontmatter.id == id).unwrap();
563
564    // 2. Reject terminal tickets.
565    let terminal = config.terminal_state_ids();
566    if terminal.contains(&ticket.frontmatter.state) {
567        bail!(
568            "cannot move ticket {}: it is in a terminal state ({})",
569            id,
570            ticket.frontmatter.state
571        );
572    }
573
574    // 3. Resolve the ticket's git branch name.
575    let ticket_branch = ticket
576        .frontmatter
577        .branch
578        .clone()
579        .or_else(|| super::ticket_fmt::branch_name_from_path(&ticket.path))
580        .unwrap_or_else(|| format!("ticket/{id}"));
581
582    // 4. Determine the old base ref (where the ticket currently branches from).
583    let old_base_ref = ticket
584        .frontmatter
585        .target_branch
586        .as_deref()
587        .unwrap_or("main")
588        .to_string();
589
590    // 5. Determine new base ref and updated frontmatter fields.
591    let target_is_clear = target.is_none() || target == Some("-");
592
593    let (new_base_ref, new_epic, new_target_branch) = if target_is_clear {
594        if ticket.frontmatter.epic.is_none() {
595            return Ok(format!("ticket {id} is not in any epic; nothing to do"));
596        }
597        ("main".to_string(), None::<String>, None::<String>)
598    } else {
599        let epic_id_arg = target.unwrap();
600        let matches = crate::epic::find_epic_branches(root, epic_id_arg);
601        let epic_branch = match matches.len() {
602            0 => bail!("no epic found matching '{epic_id_arg}'"),
603            1 => matches.into_iter().next().unwrap(),
604            _ => bail!(
605                "ambiguous epic prefix '{}': matches {}",
606                epic_id_arg,
607                matches.join(", ")
608            ),
609        };
610        let epic_id = crate::epic::epic_id_from_branch(&epic_branch).to_string();
611
612        if ticket.frontmatter.epic.as_deref() == Some(&epic_id) {
613            return Ok(format!(
614                "ticket {id} is already in epic {epic_id}; nothing to do"
615            ));
616        }
617
618        (epic_branch.clone(), Some(epic_id), Some(epic_branch))
619    };
620
621    // 6. Reject if branch is checked out in a worktree.
622    if crate::worktree::find_worktree_for_branch(root, &ticket_branch).is_some() {
623        bail!(
624            "branch {} is checked out in a worktree; close the worktree first",
625            ticket_branch
626        );
627    }
628
629    // 7. Find old divergence point: git merge-base <ticket_branch> <old_base_ref>.
630    let old_base_sha = crate::git_util::resolve_branch_sha(root, &old_base_ref)
631        .with_context(|| format!("cannot resolve old base '{old_base_ref}'"))?;
632    let old_upstream_sha =
633        crate::git_util::merge_base(root, &ticket_branch, &old_base_sha)
634            .with_context(|| {
635                format!(
636                    "cannot find merge-base of {ticket_branch} and {old_base_ref}"
637                )
638            })?;
639
640    // 8. Resolve the new base to a SHA.
641    let new_base_sha = crate::git_util::resolve_branch_sha(root, &new_base_ref)
642        .with_context(|| format!("cannot resolve new base '{new_base_ref}'"))?;
643
644    // 9. Rebase: replay (old_upstream..ticket_branch] onto new_base.
645    let rebase_result = crate::git_util::run(
646        root,
647        &[
648            "rebase",
649            "--onto",
650            &new_base_sha,
651            &old_upstream_sha,
652            &ticket_branch,
653        ],
654    );
655
656    if let Err(e) = rebase_result {
657        let _ = crate::git_util::run(root, &["rebase", "--abort"]);
658        let err_str = e.to_string();
659        if err_str.contains("checked out") || err_str.contains("worktree") {
660            bail!(
661                "branch {} is checked out in a worktree; close the worktree first",
662                ticket_branch
663            );
664        }
665        bail!(
666            "rebase onto {new_base_ref} failed (conflicts or other error); \
667             resolve manually or create a new ticket with `apm new --epic`\n{e:#}"
668        );
669    }
670
671    // 10. Read the ticket file from the rebased branch tip, update frontmatter, commit.
672    let rel_path = format!(
673        "{}/{}",
674        config.tickets.dir.to_string_lossy(),
675        ticket.path.file_name().unwrap().to_string_lossy()
676    );
677
678    let updated_content =
679        crate::git_util::read_from_branch(root, &ticket_branch, &rel_path)
680            .with_context(|| {
681                format!("cannot read ticket file from {ticket_branch} after rebase")
682            })?;
683    let mut updated_ticket = Ticket::parse(&ticket.path, &updated_content)?;
684
685    let now = chrono::Utc::now();
686    updated_ticket.frontmatter.epic = new_epic.clone();
687    updated_ticket.frontmatter.target_branch = new_target_branch;
688    updated_ticket.frontmatter.updated_at = Some(now);
689
690    let when = now.format("%Y-%m-%dT%H:%MZ").to_string();
691    let history_note = format!("move: {old_base_ref} \u{2192} {new_base_ref}");
692    crate::state::append_history(
693        &mut updated_ticket.body,
694        "\u{2014}",
695        "\u{2014}",
696        &when,
697        &history_note,
698    );
699
700    let content = updated_ticket.serialize()?;
701    crate::git_util::commit_to_branch(
702        root,
703        &ticket_branch,
704        &rel_path,
705        &content,
706        &format!("ticket({id}): move to {new_base_ref}"),
707    )?;
708
709    let msg = if target_is_clear {
710        format!("{id}: moved out of epic, rebased onto main")
711    } else {
712        format!(
713            "{id}: moved into epic {}",
714            new_epic.as_deref().unwrap_or("")
715        )
716    };
717
718    Ok(msg)
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use std::path::Path;
725
726    fn dummy_path() -> &'static Path {
727        Path::new("test.md")
728    }
729
730    fn full_body(ac: &str) -> String {
731        format!(
732            "## 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|------|------|----|----|"
733        )
734    }
735
736    // ── compute_blocking_deps ─────────────────────────────────────────────
737
738    fn make_simple_ticket(id: &str, state: &str, depends_on: Option<Vec<&str>>) -> Ticket {
739        let deps_line = match &depends_on {
740            None => String::new(),
741            Some(ids) => {
742                let items: Vec<String> = ids.iter().map(|i| format!("\"{}\"", i)).collect();
743                format!("depends_on = [{}]\n", items.join(", "))
744            }
745        };
746        let raw = format!(
747            "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"{state}\"\n{deps_line}+++\n\nbody\n"
748        );
749        Ticket::parse(Path::new("test.md"), &raw).unwrap()
750    }
751
752    #[test]
753    fn compute_blocking_deps_no_depends_on_returns_empty() {
754        let config = test_config_with_states(&["closed"]);
755        let ticket = make_simple_ticket("aaaa0001", "new", None);
756        let all = vec![ticket.clone()];
757        let result = compute_blocking_deps(&ticket, &all, &config);
758        assert!(result.is_empty());
759    }
760
761    #[test]
762    fn compute_blocking_deps_dep_in_non_terminal_state_returns_it() {
763        let config = test_config_with_states(&["closed"]);
764        let dep = make_simple_ticket("bbbb0001", "new", None);
765        let ticket = make_simple_ticket("aaaa0001", "new", Some(vec!["bbbb0001"]));
766        let all = vec![dep.clone(), ticket.clone()];
767        let result = compute_blocking_deps(&ticket, &all, &config);
768        assert_eq!(result.len(), 1);
769        assert_eq!(result[0].id, "bbbb0001");
770        assert_eq!(result[0].state, "new");
771    }
772
773    #[test]
774    fn compute_blocking_deps_all_deps_satisfied_returns_empty() {
775        let config = test_config_with_states(&["closed"]);
776        let dep = make_simple_ticket("bbbb0001", "closed", None);
777        let ticket = make_simple_ticket("aaaa0001", "new", Some(vec!["bbbb0001"]));
778        let all = vec![dep.clone(), ticket.clone()];
779        let result = compute_blocking_deps(&ticket, &all, &config);
780        assert!(result.is_empty());
781    }
782
783    #[test]
784    fn document_toggle_criterion() {
785        let body = full_body("- [ ] item one\n- [ ] item two");
786        let mut doc = TicketDocument::parse(&body).unwrap();
787        let ac = doc.sections.get("Acceptance criteria").unwrap();
788        assert!(ac.contains("- [ ] item one"));
789        doc.toggle_criterion(0, true).unwrap();
790        let ac = doc.sections.get("Acceptance criteria").unwrap();
791        assert!(ac.contains("- [x] item one"));
792    }
793
794    #[test]
795    fn document_unchecked_tasks() {
796        let body = full_body("- [ ] one\n- [x] two\n- [ ] three");
797        let doc = TicketDocument::parse(&body).unwrap();
798        assert_eq!(doc.unchecked_tasks("Acceptance criteria"), vec![0, 2]);
799    }
800
801    // ── list_filtered ─────────────────────────────────────────────────────
802
803    fn test_config_with_states(terminal_states: &[&str]) -> crate::config::Config {
804        let mut states_toml = String::new();
805        for s in ["new", "ready", "in_progress"] {
806            states_toml.push_str(&format!(
807                "[[workflow.states]]\nid = \"{s}\"\nlabel = \"{s}\"\nterminal = false\nactionable = [\"agent\"]\n\n"
808            ));
809        }
810        for s in terminal_states {
811            states_toml.push_str(&format!(
812                "[[workflow.states]]\nid = \"{s}\"\nlabel = \"{s}\"\nterminal = true\n\n"
813            ));
814        }
815        let full = format!(
816            "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n{states_toml}"
817        );
818        toml::from_str(&full).unwrap()
819    }
820
821    fn make_ticket(id: &str, state: &str, agent: Option<&str>) -> Ticket {
822        let agent_line = agent.map(|a| format!("agent = \"{a}\"\n")).unwrap_or_default();
823        let raw = format!(
824            "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\n{agent_line}+++\n\n"
825        );
826        Ticket::parse(dummy_path(), &raw).unwrap()
827    }
828
829    #[test]
830    fn list_filtered_by_state() {
831        let config = test_config_with_states(&["closed"]);
832        let tickets = vec![
833            make_ticket("0001", "new", None),
834            make_ticket("0002", "ready", None),
835            make_ticket("0003", "new", None),
836        ];
837        let result = list_filtered(&tickets, &config, Some("new"), false, false, None, None, None, None);
838        assert_eq!(result.len(), 2);
839        assert!(result.iter().all(|t| t.frontmatter.state == "new"));
840    }
841
842    #[test]
843    fn list_filtered_terminal_hidden_by_default() {
844        let config = test_config_with_states(&["closed"]);
845        let tickets = vec![
846            make_ticket("0001", "new", None),
847            make_ticket("0002", "closed", None),
848        ];
849        // By default, terminal states are hidden.
850        let result = list_filtered(&tickets, &config, None, false, false, None, None, None, None);
851        assert_eq!(result.len(), 1);
852        assert_eq!(result[0].frontmatter.state, "new");
853
854        // With all=true, terminal states are shown.
855        let result_all = list_filtered(&tickets, &config, None, false, true, None, None, None, None);
856        assert_eq!(result_all.len(), 2);
857
858        // With state_filter matching the terminal state, it's shown.
859        let result_filtered = list_filtered(&tickets, &config, Some("closed"), false, false, None, None, None, None);
860        assert_eq!(result_filtered.len(), 1);
861        assert_eq!(result_filtered[0].frontmatter.state, "closed");
862    }
863
864    #[test]
865    fn list_filtered_unassigned() {
866        let config = test_config_with_states(&[]);
867        let make_with_author = |id: &str, author: Option<&str>| {
868            let author_line = author.map(|a| format!("author = \"{a}\"\n")).unwrap_or_default();
869            let raw = format!(
870                "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"new\"\n{author_line}+++\n\n"
871            );
872            Ticket::parse(Path::new("test.md"), &raw).unwrap()
873        };
874        let tickets = vec![
875            make_with_author("0001", Some("unassigned")),
876            make_with_author("0002", Some("alice")),
877            make_with_author("0003", Some("unassigned")),
878            make_with_author("0004", None),
879        ];
880        let result = list_filtered(&tickets, &config, None, true, false, None, None, None, None);
881        assert_eq!(result.len(), 2);
882        assert!(result.iter().all(|t| t.frontmatter.author.as_deref() == Some("unassigned")));
883    }
884
885    fn make_ticket_with_author(id: &str, state: &str, author: Option<&str>) -> Ticket {
886        let author_line = author.map(|a| format!("author = \"{a}\"\n")).unwrap_or_default();
887        let raw = format!(
888            "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\n{author_line}+++\n\n"
889        );
890        Ticket::parse(dummy_path(), &raw).unwrap()
891    }
892
893    #[test]
894    fn list_filtered_by_author() {
895        let config = test_config_with_states(&[]);
896        let tickets = vec![
897            make_ticket_with_author("0001", "new", Some("alice")),
898            make_ticket_with_author("0002", "new", Some("bob")),
899            make_ticket_with_author("0003", "ready", Some("alice")),
900        ];
901        let result = list_filtered(&tickets, &config, None, false, false, None, Some("alice"), None, None);
902        assert_eq!(result.len(), 2);
903        assert!(result.iter().all(|t| t.frontmatter.author.as_deref() == Some("alice")));
904    }
905
906    #[test]
907    fn list_filtered_author_none() {
908        let config = test_config_with_states(&[]);
909        let tickets = vec![
910            make_ticket_with_author("0001", "new", Some("alice")),
911            make_ticket_with_author("0002", "new", Some("bob")),
912        ];
913        let result = list_filtered(&tickets, &config, None, false, false, None, None, None, None);
914        assert_eq!(result.len(), 2);
915    }
916
917    fn make_ticket_with_owner(id: &str, state: &str, author: Option<&str>, owner: Option<&str>) -> Ticket {
918        let author_line = author.map(|a| format!("author = \"{a}\"\n")).unwrap_or_default();
919        let owner_line = owner.map(|o| format!("owner = \"{o}\"\n")).unwrap_or_default();
920        let raw = format!(
921            "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\n{author_line}{owner_line}+++\n\n"
922        );
923        Ticket::parse(dummy_path(), &raw).unwrap()
924    }
925
926    #[test]
927    fn list_filtered_by_owner() {
928        let config = test_config_with_states(&[]);
929        let tickets = vec![
930            make_ticket_with_owner("0001", "new", Some("alice"), Some("alice")),
931            make_ticket_with_owner("0002", "new", Some("bob"), Some("bob")),
932            make_ticket_with_owner("0003", "new", Some("carol"), None),
933        ];
934        let result = list_filtered(&tickets, &config, None, false, false, None, None, Some("alice"), None);
935        assert_eq!(result.len(), 1);
936        assert_eq!(result[0].frontmatter.id, "0001");
937    }
938
939    #[test]
940    fn list_filtered_mine_matches_author() {
941        let config = test_config_with_states(&[]);
942        let tickets = vec![
943            make_ticket_with_owner("0001", "new", Some("alice"), Some("bob")),
944            make_ticket_with_owner("0002", "new", Some("bob"), Some("carol")),
945        ];
946        let result = list_filtered(&tickets, &config, None, false, false, None, None, None, Some("alice"));
947        assert_eq!(result.len(), 1);
948        assert_eq!(result[0].frontmatter.id, "0001");
949    }
950
951    #[test]
952    fn list_filtered_mine_matches_owner() {
953        let config = test_config_with_states(&[]);
954        let tickets = vec![
955            make_ticket_with_owner("0001", "new", Some("bob"), Some("alice")),
956            make_ticket_with_owner("0002", "new", Some("carol"), Some("bob")),
957        ];
958        let result = list_filtered(&tickets, &config, None, false, false, None, None, None, Some("alice"));
959        assert_eq!(result.len(), 1);
960        assert_eq!(result[0].frontmatter.id, "0001");
961    }
962
963    #[test]
964    fn list_filtered_mine_or_semantics() {
965        let config = test_config_with_states(&[]);
966        let tickets = vec![
967            make_ticket_with_owner("0001", "new", Some("alice"), None),
968            make_ticket_with_owner("0002", "new", Some("bob"), Some("alice")),
969            make_ticket_with_owner("0003", "new", Some("carol"), Some("carol")),
970        ];
971        let result = list_filtered(&tickets, &config, None, false, false, None, None, None, Some("alice"));
972        assert_eq!(result.len(), 2);
973        let ids: Vec<&str> = result.iter().map(|t| t.frontmatter.id.as_str()).collect();
974        assert!(ids.contains(&"0001"));
975        assert!(ids.contains(&"0002"));
976    }
977
978    // ── set_field ─────────────────────────────────────────────────────────
979
980    fn make_frontmatter() -> Frontmatter {
981        Frontmatter {
982            id: "0001".to_string(),
983            title: "Test".to_string(),
984            state: "new".to_string(),
985            priority: 0,
986            effort: 0,
987            risk: 0,
988            author: None,
989            owner: None,
990            branch: None,
991            created_at: None,
992            updated_at: None,
993            focus_section: None,
994            epic: None,
995            target_branch: None,
996            depends_on: None,
997            agent: None,
998            agent_overrides: std::collections::HashMap::new(),
999        }
1000    }
1001
1002    #[test]
1003    fn set_field_priority_valid() {
1004        let mut fm = make_frontmatter();
1005        set_field(&mut fm, "priority", "5").unwrap();
1006        assert_eq!(fm.priority, 5);
1007    }
1008
1009    #[test]
1010    fn set_field_priority_overflow() {
1011        let mut fm = make_frontmatter();
1012        let err = set_field(&mut fm, "priority", "256").unwrap_err();
1013        assert!(err.to_string().contains("priority must be 0"));
1014    }
1015
1016    #[test]
1017    fn set_field_author_immutable() {
1018        let mut fm = make_frontmatter();
1019        let err = set_field(&mut fm, "author", "alice").unwrap_err();
1020        assert!(err.to_string().contains("author is immutable"));
1021    }
1022
1023    #[test]
1024    fn set_field_unknown_field() {
1025        let mut fm = make_frontmatter();
1026        let err = set_field(&mut fm, "foo", "bar").unwrap_err();
1027        assert!(err.to_string().contains("unknown field: foo"));
1028    }
1029
1030    #[test]
1031    fn owner_round_trips_through_toml() {
1032        let toml_src = r#"id = "0001"
1033title = "T"
1034state = "new"
1035owner = "alice"
1036"#;
1037        let fm: Frontmatter = toml::from_str(toml_src).unwrap();
1038        assert_eq!(fm.owner, Some("alice".to_string()));
1039        let serialized = toml::to_string(&fm).unwrap();
1040        assert!(serialized.contains("owner = \"alice\""));
1041    }
1042
1043    #[test]
1044    fn owner_absent_deserializes_as_none() {
1045        let toml_src = r#"id = "0001"
1046title = "T"
1047state = "new"
1048"#;
1049        let fm: Frontmatter = toml::from_str(toml_src).unwrap();
1050        assert_eq!(fm.owner, None);
1051    }
1052
1053    #[test]
1054    fn set_field_owner_set() {
1055        let mut fm = make_frontmatter();
1056        set_field(&mut fm, "owner", "alice").unwrap();
1057        assert_eq!(fm.owner, Some("alice".to_string()));
1058    }
1059
1060    #[test]
1061    fn set_field_owner_clear() {
1062        let mut fm = make_frontmatter();
1063        fm.owner = Some("alice".to_string());
1064        set_field(&mut fm, "owner", "-").unwrap();
1065        assert_eq!(fm.owner, None);
1066    }
1067
1068    // ── dep_satisfied ─────────────────────────────────────────────────────
1069
1070    fn config_with_dep_states() -> crate::config::Config {
1071        let toml = r#"
1072[project]
1073name = "test"
1074
1075[tickets]
1076dir = "tickets"
1077
1078[[workflow.states]]
1079id = "ready"
1080label = "Ready"
1081actionable = ["agent"]
1082
1083[[workflow.states]]
1084id = "done"
1085label = "Done"
1086satisfies_deps = true
1087
1088[[workflow.states]]
1089id = "closed"
1090label = "Closed"
1091terminal = true
1092
1093[[workflow.states]]
1094id = "blocked"
1095label = "Blocked"
1096"#;
1097        toml::from_str(toml).unwrap()
1098    }
1099
1100    #[test]
1101    fn dep_satisfied_satisfies_deps_true() {
1102        let config = config_with_dep_states();
1103        assert!(dep_satisfied("done", None, &config));
1104    }
1105
1106    #[test]
1107    fn dep_satisfied_terminal_true() {
1108        let config = config_with_dep_states();
1109        assert!(dep_satisfied("closed", None, &config));
1110    }
1111
1112    #[test]
1113    fn dep_satisfied_both_false() {
1114        let config = config_with_dep_states();
1115        assert!(!dep_satisfied("blocked", None, &config));
1116    }
1117
1118    #[test]
1119    fn dep_satisfied_unknown_state() {
1120        let config = config_with_dep_states();
1121        assert!(!dep_satisfied("nonexistent", None, &config));
1122    }
1123
1124    fn config_with_spec_gate() -> crate::config::Config {
1125        let toml = r#"
1126[project]
1127name = "test"
1128
1129[tickets]
1130dir = "tickets"
1131
1132[[workflow.states]]
1133id = "groomed"
1134label = "Groomed"
1135actionable = ["agent"]
1136dep_requires = "spec"
1137
1138[[workflow.states]]
1139id = "ready"
1140label = "Ready"
1141actionable = ["agent"]
1142
1143[[workflow.states]]
1144id = "specd"
1145label = "Specd"
1146satisfies_deps = "spec"
1147
1148[[workflow.states]]
1149id = "in_progress"
1150label = "In Progress"
1151satisfies_deps = "spec"
1152
1153[[workflow.states]]
1154id = "implemented"
1155label = "Implemented"
1156satisfies_deps = true
1157
1158[[workflow.states]]
1159id = "closed"
1160label = "Closed"
1161terminal = true
1162"#;
1163        toml::from_str(toml).unwrap()
1164    }
1165
1166    #[test]
1167    fn dep_satisfied_tag_matches_required_gate() {
1168        let config = config_with_spec_gate();
1169        assert!(dep_satisfied("specd", Some("spec"), &config));
1170    }
1171
1172    #[test]
1173    fn dep_satisfied_tag_no_required_gate_is_false() {
1174        let config = config_with_spec_gate();
1175        assert!(!dep_satisfied("specd", None, &config));
1176    }
1177
1178    #[test]
1179    fn dep_satisfied_bool_true_with_no_gate() {
1180        let config = config_with_spec_gate();
1181        assert!(dep_satisfied("implemented", None, &config));
1182    }
1183
1184    #[test]
1185    fn pick_next_groomed_unblocked_when_dep_specd() {
1186        let config = config_with_spec_gate();
1187        let tickets = vec![
1188            make_ticket_with_deps("aaaa0001", "groomed", Some(vec!["bbbb0001"])),
1189            make_ticket_with_deps("bbbb0001", "specd", None),
1190        ];
1191        let result = pick_next(&tickets, &["groomed"], &[], 10.0, -2.0, -1.0, &config, None, None);
1192        assert_eq!(result.unwrap().frontmatter.id, "aaaa0001");
1193    }
1194
1195    #[test]
1196    fn pick_next_groomed_unblocked_when_dep_in_progress() {
1197        let config = config_with_spec_gate();
1198        let tickets = vec![
1199            make_ticket_with_deps("aaaa0001", "groomed", Some(vec!["bbbb0001"])),
1200            make_ticket_with_deps("bbbb0001", "in_progress", None),
1201        ];
1202        let result = pick_next(&tickets, &["groomed"], &[], 10.0, -2.0, -1.0, &config, None, None);
1203        assert_eq!(result.unwrap().frontmatter.id, "aaaa0001");
1204    }
1205
1206    #[test]
1207    fn pick_next_ready_blocked_when_dep_only_specd() {
1208        let config = config_with_spec_gate();
1209        let tickets = vec![
1210            make_ticket_with_deps("aaaa0001", "ready", Some(vec!["bbbb0001"])),
1211            make_ticket_with_deps("bbbb0001", "specd", None),
1212        ];
1213        let result = pick_next(&tickets, &["ready"], &[], 10.0, -2.0, -1.0, &config, None, None);
1214        assert!(result.is_none());
1215    }
1216
1217    // ── pick_next dep filtering ────────────────────────────────────────────
1218
1219    fn make_ticket_with_deps(id: &str, state: &str, deps: Option<Vec<&str>>) -> Ticket {
1220        let deps_line = match &deps {
1221            None => String::new(),
1222            Some(v) => {
1223                let list: Vec<String> = v.iter().map(|d| format!("\"{d}\"")).collect();
1224                format!("depends_on = [{}]\n", list.join(", "))
1225            }
1226        };
1227        let raw = format!(
1228            "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\n{deps_line}+++\n\n"
1229        );
1230        Ticket::parse(dummy_path(), &raw).unwrap()
1231    }
1232
1233    #[test]
1234    fn pick_next_skips_dep_blocked_ticket() {
1235        let config = config_with_dep_states();
1236        let tickets = vec![
1237            make_ticket_with_deps("aaaa0001", "ready", Some(vec!["bbbb0001"])),
1238            make_ticket_with_deps("bbbb0001", "ready", None),
1239            make_ticket_with_deps("cccc0001", "ready", None),
1240        ];
1241        // aaaa0001 depends on bbbb0001 which is in "ready" (not satisfies_deps)
1242        // should skip aaaa0001 and return bbbb0001 (next by score, no deps)
1243        let result = pick_next(&tickets, &["ready"], &[], 10.0, -2.0, -1.0, &config, None, None);
1244        assert!(result.is_some());
1245        let id = &result.unwrap().frontmatter.id;
1246        assert_ne!(id, "aaaa0001", "dep-blocked ticket should be skipped");
1247    }
1248
1249    #[test]
1250    fn pick_next_returns_ticket_when_dep_satisfied() {
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", "done", None),
1255        ];
1256        let result = pick_next(&tickets, &["ready"], &[], 10.0, -2.0, -1.0, &config, None, None);
1257        assert_eq!(result.unwrap().frontmatter.id, "aaaa0001");
1258    }
1259
1260    #[test]
1261    fn pick_next_unknown_dep_id_not_blocking() {
1262        let config = config_with_dep_states();
1263        let tickets = vec![
1264            make_ticket_with_deps("aaaa0001", "ready", Some(vec!["unknown1"])),
1265        ];
1266        let result = pick_next(&tickets, &["ready"], &[], 10.0, -2.0, -1.0, &config, None, None);
1267        assert_eq!(result.unwrap().frontmatter.id, "aaaa0001");
1268    }
1269
1270    #[test]
1271    fn pick_next_empty_depends_on_not_blocking() {
1272        let config = config_with_dep_states();
1273        let raw = "+++\nid = \"aaaa0001\"\ntitle = \"T\"\nstate = \"ready\"\ndepends_on = []\n+++\n\n";
1274        let t = Ticket::parse(dummy_path(), raw).unwrap();
1275        let tickets = vec![t];
1276        let result = pick_next(&tickets, &["ready"], &[], 10.0, -2.0, -1.0, &config, None, None);
1277        assert_eq!(result.unwrap().frontmatter.id, "aaaa0001");
1278    }
1279
1280    // --- build_reverse_index / effective_priority / sorted_actionable ---
1281
1282    fn make_ticket_with_priority(id: &str, state: &str, priority: u8, deps: Option<Vec<&str>>) -> Ticket {
1283        let dep_line = match &deps {
1284            Some(d) => {
1285                let list: Vec<String> = d.iter().map(|s| format!("\"{s}\"")).collect();
1286                format!("depends_on = [{}]\n", list.join(", "))
1287            }
1288            None => String::new(),
1289        };
1290        let raw = format!(
1291            "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\npriority = {priority}\n{dep_line}+++\n\n"
1292        );
1293        Ticket::parse(Path::new("test.md"), &raw).unwrap()
1294    }
1295
1296    #[test]
1297    fn effective_priority_no_dependents_returns_own() {
1298        let a = make_ticket_with_priority("aaaa", "ready", 5, None);
1299        let tickets = vec![&a];
1300        let rev_idx = build_reverse_index(&tickets);
1301        assert_eq!(effective_priority(&a, &rev_idx), 5);
1302    }
1303
1304    #[test]
1305    fn effective_priority_single_hop_elevation() {
1306        // A (priority 2) is depended on by B (priority 9)
1307        let a = make_ticket_with_priority("aaaa", "ready", 2, None);
1308        let b = make_ticket_with_priority("bbbb", "ready", 9, Some(vec!["aaaa"]));
1309        let tickets = vec![&a, &b];
1310        let rev_idx = build_reverse_index(&tickets);
1311        assert_eq!(effective_priority(&a, &rev_idx), 9);
1312        assert_eq!(effective_priority(&b, &rev_idx), 9);
1313    }
1314
1315    #[test]
1316    fn effective_priority_transitive_elevation() {
1317        // A (2) blocks B (5) blocks C (9); A's effective priority should be 9
1318        let a = make_ticket_with_priority("aaaa", "ready", 2, None);
1319        let b = make_ticket_with_priority("bbbb", "ready", 5, Some(vec!["aaaa"]));
1320        let c = make_ticket_with_priority("cccc", "ready", 9, Some(vec!["bbbb"]));
1321        let tickets = vec![&a, &b, &c];
1322        let rev_idx = build_reverse_index(&tickets);
1323        assert_eq!(effective_priority(&a, &rev_idx), 9);
1324        assert_eq!(effective_priority(&b, &rev_idx), 9);
1325        assert_eq!(effective_priority(&c, &rev_idx), 9);
1326    }
1327
1328    #[test]
1329    fn effective_priority_cycle_does_not_panic() {
1330        // A depends on B, B depends on A
1331        let a = make_ticket_with_priority("aaaa", "ready", 3, Some(vec!["bbbb"]));
1332        let b = make_ticket_with_priority("bbbb", "ready", 7, Some(vec!["aaaa"]));
1333        let tickets = vec![&a, &b];
1334        let rev_idx = build_reverse_index(&tickets);
1335        // Should not panic; both see each other's priority
1336        let ep_a = effective_priority(&a, &rev_idx);
1337        let ep_b = effective_priority(&b, &rev_idx);
1338        assert_eq!(ep_a, 7);
1339        assert_eq!(ep_b, 7);
1340    }
1341
1342    #[test]
1343    fn effective_priority_closed_dependent_excluded() {
1344        // A (2) is in the active set; B (9, closed) is NOT passed to build_reverse_index
1345        let a = make_ticket_with_priority("aaaa", "ready", 2, None);
1346        // B is "closed" — caller filters it out before building the index
1347        let tickets_active = vec![&a];
1348        let rev_idx = build_reverse_index(&tickets_active);
1349        assert_eq!(effective_priority(&a, &rev_idx), 2);
1350    }
1351
1352    #[test]
1353    fn sorted_actionable_low_priority_blocker_elevated() {
1354        // A (priority 2, ready) is depended on by B (priority 9, ready)
1355        // A's effective priority becomes 9 — it should not sort last
1356        let a = make_ticket_with_priority("aaaa", "ready", 2, None);
1357        let b = make_ticket_with_priority("bbbb", "ready", 9, Some(vec!["aaaa"]));
1358        let tickets = vec![a, b];
1359        let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, None);
1360        assert_eq!(result.len(), 2);
1361        let ids: Vec<&str> = result.iter().map(|t| t.frontmatter.id.as_str()).collect();
1362        assert!(ids.contains(&"aaaa"), "A must appear in results");
1363        assert!(ids.contains(&"bbbb"), "B must appear in results");
1364        // A (ep=9) and B (ep=9) are tied; A must not be sorted below B due to raw priority
1365        // The last entry must not be A simply because raw priority 2 < 9
1366        // Both ep=9 so the sort is stable-ish; just verify A is present
1367    }
1368
1369    #[test]
1370    fn sorted_actionable_blocker_before_independent_higher_raw() {
1371        // A (priority 2, ready, blocks C which has priority 9)
1372        // B (priority 7, ready, no deps)
1373        // A's effective priority = 9, B's = 7 → A should sort before B
1374        let a = make_ticket_with_priority("aaaa", "ready", 2, None);
1375        let b = make_ticket_with_priority("bbbb", "ready", 7, None);
1376        let c = make_ticket_with_priority("cccc", "ready", 9, Some(vec!["aaaa"]));
1377        let tickets = vec![a, b, c];
1378        let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, None);
1379        assert_eq!(result.len(), 3);
1380        let ids: Vec<&str> = result.iter().map(|t| t.frontmatter.id.as_str()).collect();
1381        let a_pos = ids.iter().position(|&id| id == "aaaa").unwrap();
1382        let b_pos = ids.iter().position(|&id| id == "bbbb").unwrap();
1383        assert!(a_pos < b_pos, "A (ep=9) should sort before B (ep=7)");
1384    }
1385
1386    #[test]
1387    fn sorted_actionable_no_deps_unchanged() {
1388        let a = make_ticket_with_priority("aaaa", "ready", 3, None);
1389        let b = make_ticket_with_priority("bbbb", "ready", 7, None);
1390        let tickets = vec![a, b];
1391        let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, None);
1392        assert_eq!(result[0].frontmatter.id, "bbbb");
1393        assert_eq!(result[1].frontmatter.id, "aaaa");
1394    }
1395
1396    fn make_ticket_with_owner_field(id: &str, state: &str, owner: Option<&str>) -> Ticket {
1397        let owner_line = owner.map(|o| format!("owner = \"{o}\"\n")).unwrap_or_default();
1398        let raw = format!(
1399            "+++\nid = \"{id}\"\ntitle = \"T{id}\"\nstate = \"{state}\"\n{owner_line}+++\n\n"
1400        );
1401        Ticket::parse(Path::new("test.md"), &raw).unwrap()
1402    }
1403
1404    #[test]
1405    fn sorted_actionable_excludes_ticket_owned_by_other() {
1406        let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1407        let tickets = vec![t];
1408        let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, Some("bob"));
1409        assert!(result.is_empty(), "ticket owned by alice should not appear for bob");
1410    }
1411
1412    #[test]
1413    fn sorted_actionable_includes_ticket_owned_by_caller() {
1414        let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1415        let tickets = vec![t];
1416        let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, Some("alice"));
1417        assert_eq!(result.len(), 1);
1418        assert_eq!(result[0].frontmatter.id, "aaaa");
1419    }
1420
1421    #[test]
1422    fn sorted_actionable_includes_unowned_ticket() {
1423        let t = make_ticket_with_owner_field("aaaa", "ready", None);
1424        let tickets = vec![t];
1425        let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, Some("bob"));
1426        assert!(result.is_empty(), "unowned ticket should be excluded when owner_filter is set");
1427    }
1428
1429    #[test]
1430    fn sorted_actionable_no_owner_filter_shows_all() {
1431        let t1 = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1432        let t2 = make_ticket_with_owner_field("bbbb", "ready", Some("bob"));
1433        let tickets = vec![t1, t2];
1434        let result = sorted_actionable(&tickets, &["ready"], 1.0, 0.0, 0.0, None, None);
1435        assert_eq!(result.len(), 2);
1436    }
1437
1438    #[test]
1439    fn pick_next_skips_unowned_ticket_when_owner_filter_set() {
1440        let config = config_with_dep_states();
1441        let t = make_ticket_with_owner_field("aaaa", "ready", None);
1442        let tickets = vec![t];
1443        let result = pick_next(&tickets, &["ready"], &[], 1.0, 0.0, 0.0, &config, None, Some("alice"));
1444        assert!(result.is_none(), "unowned ticket should be skipped when owner_filter is set");
1445    }
1446
1447    #[test]
1448    fn pick_next_skips_ticket_owned_by_other() {
1449        let config = config_with_dep_states();
1450        let t = make_ticket_with_owner_field("aaaa", "ready", Some("bob"));
1451        let tickets = vec![t];
1452        let result = pick_next(&tickets, &["ready"], &[], 1.0, 0.0, 0.0, &config, None, Some("alice"));
1453        assert!(result.is_none(), "ticket owned by bob should be skipped for alice");
1454    }
1455
1456    #[test]
1457    fn pick_next_picks_ticket_owned_by_current_user() {
1458        let config = config_with_dep_states();
1459        let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1460        let tickets = vec![t];
1461        let result = pick_next(&tickets, &["ready"], &[], 1.0, 0.0, 0.0, &config, None, Some("alice"));
1462        assert!(result.is_some(), "ticket owned by alice should be picked");
1463        assert_eq!(result.unwrap().frontmatter.id, "aaaa");
1464    }
1465
1466    #[test]
1467    fn check_owner_passes_when_identity_matches_owner() {
1468        let tmp = tempfile::tempdir().unwrap();
1469        let apm_dir = tmp.path().join(".apm");
1470        std::fs::create_dir_all(&apm_dir).unwrap();
1471        std::fs::write(apm_dir.join("config.toml"), "[project]\nname = \"test\"\n").unwrap();
1472        std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
1473        let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1474        assert!(check_owner(tmp.path(), &t).is_ok());
1475    }
1476
1477    #[test]
1478    fn check_owner_fails_when_identity_does_not_match_owner() {
1479        let tmp = tempfile::tempdir().unwrap();
1480        let apm_dir = tmp.path().join(".apm");
1481        std::fs::create_dir_all(&apm_dir).unwrap();
1482        std::fs::write(apm_dir.join("config.toml"), "[project]\nname = \"test\"\n").unwrap();
1483        std::fs::write(apm_dir.join("local.toml"), "username = \"bob\"\n").unwrap();
1484        let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1485        let err = check_owner(tmp.path(), &t).unwrap_err();
1486        assert!(err.to_string().contains("alice"), "error should mention the owner");
1487    }
1488
1489    #[test]
1490    fn check_owner_fails_when_identity_is_unassigned() {
1491        let tmp = tempfile::tempdir().unwrap();
1492        std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
1493        std::fs::write(tmp.path().join(".apm/config.toml"), "[project]\nname = \"test\"\n").unwrap();
1494        let t = make_ticket_with_owner_field("aaaa", "ready", Some("alice"));
1495        let err = check_owner(tmp.path(), &t).unwrap_err();
1496        assert!(err.to_string().contains("identity not configured"));
1497    }
1498
1499    #[test]
1500    fn check_owner_passes_when_ticket_has_no_owner() {
1501        let tmp = tempfile::tempdir().unwrap();
1502        std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
1503        std::fs::write(tmp.path().join(".apm/config.toml"), "[project]\nname = \"test\"\n").unwrap();
1504        let t = make_ticket_with_owner_field("aaaa", "ready", None);
1505        assert!(check_owner(tmp.path(), &t).is_ok());
1506    }
1507
1508    #[test]
1509    fn check_owner_rejects_owner_change_on_terminal_state() {
1510        let tmp = tempfile::tempdir().unwrap();
1511        let cfg_toml = concat!(
1512            "[project]\nname = \"test\"\n\n",
1513            "[[workflow.states]]\nid = \"open\"\nlabel = \"Open\"\nterminal = false\n\n",
1514            "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
1515        );
1516        std::fs::create_dir_all(tmp.path().join(".apm")).unwrap();
1517        std::fs::write(tmp.path().join(".apm/config.toml"), cfg_toml).unwrap();
1518        let t = make_ticket_with_owner_field("aaaa", "closed", Some("alice"));
1519        let err = check_owner(tmp.path(), &t).unwrap_err();
1520        assert!(
1521            err.to_string().contains("cannot change owner of a closed ticket"),
1522            "unexpected error: {err}"
1523        );
1524    }
1525
1526    #[test]
1527    fn check_owner_allows_owner_change_on_non_terminal_state() {
1528        let tmp = tempfile::tempdir().unwrap();
1529        let apm_dir = tmp.path().join(".apm");
1530        std::fs::create_dir_all(&apm_dir).unwrap();
1531        let cfg_toml = concat!(
1532            "[project]\nname = \"test\"\n\n",
1533            "[[workflow.states]]\nid = \"open\"\nlabel = \"Open\"\nterminal = false\n\n",
1534            "[[workflow.states]]\nid = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
1535        );
1536        std::fs::write(apm_dir.join("config.toml"), cfg_toml).unwrap();
1537        std::fs::write(apm_dir.join("local.toml"), "username = \"alice\"\n").unwrap();
1538        let t = make_ticket_with_owner_field("aaaa", "open", Some("alice"));
1539        assert!(check_owner(tmp.path(), &t).is_ok());
1540    }
1541}