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