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 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}