Skip to main content

apm/cmd/
new.rs

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 &sections {
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    if let Some(ref dep_ids) = depends_on_parsed {
62        if !dep_ids.is_empty() {
63            let all_tickets = apm_core::ticket::load_all_from_git(root, &config.tickets.dir)?;
64            let strategy = apm_core::validate::active_completion_strategy(&config);
65            apm_core::validate::check_depends_on_rules(
66                &strategy,
67                epic_id.as_deref(),
68                target_branch.as_deref(),
69                dep_ids,
70                &all_tickets,
71                &config.project.default_branch,
72            )?;
73        }
74    }
75
76    let section_sets: Vec<(String, String)> = sections.into_iter().zip(sets).collect();
77    let mut warnings = Vec::new();
78    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)?;
79    for w in &warnings {
80        eprintln!("{w}");
81    }
82    let id = &t.frontmatter.id;
83    let branch = t.frontmatter.branch.as_deref().unwrap_or("");
84    let filename = t.path.file_name().unwrap().to_string_lossy();
85    let rel_path = format!("{}/{}", config.tickets.dir.to_string_lossy(), filename);
86
87    println!("Created ticket {id}: {filename} (branch: {branch})");
88
89    if !no_edit {
90        open_editor(root, &config, branch, &rel_path)?;
91    }
92
93    Ok(())
94}
95
96fn open_editor(root: &Path, config: &Config, branch: &str, rel_path: &str) -> Result<()> {
97    // Check out the ticket branch, open editor, commit result, return to previous branch.
98    let prev_branch = std::process::Command::new("git")
99        .args(["rev-parse", "--abbrev-ref", "HEAD"])
100        .current_dir(root)
101        .output()
102        .ok()
103        .and_then(|o| String::from_utf8(o.stdout).ok())
104        .map(|s| s.trim().to_string())
105        .unwrap_or_else(|| config.project.default_branch.clone());
106
107    let _ = std::process::Command::new("git")
108        .args(["checkout", branch])
109        .current_dir(root)
110        .status();
111
112    let file_path = root.join(rel_path);
113    // Commit whatever the user wrote, even if editor exited non-zero.
114    let _ = crate::editor::open(&file_path);
115
116    let _ = std::process::Command::new("git")
117        .args(["-c", "commit.gpgsign=false", "add", rel_path])
118        .current_dir(root)
119        .status();
120    let _ = std::process::Command::new("git")
121        .args(["-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "write spec"])
122        .current_dir(root)
123        .status();
124
125    let _ = std::process::Command::new("git")
126        .args(["checkout", &prev_branch])
127        .current_dir(root)
128        .status();
129
130    Ok(())
131}