1use anyhow::{bail, Result};
2use apm_core::{git, review as core_review, ticket, ticket_fmt};
3use chrono::Utc;
4use std::io::{self, BufRead, Write};
5use std::path::Path;
6use crate::ctx::CmdContext;
7
8struct TransitionOption {
9 to: String,
10 label: String,
11 hint: String,
12}
13
14pub fn run(root: &Path, id_arg: &str, to: Option<String>, no_aggressive: bool) -> Result<()> {
15 let ctx = CmdContext::load(root, no_aggressive)?;
16 let id = ticket::resolve_id_in_slice(&ctx.tickets, id_arg)?;
17 let Some(mut t) = ctx.tickets.into_iter().find(|t| t.frontmatter.id == id) else {
18 bail!("ticket {id:?} not found");
19 };
20
21 let current_state = t.frontmatter.state.clone();
22 let raw_transitions = core_review::available_transitions(&ctx.config, ¤t_state);
23 let transitions: Vec<TransitionOption> = raw_transitions.into_iter()
24 .map(|(to, label, hint)| TransitionOption { to, label, hint })
25 .collect();
26
27 if let Some(ref target) = to {
29 let valid = transitions.iter().any(|tr| &tr.to == target)
30 || ctx.config.workflow.states.iter().any(|s| &s.id == target && s.terminal);
31 if !valid {
32 let options: Vec<&str> = transitions.iter().map(|t| t.to.as_str()).collect();
33 bail!(
34 "transition '{target}' is not available from '{current_state}'\n\
35 Valid options: {}",
36 if options.is_empty() { "(none defined)".to_string() } else { options.join(", ") }
37 );
38 }
39 }
40
41 let (spec_body, history_section) = core_review::split_body(&t.body);
43
44 let header = build_header(&id, &t.frontmatter.title, ¤t_state, &transitions, to.as_deref());
46 let tmp_path = {
47 let unique = std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .map(|d| d.subsec_nanos())
50 .unwrap_or(0);
51 std::env::temp_dir().join(format!("apm-review-{id}-{unique}.md"))
52 };
53 std::fs::write(&tmp_path, format!("{header}\n{}\n\n{spec_body}", core_review::SENTINEL))?;
54
55 crate::editor::open(&tmp_path)?;
56
57 let edited_raw = std::fs::read_to_string(&tmp_path)?;
58 let _ = std::fs::remove_file(&tmp_path);
59 let mut new_spec = core_review::extract_spec(&edited_raw);
60
61 let chosen_state = match to {
63 Some(s) => Some(s),
64 None => prompt_transition(&id, ¤t_state, &transitions)?,
65 };
66
67 if chosen_state.as_deref() == Some("ammend") {
69 new_spec = core_review::normalize_amendments(new_spec);
70 }
71
72 let changed = new_spec.trim_end() != spec_body.trim_end();
73
74 if !changed && chosen_state.is_none() {
75 println!("No changes.");
76 return Ok(());
77 }
78
79 let rel_path = format!(
80 "{}/{}",
81 ctx.config.tickets.dir.to_string_lossy(),
82 t.path.file_name().unwrap().to_string_lossy()
83 );
84 let branch = t.frontmatter.branch.clone()
85 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
86 .unwrap_or_else(|| format!("ticket/{id}"));
87
88 if changed {
90 t.body = core_review::apply_review(&new_spec, &history_section);
92 t.frontmatter.updated_at = Some(Utc::now());
93 let content = t.serialize()?;
94 git::commit_to_branch(root, &branch, &rel_path, &content,
95 &format!("ticket({id}): review edit"))?;
96 if ctx.aggressive {
97 if let Err(e) = git::push_branch(root, &branch) {
98 eprintln!("warning: push failed: {e:#}");
99 }
100 }
101 println!("{id}: spec updated");
102 }
103
104 if let Some(target) = chosen_state {
106 super::state::run(root, &id, target, false, false)?;
107 }
108
109 Ok(())
110}
111
112fn build_header(
113 id: &str,
114 title: &str,
115 state: &str,
116 transitions: &[TransitionOption],
117 fixed_to: Option<&str>,
118) -> String {
119 let mut lines = Vec::new();
120 lines.push(format!("# Reviewing ticket {id} · state: {state}"));
121 lines.push(format!("# \"{title}\""));
122 lines.push("#".to_string());
123
124 if let Some(target) = fixed_to {
125 lines.push(format!("# Will transition to: {target}"));
126 } else if !transitions.is_empty() {
127 lines.push("# Transitions (choose after saving):".to_string());
128 for tr in transitions {
129 if tr.label.is_empty() {
130 lines.push(format!("# {}", tr.to));
131 } else {
132 lines.push(format!("# {} — {}", tr.to, tr.label));
133 }
134 if !tr.hint.is_empty() {
135 lines.push(format!("# → {}", tr.hint));
136 }
137 }
138 } else {
139 lines.push("# No transitions defined for this state.".to_string());
140 }
141
142 lines.push("#".to_string());
143 lines.push("# Lines starting with \"# \" are ignored. Do not delete the dashed line below.".to_string());
144 lines.join("\n")
145}
146
147fn prompt_transition(
148 id: &str,
149 current_state: &str,
150 transitions: &[TransitionOption],
151) -> Result<Option<String>> {
152 if transitions.is_empty() {
153 return Ok(None);
154 }
155
156 let options: Vec<&str> = transitions.iter().map(|t| t.to.as_str()).collect();
157 print!(
158 "{id} {current_state} → ? {} / [keep] > ",
159 options.join(" / ")
160 );
161 io::stdout().flush()?;
162
163 let mut line = String::new();
164 io::stdin().lock().read_line(&mut line)?;
165 let input = line.trim().to_lowercase();
166
167 if input.is_empty() || input == "keep" || input == "k" {
168 return Ok(None);
169 }
170
171 if let Some(tr) = transitions.iter().find(|t| t.to.to_lowercase() == input) {
173 return Ok(Some(tr.to.clone()));
174 }
175 let matches: Vec<&TransitionOption> = transitions.iter()
176 .filter(|t| t.to.to_lowercase().starts_with(&input))
177 .collect();
178 match matches.len() {
179 0 => bail!("unknown transition '{input}' — valid: {}", options.join(", ")),
180 1 => Ok(Some(matches[0].to.clone())),
181 _ => bail!("ambiguous: '{}' — be more specific", input),
182 }
183}