Skip to main content

apm/cmd/
review.rs

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, &current_state);
23    let transitions: Vec<TransitionOption> = raw_transitions.into_iter()
24        .map(|(to, label, hint)| TransitionOption { to, label, hint })
25        .collect();
26
27    // Pre-validate --to before opening editor.
28    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    // Split body into editable spec and preserved history.
42    let (spec_body, history_section) = core_review::split_body(&t.body);
43
44    // Write temp file: header + sentinel + spec body.
45    let header = build_header(&id, &t.frontmatter.title, &current_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    // Determine transition.
62    let chosen_state = match to {
63        Some(s) => Some(s),
64        None => prompt_transition(&id, &current_state, &transitions)?,
65    };
66
67    // Normalise plain bullets → checkboxes in the amendment section when transitioning to ammend.
68    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    // Commit the spec edit if the body changed.
89    if changed {
90        // Splice: trimmed new spec + original history section.
91        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    // Apply the state transition (state::run re-reads from git, handles history etc.).
105    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    // Exact match first, then prefix match.
172    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}