1use anyhow::{bail, Result};
2use crate::{config::{CompletionStrategy, Config}, git, review, ticket, ticket_fmt};
3use chrono::Utc;
4use std::path::{Path, PathBuf};
5
6pub struct TransitionOutput {
7 pub id: String,
8 pub old_state: String,
9 pub new_state: String,
10 pub worktree_path: Option<PathBuf>,
11 pub warnings: Vec<String>,
12 pub messages: Vec<String>,
13}
14
15pub fn transition(root: &Path, id_arg: &str, new_state: String, no_aggressive: bool, force: bool) -> Result<TransitionOutput> {
16 let mut warnings: Vec<String> = Vec::new();
17 let mut messages: Vec<String> = Vec::new();
18
19 let config = Config::load(root)?;
20 let valid_states: std::collections::HashSet<&str> = config.workflow.states.iter()
21 .map(|s| s.id.as_str())
22 .collect();
23 if !valid_states.is_empty() && !valid_states.contains(new_state.as_str()) {
24 let list: Vec<&str> = config.workflow.states.iter().map(|s| s.id.as_str()).collect();
25 bail!("unknown state {:?} — valid states: {}", new_state, list.join(", "));
26 }
27 let aggressive = config.sync.aggressive && !no_aggressive;
28
29 let mut tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
30 let id = ticket::resolve_id_in_slice(&tickets, id_arg)?;
31
32 if aggressive {
33 let branches = git::ticket_branches(root).unwrap_or_default();
34 if let Some(b) = branches.iter().find(|b| {
35 b.strip_prefix("ticket/")
36 .and_then(|s| s.split('-').next())
37 .map(|bid| bid == id.as_str())
38 .unwrap_or(false)
39 }) {
40 if let Err(e) = git::fetch_branch(root, b) {
41 warnings.push(format!("warning: fetch failed: {e:#}"));
42 }
43 }
44 }
45
46 let Some(t) = tickets.iter_mut().find(|t| t.frontmatter.id == id) else {
47 bail!("ticket {id:?} not found");
48 };
49 let old_state = t.frontmatter.state.clone();
50
51 let target_is_terminal = config.workflow.states.iter()
52 .find(|s| s.id == new_state)
53 .map(|s| s.terminal)
54 .unwrap_or(false);
55 let completion = if force {
56 CompletionStrategy::None
57 } else if !target_is_terminal {
58 if let Some(state_cfg) = config.workflow.states.iter().find(|s| s.id == old_state) {
59 if !state_cfg.transitions.is_empty() {
60 let tr = state_cfg.transitions.iter().find(|tr| tr.to == new_state);
61 if tr.is_none() {
62 let allowed: Vec<&str> = state_cfg.transitions.iter().map(|tr| tr.to.as_str()).collect();
63 bail!(
64 "no transition from {:?} to {:?} — valid transitions from {:?}: {}",
65 old_state, new_state, old_state,
66 allowed.join(", ")
67 );
68 }
69 let found = tr.unwrap();
70 if let Some(ref w) = found.warning {
71 warnings.push(format!("⚠ {w}"));
72 }
73 found.completion.clone()
74 } else {
75 CompletionStrategy::None
76 }
77 } else {
78 CompletionStrategy::None
79 }
80 } else {
81 CompletionStrategy::None
82 };
83
84 match new_state.as_str() {
85 "specd" => {
86 if let Ok(doc) = t.document() {
87 let errors = doc.validate(&config.ticket.sections);
88 if !errors.is_empty() {
89 let msgs: Vec<String> = errors.iter().map(|e| format!(" - {e}")).collect();
90 bail!("spec validation failed:\n{}", msgs.join("\n"));
91 }
92 if old_state == "ammend" {
93 let unchecked = doc.unchecked_tasks("Amendment requests");
94 if !unchecked.is_empty() {
95 bail!("not all amendment requests are checked — mark them [x] before resubmitting");
96 }
97 }
98 }
99 }
100 "implemented" => {
101 if let Ok(doc) = t.document() {
102 let unchecked = doc.unchecked_tasks("Acceptance criteria");
103 if !unchecked.is_empty() {
104 bail!(
105 "not all acceptance criteria are checked — mark them [x] before transitioning to implemented"
106 );
107 }
108 }
109 }
110 _ => {}
111 }
112
113 let now = Utc::now();
114 let actor = crate::config::resolve_caller_name();
115 t.frontmatter.state = new_state.clone();
116 t.frontmatter.updated_at = Some(now);
117 if new_state == "ammend" {
118 review::ensure_amendment_section(&mut t.body);
119 }
120 append_history(&mut t.body, &old_state, &new_state, &now.format("%Y-%m-%dT%H:%MZ").to_string(), &actor);
121
122 let content = t.serialize()?;
123 let rel_path = format!(
124 "{}/{}",
125 config.tickets.dir.to_string_lossy(),
126 t.path.file_name().unwrap().to_string_lossy()
127 );
128 let branch = t
129 .frontmatter
130 .branch
131 .clone()
132 .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
133 .unwrap_or_else(|| format!("ticket/{id}"));
134
135 git::commit_to_branch(
136 root,
137 &branch,
138 &rel_path,
139 &content,
140 &format!("ticket({id}): {old_state} → {new_state}"),
141 )?;
142 crate::logger::log("state_transition", &format!("{id:?} {old_state} -> {new_state}"));
143
144 match completion {
145 CompletionStrategy::Pr => {
146 git::push_branch_tracking(root, &branch)?;
147 let pr_base = t.frontmatter.target_branch.as_deref()
148 .unwrap_or(&config.project.default_branch);
149 crate::github::gh_pr_create_or_update(root, &branch, pr_base, &id, &t.frontmatter.title, &format!("Closes #{id}"), &mut messages)?;
150 }
151 CompletionStrategy::Merge => {
152 let merge_result = {
153 let merge_target = t.frontmatter.target_branch.as_deref()
154 .unwrap_or(&config.project.default_branch);
155 let is_main = merge_target == config.project.default_branch;
156 if let Err(e) = git::push_branch_tracking(root, &branch) {
157 warnings.push(format!("warning: could not push {branch}: {e}"));
158 }
159 git::merge_into_default(root, &config, &branch, merge_target, is_main, &mut messages, &mut warnings)
160 };
161 if let Err(merge_err) = merge_result {
162 let merge_err_msg = format!("{merge_err:#}");
163 let fail_now = Utc::now();
164 t.frontmatter.state = "merge_failed".to_string();
165 t.frontmatter.updated_at = Some(fail_now);
166 set_merge_notes(&mut t.body, &merge_err_msg);
167 append_history(&mut t.body, &new_state, "merge_failed", &fail_now.format("%Y-%m-%dT%H:%MZ").to_string(), &actor);
168 let fallback_content = match t.serialize() {
169 Ok(c) => c,
170 Err(_) => return Err(merge_err),
171 };
172 if git::commit_to_branch(root, &branch, &rel_path, &fallback_content, &format!("ticket({id}): {new_state} → merge_failed")).is_err() {
173 return Err(merge_err);
174 }
175 crate::logger::log("state_transition", &format!("{id:?} {new_state} -> merge_failed"));
176 return Ok(TransitionOutput {
177 id: id.clone(),
178 old_state: old_state.clone(),
179 new_state: "merge_failed".to_string(),
180 worktree_path: None,
181 warnings,
182 messages,
183 });
184 }
185 }
186 CompletionStrategy::PrOrEpicMerge => {
187 git::push_branch_tracking(root, &branch)?;
188 if let Some(ref target) = t.frontmatter.target_branch {
189 git::merge_into_default(root, &config, &branch, target, false, &mut messages, &mut warnings)?;
190 } else {
191 crate::github::gh_pr_create_or_update(root, &branch, &config.project.default_branch, &id, &t.frontmatter.title, &format!("Closes #{id}"), &mut messages)?;
192 }
193 }
194 CompletionStrategy::Pull => {
195 git::pull_default(root, &config.project.default_branch, &mut warnings)?;
196 }
197 CompletionStrategy::None => {
198 if aggressive {
199 if let Err(e) = git::push_branch_tracking(root, &branch) {
200 warnings.push(format!("warning: push failed: {e:#}"));
201 }
202 }
203 }
204 }
205
206 let worktree_path = if new_state == "in_design" {
207 Some(crate::worktree::provision_worktree(root, &config, &branch, &mut warnings)?)
208 } else {
209 None
210 };
211
212 Ok(TransitionOutput {
213 id,
214 old_state,
215 new_state,
216 worktree_path,
217 warnings,
218 messages,
219 })
220}
221
222
223pub fn available_transitions(config: &crate::config::Config, current_state: &str) -> Vec<(String, String, String)> {
224 let terminal_ids: Vec<&str> = config.workflow.states.iter()
225 .filter(|s| s.terminal)
226 .map(|s| s.id.as_str())
227 .collect();
228
229 let state_cfg = config.workflow.states.iter().find(|s| s.id == current_state);
230
231 if let Some(sc) = state_cfg {
232 if !sc.transitions.is_empty() {
233 return sc.transitions.iter()
234 .filter(|tr| !tr.trigger.starts_with("event:"))
235 .map(|tr| (tr.to.clone(), tr.label.clone(), tr.hint.clone()))
236 .collect();
237 }
238 }
239
240 config.workflow.states.iter()
242 .filter(|s| s.id != current_state && !terminal_ids.contains(&s.id.as_str()))
243 .map(|s| (s.id.clone(), s.label.clone(), String::new()))
244 .collect()
245}
246
247#[derive(serde::Serialize, Clone, Debug)]
248pub struct TransitionOption {
249 pub to: String,
250 pub label: String,
251 #[serde(skip_serializing_if = "Option::is_none")]
252 pub warning: Option<String>,
253}
254
255pub fn compute_valid_transitions(state: &str, config: &crate::config::Config) -> Vec<TransitionOption> {
256 config
257 .workflow
258 .states
259 .iter()
260 .find(|s| s.id == state)
261 .map(|s| {
262 s.transitions
263 .iter()
264 .map(|tr| TransitionOption {
265 to: tr.to.clone(),
266 label: if tr.label.is_empty() {
267 format!("-> {}", tr.to)
268 } else {
269 tr.label.clone()
270 },
271 warning: tr.warning.clone(),
272 })
273 .collect()
274 })
275 .unwrap_or_default()
276}
277
278fn set_merge_notes(body: &mut String, notes: &str) {
279 const SECTION: &str = "### Merge notes";
280
281 if let Some(start) = body.find(SECTION) {
283 let actual_start = if start > 0 && body.as_bytes().get(start - 1) == Some(&b'\n') {
284 start - 1
285 } else {
286 start
287 };
288 let after_header = start + SECTION.len();
289 let end = body[after_header..]
290 .find("\n##")
291 .map(|i| after_header + i)
292 .unwrap_or(body.len());
293 body.replace_range(actual_start..end, "");
294 }
295
296 let block = format!("\n{SECTION}\n\n{notes}\n");
298 if let Some(pos) = body.find("\n## History") {
299 body.insert_str(pos, &block);
300 } else {
301 body.push_str(&block);
302 }
303}
304
305pub fn append_history(body: &mut String, from: &str, to: &str, when: &str, by: &str) {
306 let row = format!("| {when} | {from} | {to} | {by} |");
307 if body.contains("## History") {
308 if !body.ends_with('\n') {
309 body.push('\n');
310 }
311 body.push_str(&row);
312 body.push('\n');
313 } else {
314 body.push_str(&format!(
315 "\n## History\n\n| When | From | To | By |\n|------|------|----|----|\n{row}\n"
316 ));
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 fn config_with_transitions() -> crate::config::Config {
325 let toml = concat!(
326 "[project]\nname = \"test\"\n",
327 "[tickets]\ndir = \"tickets\"\n",
328 "[[workflow.states]]\n",
329 "id = \"new\"\nlabel = \"New\"\n",
330 "[[workflow.states.transitions]]\n",
331 "to = \"ready\"\nlabel = \"Mark ready\"\n",
332 "[[workflow.states.transitions]]\n",
333 "to = \"closed\"\nlabel = \"\"\n",
334 "warning = \"This will close the ticket\"\n",
335 "[[workflow.states]]\n",
336 "id = \"ready\"\nlabel = \"Ready\"\n",
337 "[[workflow.states]]\n",
338 "id = \"closed\"\nlabel = \"Closed\"\nterminal = true\n",
339 );
340 toml::from_str(toml).unwrap()
341 }
342
343 #[test]
344 fn set_merge_notes_inserts_before_history() {
345 let mut body = "## Spec\n\ncontent\n\n## History\n\n| row |".to_string();
346 set_merge_notes(&mut body, "conflict error");
347 assert!(body.contains("### Merge notes\n\nconflict error\n"));
348 let notes_pos = body.find("### Merge notes").unwrap();
349 let hist_pos = body.find("## History").unwrap();
350 assert!(notes_pos < hist_pos);
351 }
352
353 #[test]
354 fn set_merge_notes_appends_when_no_history() {
355 let mut body = "## Spec\n\ncontent".to_string();
356 set_merge_notes(&mut body, "error msg");
357 assert!(body.contains("### Merge notes\n\nerror msg\n"));
358 }
359
360 #[test]
361 fn set_merge_notes_overwrites_existing_section() {
362 let mut body = "## Spec\n\n### Merge notes\n\nold error\n\n## History\n\n| row |".to_string();
363 set_merge_notes(&mut body, "new error");
364 assert!(body.contains("### Merge notes\n\nnew error\n"));
365 assert!(!body.contains("old error"));
366 let notes_pos = body.find("### Merge notes").unwrap();
367 let hist_pos = body.find("## History").unwrap();
368 assert!(notes_pos < hist_pos);
369 }
370
371 #[test]
372 fn compute_valid_transitions_returns_expected_options() {
373 let config = config_with_transitions();
374 let opts = compute_valid_transitions("new", &config);
375 assert_eq!(opts.len(), 2);
376 assert_eq!(opts[0].to, "ready");
377 assert_eq!(opts[0].label, "Mark ready");
378 assert!(opts[0].warning.is_none());
379 assert_eq!(opts[1].to, "closed");
380 assert_eq!(opts[1].label, "-> closed");
381 assert_eq!(opts[1].warning.as_deref(), Some("This will close the ticket"));
382 }
383
384 #[test]
385 fn compute_valid_transitions_unknown_state_returns_empty() {
386 let config = config_with_transitions();
387 let opts = compute_valid_transitions("nonexistent", &config);
388 assert!(opts.is_empty());
389 }
390}