1use anyhow::Result;
2use apm_core::{config::{Config, resolve_identity}, epic, ticket};
3use std::path::Path;
4use crate::ctx::CmdContext;
5
6pub fn run(root: &Path, title: String, no_edit: bool, side_note: bool, context: Option<String>, context_section: Option<String>, no_aggressive: bool, sections: Vec<String>, sets: Vec<String>, epic: Option<String>, depends_on: Vec<String>) -> Result<()> {
7 let config = CmdContext::load_config_only(root)?;
8
9 if context_section.is_some() && context.is_none() {
10 anyhow::bail!("--context-section requires --context");
11 }
12
13 if !sets.is_empty() && sections.is_empty() {
14 anyhow::bail!("--set requires --section");
15 }
16 if sections.len() != sets.len() {
17 anyhow::bail!(
18 "--section and --set must be paired: {} --section flag(s) but {} --set flag(s)",
19 sections.len(),
20 sets.len()
21 );
22 }
23
24 if !config.ticket.sections.is_empty() {
25 for name in §ions {
26 if !config.ticket.sections.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
27 anyhow::bail!("unknown section {:?}; not defined in [ticket.sections]", name);
28 }
29 }
30 }
31
32 let aggressive = config.sync.aggressive && !no_aggressive;
33 if side_note && !config.agents.side_tickets {
34 anyhow::bail!("side tickets are disabled in apm.toml (agents.side_tickets = false)");
35 }
36
37 let author = resolve_identity(root);
38
39 let (epic_id, target_branch, base_branch) = if let Some(ref id) = epic {
40 match epic::find_epic_branch(root, id) {
41 Some(branch) => (Some(id.clone()), Some(branch.clone()), Some(branch)),
42 None => anyhow::bail!("No epic branch found for id '{id}'"),
43 }
44 } else {
45 (None, None, None)
46 };
47
48 let depends_on_parsed: Option<Vec<String>> = if depends_on.is_empty() {
49 None
50 } else {
51 Some(
52 depends_on
53 .iter()
54 .flat_map(|s| s.split(','))
55 .map(|s| s.trim().to_string())
56 .filter(|s| !s.is_empty())
57 .collect(),
58 )
59 };
60
61 let section_sets: Vec<(String, String)> = sections.into_iter().zip(sets).collect();
62 let mut warnings = Vec::new();
63 let t = ticket::create(root, &config, title, author, context, context_section, aggressive, section_sets, epic_id, target_branch, depends_on_parsed, base_branch, &mut warnings)?;
64 for w in &warnings {
65 eprintln!("{w}");
66 }
67 let id = &t.frontmatter.id;
68 let branch = t.frontmatter.branch.as_deref().unwrap_or("");
69 let filename = t.path.file_name().unwrap().to_string_lossy();
70 let rel_path = format!("{}/{}", config.tickets.dir.to_string_lossy(), filename);
71
72 println!("Created ticket {id}: {filename} (branch: {branch})");
73
74 if !no_edit {
75 open_editor(root, &config, branch, &rel_path)?;
76 }
77
78 Ok(())
79}
80
81fn open_editor(root: &Path, _config: &Config, branch: &str, rel_path: &str) -> Result<()> {
82 let prev_branch = std::process::Command::new("git")
84 .args(["rev-parse", "--abbrev-ref", "HEAD"])
85 .current_dir(root)
86 .output()
87 .ok()
88 .and_then(|o| String::from_utf8(o.stdout).ok())
89 .map(|s| s.trim().to_string())
90 .unwrap_or_else(|| "main".to_string());
91
92 let _ = std::process::Command::new("git")
93 .args(["checkout", branch])
94 .current_dir(root)
95 .status();
96
97 let file_path = root.join(rel_path);
98 let _ = crate::editor::open(&file_path);
100
101 let _ = std::process::Command::new("git")
102 .args(["-c", "commit.gpgsign=false", "add", rel_path])
103 .current_dir(root)
104 .status();
105 let _ = std::process::Command::new("git")
106 .args(["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "write spec"])
107 .current_dir(root)
108 .status();
109
110 let _ = std::process::Command::new("git")
111 .args(["checkout", &prev_branch])
112 .current_dir(root)
113 .status();
114
115 Ok(())
116}