From f5e31c3a7385156427d8b102ebb5c44cbcdbeafe Mon Sep 17 00:00:00 2001 From: Daan Boerlage Date: Tue, 21 Jan 2025 16:57:23 +0100 Subject: [PATCH] Add a front-matter with potential issue properties to the create command markdown --- Cargo.lock | 25 ++++- Cargo.toml | 3 +- readme.md | 12 ++- src/cli.rs | 2 +- src/cmd/create.rs | 259 ++++++++++++++++++++++++++++++++++++++++------ 5 files changed, 265 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c0c2ec..4b0621c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,7 +240,7 @@ dependencies = [ "rust-ini", "serde", "serde_json", - "toml", + "toml 0.8.19", "winnow", "yaml-rust2", ] @@ -493,6 +493,17 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "gray_matter" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31ee6a6070bad7c953b0c8be9367e9372181fed69f3e026c4eb5160d8b3c0222" +dependencies = [ + "serde", + "serde_json", + "toml 0.5.11", +] + [[package]] name = "h2" version = "0.4.7" @@ -828,12 +839,13 @@ dependencies = [ "clap", "config", "directories", + "gray_matter", "reqwest", "serde", "serde_json", "tempfile", "tokio", - "toml", + "toml 0.8.19", ] [[package]] @@ -1646,6 +1658,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.8.19" diff --git a/Cargo.toml b/Cargo.toml index b5268c8..55a8bd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,4 +13,5 @@ serde_json = "1.0" toml = "0.8" config = "0.15" directories = "6.0" -tempfile = "3.8" \ No newline at end of file +tempfile = "3.8" +gray_matter = { version = "0.2", default-features = false, features = ["toml"] } \ No newline at end of file diff --git a/readme.md b/readme.md index 962adc9..d09efd0 100644 --- a/readme.md +++ b/readme.md @@ -37,7 +37,7 @@ Options: ### Creating a ticket ``` -Usage: jirac create --project [MARKDOWN_FILE] +Usage: jirac create [OPTIONS] [MARKDOWN_FILE] Arguments: [MARKDOWN_FILE] @@ -50,13 +50,19 @@ Options: *Using your favourite editor* ```sh -jirac create --project KEY +jirac create ``` *From a markdown file* ```sh -jirac create --project KEY ticket.md +jirac create ticket.md +``` + +*Specify a project* + +```sh +jirac create --project KEY ``` ## Listing tickets diff --git a/src/cli.rs b/src/cli.rs index aef9bfa..c75bc73 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -12,7 +12,7 @@ pub struct Cli { pub enum Commands { Create { #[arg(long)] - project: String, + project: Option, #[arg(value_name = "MARKDOWN_FILE")] markdown_file: Option, diff --git a/src/cmd/create.rs b/src/cmd/create.rs index 10a50ac..1ec7458 100644 --- a/src/cmd/create.rs +++ b/src/cmd/create.rs @@ -1,30 +1,73 @@ use crate::jira_config::JiraConfig; +use gray_matter::engine::TOML; +use gray_matter::Matter; use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs; use std::io::Write; use std::path::PathBuf; use tempfile::NamedTempFile; -#[derive(Debug, Serialize)] -struct JiraIssueRequest { - fields: JiraIssueFields, +#[derive(Debug, Deserialize, Serialize)] +struct TicketMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + assignee: Option, + #[serde(skip_serializing_if = "Option::is_none")] + parent: Option, + #[serde(flatten)] + extra: HashMap, } #[derive(Debug, Serialize)] +struct JiraIssueRequest { + fields: JiraIssueFields, + update: Option, +} + +#[derive(Debug, Serialize, Deserialize)] struct JiraIssueFields { project: Project, summary: String, description: String, issuetype: IssueType, + #[serde(skip_serializing_if = "Option::is_none")] + assignee: Option, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, } #[derive(Debug, Serialize)] +struct JiraIssueUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + parent: Option>, +} + +#[derive(Debug, Serialize)] +struct ParentUpdate { + add: Parent, +} + +#[derive(Debug, Serialize)] +struct Parent { + key: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Assignee { + name: String, +} + +#[derive(Debug, Serialize, Deserialize)] struct Project { key: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] struct IssueType { name: String, } @@ -53,18 +96,72 @@ struct JiraIssueResponseFields { created: String, } -#[derive(Deserialize)] +#[derive(Debug, Serialize, Deserialize)] struct Status { name: String, } +fn get_ticket_template( + ticket_metadata: &TicketMetadata, +) -> Result> { + let matter = toml::to_string(ticket_metadata)?; + let has_project = ticket_metadata.project.is_some(); + + let template = format!( + r#"--- +# The ticket can contain the following properties: +# - status +# - project (required) +# - assignee +# - parent +# - labels +# - priority + +{} +{} +--- +# Title + +[description] +"#, + if has_project { "" } else { r#"project = """# }, + matter, + ); + + Ok(template) +} + fn get_editor() -> String { std::env::var("EDITOR") .or_else(|_| std::env::var("VISUAL")) .unwrap_or_else(|_| "vi".to_string()) } -fn parse_markdown(content: &str) -> (String, String) { +fn parse_markdown( + content: &str, +) -> Result<(String, String, TicketMetadata), Box> { + let matter = Matter::::new(); + let result = matter.parse_with_struct::(content); + + let (metadata, content) = match result { + Some(x) => (x.data, x.content), + None => { + eprintln!("unexpected markdown content found"); + std::process::exit(1); + } + }; + + // let metadata = result.data + // .and_then(|d| toml::from_str(d).ok()) + // .unwrap_or_else(|| TicketMetadata { + // status: None, + // project: None, + // assignee: None, + // parent: None, + // extra: HashMap::new(), + // }); + // + // let content = result.content; let mut lines = content.lines(); // Get first non-empty line as title @@ -79,13 +176,21 @@ fn parse_markdown(content: &str) -> (String, String) { // Rest is description let description = lines.collect::>().join("\n"); - (title, description) + Ok((title, description, metadata)) } -fn create_temp_markdown() -> Result> { +fn create_temp_markdown(project_key: Option) -> Result> { let mut temp_file = NamedTempFile::new()?; - let template = r#"# "#; + let metadata = TicketMetadata { + status: None, + project: project_key, + assignee: None, + parent: None, + extra: Default::default(), + }; + + let template = get_ticket_template(&metadata)?; temp_file.write_all(template.as_bytes())?; temp_file.flush()?; @@ -105,35 +210,56 @@ fn create_temp_markdown() -> Result> { // temp_file will be automatically deleted when it goes out of scope Ok(content) } - async fn create_jira_issue( _config: &JiraConfig, project_key: &str, title: &str, description: &str, + metadata: &TicketMetadata, ) -> Result> { let _client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - let issue = JiraIssueRequest { - fields: JiraIssueFields { - project: Project { - key: project_key.to_string(), - }, - summary: title.to_string(), - description: description.to_string(), - issuetype: IssueType { - name: "Task".to_string(), - }, + let mut fields = JiraIssueFields { + project: Project { + key: project_key.to_string(), }, + summary: title.to_string(), + description: description.to_string(), + issuetype: IssueType { + name: "Task".to_string(), + }, + assignee: None, + status: None, }; - println!("{:#?}", issue); + // Add assignee if specified + if let Some(assignee) = &metadata.assignee { + fields.assignee = Some(Assignee { + name: assignee.clone(), + }); + } + let mut update = None; + + // Add parent if specified + if let Some(parent_key) = &metadata.parent { + update = Some(JiraIssueUpdate { + parent: Some(vec![ParentUpdate { + add: Parent { + key: parent_key.clone(), + }, + }]), + }); + } + + let issue = JiraIssueRequest { fields, update }; + + println!("{:#?}", issue); Ok(JiraResponse { - key: "123".to_string(), + key: format!("{}-1234", project_key), }) // let response = client @@ -149,11 +275,71 @@ async fn create_jira_issue( // return Err(format!("Failed to create issue: {}", error_text).into()); // } // - // Ok(response.json::().await?) + // let issue_response = response.json::().await?; + // + // // Update status if specified (requires a separate API call) + // if let Some(status) = &metadata.status { + // update_issue_status(config, &issue_response.key, status).await?; + // } + // + // Ok(issue_response) +} + +async fn update_issue_status( + config: &JiraConfig, + issue_key: &str, + status: &str, +) -> Result<(), Box> { + let client = reqwest::Client::new(); + + let transitions = client + .get(format!( + "{}/rest/api/2/issue/{}/transitions", + config.url, issue_key + )) + .basic_auth(&config.email, Some(&config.api_token)) + .send() + .await? + .json::() + .await?; + + // Find the transition ID for the desired status + let transition_id = transitions["transitions"] + .as_array() + .and_then(|t| { + t.iter().find(|t| { + t["to"]["name"] + .as_str() + .map_or(false, |s| s.eq_ignore_ascii_case(status)) + }) + }) + .and_then(|t| t["id"].as_str()) + .ok_or_else(|| format!("No transition found for status: {}", status))?; + + // Perform the transition + let transition_payload = serde_json::json!({ + "transition": { "id": transition_id } + }); + + let response = client + .post(format!( + "{}/rest/api/2/issue/{}/transitions", + config.url, issue_key + )) + .basic_auth(&config.email, Some(&config.api_token)) + .json(&transition_payload) + .send() + .await?; + + if !response.status().is_success() { + return Err(format!("Failed to update status: {}", response.text().await?).into()); + } + + Ok(()) } pub async fn create( - project: String, + project: Option, markdown_file: Option, ) -> Result<(), Box> { let config = JiraConfig::load().map_err(|e| format!("Configuration error: {}", e))?; @@ -163,8 +349,8 @@ pub async fn create( fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e))? } None => { - println!("No markdown file specified. Opening editor..."); - create_temp_markdown()? + eprintln!("No markdown file specified. Opening editor..."); + create_temp_markdown(project)? } }; @@ -172,18 +358,33 @@ pub async fn create( return Err("Empty content. Aborting ticket creation.".into()); } - let (title, description) = parse_markdown(&content); + let (title, description, metadata) = parse_markdown(&content)?; + + let selected_project = match metadata.project.clone() { + Some(x) => x.to_uppercase(), + None => { + eprintln!("No project set..."); + std::process::exit(1); + } + }; + + if selected_project.is_empty() { + eprintln!("No project set..."); + std::process::exit(1); + } // Confirm creation println!("\nAbout to create ticket:"); println!("Title: {}", title); - println!("{}...", &description.chars().take(100).collect::()); + println!("{}", description); + // println!("{}...", &description.chars().take(100).collect::()); println!("\nPress Enter to continue or Ctrl+C to abort"); let mut input = String::new(); std::io::stdin().read_line(&mut input)?; - let response = create_jira_issue(&config, &project, &title, &description).await?; + let response = + create_jira_issue(&config, &selected_project, &title, &description, &metadata).await?; println!("Successfully created ticket: {}", response.key); println!("URL: {}/browse/{}", config.url, response.key);