Skip to main content

apm/cmd/
new.rs

1use anyhow::Result;
2use apm_core::{config::{resolve_identity, resolve_caller_name}, epic, ticket};
3use std::path::Path;
4use crate::ctx::CmdContext;
5
6#[allow(clippy::too_many_arguments)]
7// Each argument maps to a distinct CLI flag.
8pub 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<()> {
9    let config = CmdContext::load_config_only(root)?;
10
11    if context_section.is_some() && context.is_none() {
12        anyhow::bail!("--context-section requires --context");
13    }
14
15    if !sets.is_empty() && sections.is_empty() {
16        anyhow::bail!("--set requires --section");
17    }
18    if sections.len() != sets.len() {
19        anyhow::bail!(
20            "--section and --set must be paired: {} --section flag(s) but {} --set flag(s)",
21            sections.len(),
22            sets.len()
23        );
24    }
25
26    if !config.ticket.sections.is_empty() {
27        for name in &sections {
28            if !config.ticket.sections.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
29                anyhow::bail!("unknown section {:?}; not defined in [ticket.sections]", name);
30            }
31        }
32    }
33
34    let aggressive = config.sync.aggressive && !no_aggressive;
35    if side_note && !config.agents.side_tickets {
36        anyhow::bail!("side tickets are disabled in .apm/config.toml (agents.side_tickets = false)");
37    }
38
39    let author = resolve_identity(root);
40    let actor = resolve_caller_name();
41
42    let (epic_id, target_branch, base_branch) = if let Some(ref id) = epic {
43        match epic::find_epic_branch(root, id) {
44            Some(branch) => (Some(id.clone()), Some(branch.clone()), Some(branch)),
45            None => anyhow::bail!("No epic branch found for id '{id}'"),
46        }
47    } else {
48        (None, None, None)
49    };
50
51    let depends_on_parsed: Option<Vec<String>> = if depends_on.is_empty() {
52        None
53    } else {
54        Some(
55            depends_on
56                .iter()
57                .flat_map(|s| s.split(','))
58                .map(|s| s.trim().to_string())
59                .filter(|s| !s.is_empty())
60                .collect(),
61        )
62    };
63
64    if let Some(ref dep_ids) = depends_on_parsed {
65        if !dep_ids.is_empty() {
66            let all_tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
67            let strategy = apm_core::validate::active_completion_strategy(&config);
68            apm_core::validate::check_depends_on_rules(
69                &strategy,
70                epic_id.as_deref(),
71                target_branch.as_deref(),
72                dep_ids,
73                &all_tickets,
74                &config.project.default_branch,
75            )?;
76        }
77    }
78
79    let section_sets: Vec<(String, String)> = sections.into_iter().zip(sets).collect();
80    let mut warnings = Vec::new();
81    let t = ticket::create(root, &config, title, author, actor, context, context_section, aggressive, section_sets, epic_id, target_branch, depends_on_parsed, base_branch, &mut warnings)?;
82    for w in &warnings {
83        eprintln!("{w}");
84    }
85    let id = &t.frontmatter.id;
86    let branch = t.frontmatter.branch.as_deref().unwrap_or("");
87    let filename = t.path.file_name().unwrap().to_string_lossy();
88    let rel_path = format!("{}/{}", config.tickets.dir.to_string_lossy(), filename);
89
90    println!("Created ticket {id}: {filename} (branch: {branch})");
91
92    if !no_edit {
93        open_editor(root, branch, &rel_path)?;
94    }
95
96    Ok(())
97}
98
99fn open_editor(root: &Path, branch: &str, rel_path: &str) -> Result<()> {
100    let content = apm_core::git_util::read_from_branch(root, branch, rel_path)?;
101
102    let fname = std::path::Path::new(rel_path)
103        .file_name().unwrap().to_string_lossy().into_owned();
104    let tmp_path = std::env::temp_dir()
105        .join(format!("apm-{}-{}", std::process::id(), fname));
106    std::fs::write(&tmp_path, &content)?;
107
108    crate::editor::open(&tmp_path)?;
109
110    let new_content = std::fs::read_to_string(&tmp_path)?;
111
112    apm_core::git_util::commit_to_branch(root, branch, rel_path, &new_content, "write spec")?;
113
114    let _ = std::fs::remove_file(&tmp_path);
115
116    Ok(())
117}