Skip to main content

apm/cmd/
show.rs

1use anyhow::{bail, Result};
2use apm_core::{
3    classify_recovery_options, config::{Config, WorkflowConfig}, git, is_merge_failure_state,
4    ticket, ticket_fmt, RecoveryKind, RecoveryOption,
5};
6use std::path::Path;
7
8pub fn run(root: &Path, id_arg: &str, no_aggressive: bool, edit: bool) -> Result<()> {
9    let config = Config::load(root)?;
10
11    let branches = git::ticket_branches(root)?;
12    let branch_result = ticket_fmt::resolve_ticket_branch(&branches, id_arg);
13
14    match branch_result {
15        Ok(branch) => {
16            let aggressive = config.sync.aggressive && !no_aggressive;
17            crate::util::fetch_branch_if_aggressive(root, &branch, aggressive);
18
19            let suffix = branch.trim_start_matches("ticket/");
20            let filename = format!("{suffix}.md");
21            let rel_path = format!("{}/{}", config.tickets.dir.to_string_lossy(), filename);
22            let dummy_path = root.join(&rel_path);
23
24            if aggressive {
25                let (content, class) = git::read_from_branch_with_class(root, &branch, &rel_path)?;
26                match class {
27                    git::BranchClass::Behind => {
28                        eprintln!("note: local ref is behind origin — showing origin content (run `apm sync` to fast-forward)");
29                    }
30                    git::BranchClass::Diverged => {
31                        eprintln!("warning: local ref has diverged from origin — showing local content");
32                    }
33                    _ => {}
34                }
35                let t = ticket::Ticket::parse(&dummy_path, &content)?;
36                show_ticket(&t, &content, root, &branch, &rel_path, edit, &config.workflow)
37            } else {
38                let content = git::read_from_branch(root, &branch, &rel_path)?;
39                let t = ticket::Ticket::parse(&dummy_path, &content)?;
40                show_ticket(&t, &content, root, &branch, &rel_path, edit, &config.workflow)
41            }
42        }
43        Err(_) => {
44            // Fallback: search tickets/ on default branch, then archive_dir if set.
45            let default_branch = &config.project.default_branch;
46            let prefixes = ticket::id_arg_prefixes(id_arg)?;
47
48            if let Some((rel_path, content)) = find_in_dir(
49                root,
50                default_branch,
51                &config.tickets.dir.to_string_lossy(),
52                &prefixes,
53            ) {
54                let dummy_path = root.join(&rel_path);
55                let t = ticket::Ticket::parse(&dummy_path, &content)?;
56                return show_ticket_readonly(&t, &content, edit, &config.workflow);
57            }
58
59            if let Some(archive_dir) = &config.tickets.archive_dir {
60                if let Some((rel_path, content)) = find_in_dir(
61                    root,
62                    default_branch,
63                    &archive_dir.to_string_lossy(),
64                    &prefixes,
65                ) {
66                    let dummy_path = root.join(&rel_path);
67                    let t = ticket::Ticket::parse(&dummy_path, &content)?;
68                    return show_ticket_readonly(&t, &content, edit, &config.workflow);
69                }
70            }
71
72            bail!("no ticket matches '{id_arg}'")
73        }
74    }
75}
76
77fn find_in_dir(
78    root: &Path,
79    branch: &str,
80    dir: &str,
81    prefixes: &[String],
82) -> Option<(String, String)> {
83    let files = git::list_files_on_branch(root, branch, dir).ok()?;
84    for rel_path in files {
85        let filename = rel_path.split('/').last().unwrap_or("");
86        let file_id = filename.split('-').next().unwrap_or("");
87        if prefixes.iter().any(|p| file_id.starts_with(p.as_str())) {
88            if let Ok(content) = git::read_from_branch(root, branch, &rel_path) {
89                return Some((rel_path, content));
90            }
91        }
92    }
93    None
94}
95
96fn show_ticket(
97    t: &ticket::Ticket,
98    content: &str,
99    root: &Path,
100    branch: &str,
101    rel_path: &str,
102    edit: bool,
103    workflow: &WorkflowConfig,
104) -> Result<()> {
105    if !edit {
106        print_ticket(t, workflow);
107        return Ok(());
108    }
109
110    let id = &t.frontmatter.id;
111    let tmp_path = std::env::temp_dir().join(format!("apm-{id}.md"));
112    std::fs::write(&tmp_path, content)?;
113
114    if let Err(e) = crate::editor::open(&tmp_path) {
115        let _ = std::fs::remove_file(&tmp_path);
116        return Err(e);
117    }
118
119    let edited = std::fs::read_to_string(&tmp_path)?;
120    let _ = std::fs::remove_file(&tmp_path);
121
122    if edited != content {
123        git::commit_to_branch(root, branch, rel_path, &edited, &format!("ticket({id}): edit"))?;
124    }
125
126    Ok(())
127}
128
129fn show_ticket_readonly(
130    t: &ticket::Ticket,
131    _content: &str,
132    edit: bool,
133    workflow: &WorkflowConfig,
134) -> Result<()> {
135    if edit {
136        bail!("--edit is not supported for archived tickets (no active branch)");
137    }
138    print_ticket(t, workflow);
139    Ok(())
140}
141
142fn print_ticket(t: &ticket::Ticket, workflow: &WorkflowConfig) {
143    let fm = &t.frontmatter;
144    println!("{} — {}", fm.id, fm.title);
145    println!("state:    {}", fm.state);
146    println!("priority: {}  effort: {}  risk: {}", fm.priority, fm.effort, fm.risk);
147    if let Some(b) = &fm.branch { println!("branch:   {b}"); }
148    if let Some(e) = &fm.epic { println!("epic:         {e}"); }
149    if let Some(tb) = &fm.target_branch { println!("target_branch: {tb}"); }
150    if let Some(deps) = &fm.depends_on {
151        if !deps.is_empty() {
152            println!("depends_on:   {}", deps.join(", "));
153        }
154    }
155    if let Some(o) = &fm.owner {
156        println!("owner:        {o}");
157    }
158    if let Some(a) = &fm.agent {
159        println!("agent:        {a}");
160    }
161    if !fm.agent_overrides.is_empty() {
162        let mut keys: Vec<&String> = fm.agent_overrides.keys().collect();
163        keys.sort();
164        let parts: Vec<String> = keys.iter()
165            .map(|k| format!("{}={}", k, fm.agent_overrides[*k]))
166            .collect();
167        println!("agent_overrides: {}", parts.join(", "));
168    }
169    println!();
170    print!("{}", t.body);
171
172    if is_merge_failure_state(&fm.state, workflow) {
173        let opts = classify_recovery_options(&fm.state, workflow);
174        let mut groups: [Vec<&RecoveryOption>; 4] = [vec![], vec![], vec![], vec![]];
175        for opt in &opts {
176            let idx = match opt.kind {
177                RecoveryKind::RetryMerge     => 0,
178                RecoveryKind::ReturnToWorker => 1,
179                RecoveryKind::Abandon        => 2,
180                RecoveryKind::Other          => 3,
181            };
182            groups[idx].push(opt);
183        }
184        println!("\nRecovery options:");
185        for opt in groups.iter().flatten() {
186            println!("  {}  →  apm state {} {}", opt.label, fm.id, opt.to);
187        }
188        println!("\n  See: docs/merge-failed-recovery.md");
189        println!();
190    }
191}