use anyhow::{Context, Result};
use clap::Parser;
use crate::atlassian::adf::AdfDocument;
use crate::atlassian::client::AtlassianClient;
use crate::atlassian::convert::markdown_to_adf;
use crate::atlassian::document::{JfmDocument, JfmFrontmatter};
use crate::cli::atlassian::format::ContentFormat;
use crate::cli::atlassian::helpers::{create_client, print_create_dry_run, read_input};
#[derive(Parser)]
pub struct CreateCommand {
pub file: Option<String>,
#[arg(long, value_enum, default_value_t = ContentFormat::Jfm)]
pub format: ContentFormat,
#[arg(long)]
pub project: Option<String>,
#[arg(long, value_name = "TYPE")]
pub r#type: Option<String>,
#[arg(long)]
pub summary: Option<String>,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Debug)]
struct CreateParams {
project: String,
issue_type: String,
summary: String,
labels: Vec<String>,
adf: AdfDocument,
}
impl CreateCommand {
pub async fn execute(self) -> Result<()> {
let params = self.resolve_params()?;
if self.dry_run {
return print_create_dry_run(
¶ms.project,
¶ms.issue_type,
¶ms.summary,
¶ms.adf,
¶ms.labels,
);
}
let (client, _instance_url) = create_client()?;
run_create(&client, ¶ms).await
}
fn resolve_params(&self) -> Result<CreateParams> {
match self.format {
ContentFormat::Jfm => self.resolve_from_jfm(),
ContentFormat::Adf => self.resolve_from_adf(),
}
}
fn resolve_from_jfm(&self) -> Result<CreateParams> {
let input = read_input(self.file.as_deref())?;
let doc = JfmDocument::parse(&input)?;
let adf = markdown_to_adf(&doc.body)?;
let (fm_project, fm_issue_type, fm_summary, fm_labels) = match &doc.frontmatter {
JfmFrontmatter::Jira(fm) => {
let project = fm.project.clone().or_else(|| {
if fm.key.is_empty() {
None
} else {
fm.key.split('-').next().map(String::from)
}
});
(
project,
fm.issue_type.clone(),
Some(fm.summary.clone()),
fm.labels.clone(),
)
}
JfmFrontmatter::Confluence(_) => {
anyhow::bail!("Cannot create a JIRA issue from Confluence frontmatter");
}
};
let project = self.project.clone().or(fm_project).ok_or_else(|| {
anyhow::anyhow!("Project key is required (use --project or set in frontmatter)")
})?;
let issue_type = self
.r#type
.clone()
.or(fm_issue_type)
.unwrap_or_else(|| "Task".to_string());
let summary = self.summary.clone().or(fm_summary).ok_or_else(|| {
anyhow::anyhow!("Summary is required (use --summary or set in frontmatter)")
})?;
Ok(CreateParams {
project,
issue_type,
summary,
labels: fm_labels,
adf,
})
}
fn resolve_from_adf(&self) -> Result<CreateParams> {
let input = read_input(self.file.as_deref())?;
let adf: AdfDocument =
serde_json::from_str(&input).context("Failed to parse ADF JSON input")?;
let project = self
.project
.clone()
.ok_or_else(|| anyhow::anyhow!("--project is required when using ADF format"))?;
let issue_type = self.r#type.clone().unwrap_or_else(|| "Task".to_string());
let summary = self
.summary
.clone()
.ok_or_else(|| anyhow::anyhow!("--summary is required when using ADF format"))?;
Ok(CreateParams {
project,
issue_type,
summary,
labels: vec![],
adf,
})
}
}
async fn run_create(client: &AtlassianClient, params: &CreateParams) -> Result<()> {
let result = client
.create_issue(
¶ms.project,
¶ms.issue_type,
¶ms.summary,
Some(¶ms.adf),
¶ms.labels,
)
.await?;
println!("{}", result.key);
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::fs;
#[test]
fn create_command_struct_defaults() {
let cmd = CreateCommand {
file: None,
format: ContentFormat::default(),
project: None,
r#type: None,
summary: None,
dry_run: false,
};
assert!(cmd.file.is_none());
assert!(cmd.project.is_none());
assert!(!cmd.dry_run);
}
#[test]
fn resolve_from_jfm_with_all_frontmatter() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("issue.md");
let content = "---\ntype: jira\ninstance: https://org.atlassian.net\nproject: PROJ\nsummary: My Title\nissue_type: Bug\nlabels:\n - backend\n---\n\nBody text\n";
fs::write(&file_path, content).unwrap();
let cmd = CreateCommand {
file: Some(file_path.to_str().unwrap().to_string()),
format: ContentFormat::Jfm,
project: None,
r#type: None,
summary: None,
dry_run: false,
};
let params = cmd.resolve_params().unwrap();
assert_eq!(params.project, "PROJ");
assert_eq!(params.issue_type, "Bug");
assert_eq!(params.summary, "My Title");
assert_eq!(params.labels, vec!["backend"]);
}
#[test]
fn resolve_from_jfm_project_from_key() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("issue.md");
let content = "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-123\nsummary: Existing issue\n---\n\nBody\n";
fs::write(&file_path, content).unwrap();
let cmd = CreateCommand {
file: Some(file_path.to_str().unwrap().to_string()),
format: ContentFormat::Jfm,
project: None,
r#type: None,
summary: None,
dry_run: false,
};
let params = cmd.resolve_params().unwrap();
assert_eq!(params.project, "PROJ");
}
#[test]
fn cli_flags_override_frontmatter() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("issue.md");
let content = "---\ntype: jira\ninstance: https://org.atlassian.net\nproject: OLD\nsummary: Old Title\nissue_type: Bug\n---\n\nBody\n";
fs::write(&file_path, content).unwrap();
let cmd = CreateCommand {
file: Some(file_path.to_str().unwrap().to_string()),
format: ContentFormat::Jfm,
project: Some("NEW".to_string()),
r#type: Some("Story".to_string()),
summary: Some("New Title".to_string()),
dry_run: false,
};
let params = cmd.resolve_params().unwrap();
assert_eq!(params.project, "NEW");
assert_eq!(params.issue_type, "Story");
assert_eq!(params.summary, "New Title");
}
#[test]
fn resolve_from_jfm_missing_project_errors() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("issue.md");
let content = "---\ntype: jira\ninstance: https://org.atlassian.net\nsummary: No project\n---\n\nBody\n";
fs::write(&file_path, content).unwrap();
let cmd = CreateCommand {
file: Some(file_path.to_str().unwrap().to_string()),
format: ContentFormat::Jfm,
project: None,
r#type: None,
summary: None,
dry_run: false,
};
let err = cmd.resolve_params().unwrap_err();
assert!(err.to_string().contains("Project key is required"));
}
#[test]
fn resolve_from_adf_with_flags() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("body.json");
let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}"#;
fs::write(&file_path, adf_json).unwrap();
let cmd = CreateCommand {
file: Some(file_path.to_str().unwrap().to_string()),
format: ContentFormat::Adf,
project: Some("PROJ".to_string()),
r#type: Some("Bug".to_string()),
summary: Some("Fix it".to_string()),
dry_run: false,
};
let params = cmd.resolve_params().unwrap();
assert_eq!(params.project, "PROJ");
assert_eq!(params.issue_type, "Bug");
assert_eq!(params.summary, "Fix it");
}
#[test]
fn resolve_from_adf_missing_project_errors() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("body.json");
let adf_json = r#"{"version":1,"type":"doc","content":[]}"#;
fs::write(&file_path, adf_json).unwrap();
let cmd = CreateCommand {
file: Some(file_path.to_str().unwrap().to_string()),
format: ContentFormat::Adf,
project: None,
r#type: None,
summary: Some("Title".to_string()),
dry_run: false,
};
let err = cmd.resolve_params().unwrap_err();
assert!(err.to_string().contains("--project is required"));
}
#[test]
fn resolve_from_adf_missing_summary_errors() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("body.json");
let adf_json = r#"{"version":1,"type":"doc","content":[]}"#;
fs::write(&file_path, adf_json).unwrap();
let cmd = CreateCommand {
file: Some(file_path.to_str().unwrap().to_string()),
format: ContentFormat::Adf,
project: Some("PROJ".to_string()),
r#type: None,
summary: None,
dry_run: false,
};
let err = cmd.resolve_params().unwrap_err();
assert!(err.to_string().contains("--summary is required"));
}
#[test]
fn default_issue_type_is_task() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("issue.md");
let content = "---\ntype: jira\ninstance: https://org.atlassian.net\nproject: PROJ\nsummary: Title\n---\n\nBody\n";
fs::write(&file_path, content).unwrap();
let cmd = CreateCommand {
file: Some(file_path.to_str().unwrap().to_string()),
format: ContentFormat::Jfm,
project: None,
r#type: None,
summary: None,
dry_run: false,
};
let params = cmd.resolve_params().unwrap();
assert_eq!(params.issue_type, "Task");
}
#[test]
fn dry_run_jfm_does_not_call_api() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("issue.md");
let content = "---\ntype: jira\ninstance: https://org.atlassian.net\nproject: PROJ\nsummary: Test\n---\n\nBody\n";
fs::write(&file_path, content).unwrap();
let cmd = CreateCommand {
file: Some(file_path.to_str().unwrap().to_string()),
format: ContentFormat::Jfm,
project: None,
r#type: None,
summary: None,
dry_run: true,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(cmd.execute());
assert!(result.is_ok());
}
#[test]
fn dry_run_adf_does_not_call_api() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("body.json");
let adf_json = r#"{"version":1,"type":"doc","content":[]}"#;
fs::write(&file_path, adf_json).unwrap();
let cmd = CreateCommand {
file: Some(file_path.to_str().unwrap().to_string()),
format: ContentFormat::Adf,
project: Some("PROJ".to_string()),
r#type: None,
summary: Some("Test".to_string()),
dry_run: true,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(cmd.execute());
assert!(result.is_ok());
}
#[test]
fn confluence_frontmatter_errors() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("page.md");
let content = "---\ntype: confluence\ninstance: https://org.atlassian.net\npage_id: '12345'\ntitle: Page\nspace_key: ENG\n---\n\nBody\n";
fs::write(&file_path, content).unwrap();
let cmd = CreateCommand {
file: Some(file_path.to_str().unwrap().to_string()),
format: ContentFormat::Jfm,
project: None,
r#type: None,
summary: None,
dry_run: false,
};
let err = cmd.resolve_params().unwrap_err();
assert!(err.to_string().contains("Confluence"));
}
fn mock_client(base_url: &str) -> AtlassianClient {
AtlassianClient::new(base_url, "user@test.com", "token").unwrap()
}
fn sample_params() -> CreateParams {
CreateParams {
project: "PROJ".to_string(),
issue_type: "Task".to_string(),
summary: "Test issue".to_string(),
labels: vec![],
adf: AdfDocument::new(),
}
}
#[tokio::test]
async fn run_create_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue"))
.respond_with(
wiremock::ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "100", "key": "PROJ-100", "self": "https://org.atlassian.net/rest/api/3/issue/100"
})),
)
.mount(&server)
.await;
let client = mock_client(&server.uri());
assert!(run_create(&client, &sample_params()).await.is_ok());
}
#[tokio::test]
async fn run_create_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/rest/api/3/issue"))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("Bad Request"))
.mount(&server)
.await;
let client = mock_client(&server.uri());
let err = run_create(&client, &sample_params()).await.unwrap_err();
assert!(err.to_string().contains("400"));
}
}