Add a front-matter with potential issue properties to the create command markdown

This commit is contained in:
Daan Boerlage 2025-01-21 16:57:23 +01:00
parent 0d244cf80d
commit f5e31c3a73
Signed by: daan
GPG key ID: FCE070E1E4956606
5 changed files with 265 additions and 36 deletions

25
Cargo.lock generated
View file

@ -240,7 +240,7 @@ dependencies = [
"rust-ini", "rust-ini",
"serde", "serde",
"serde_json", "serde_json",
"toml", "toml 0.8.19",
"winnow", "winnow",
"yaml-rust2", "yaml-rust2",
] ]
@ -493,6 +493,17 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 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]] [[package]]
name = "h2" name = "h2"
version = "0.4.7" version = "0.4.7"
@ -828,12 +839,13 @@ dependencies = [
"clap", "clap",
"config", "config",
"directories", "directories",
"gray_matter",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
"tokio", "tokio",
"toml", "toml 0.8.19",
] ]
[[package]] [[package]]
@ -1646,6 +1658,15 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.8.19" version = "0.8.19"

View file

@ -14,3 +14,4 @@ toml = "0.8"
config = "0.15" config = "0.15"
directories = "6.0" directories = "6.0"
tempfile = "3.8" tempfile = "3.8"
gray_matter = { version = "0.2", default-features = false, features = ["toml"] }

View file

@ -37,7 +37,7 @@ Options:
### Creating a ticket ### Creating a ticket
``` ```
Usage: jirac create --project <PROJECT> [MARKDOWN_FILE] Usage: jirac create [OPTIONS] [MARKDOWN_FILE]
Arguments: Arguments:
[MARKDOWN_FILE] [MARKDOWN_FILE]
@ -50,13 +50,19 @@ Options:
*Using your favourite editor* *Using your favourite editor*
```sh ```sh
jirac create --project KEY jirac create
``` ```
*From a markdown file* *From a markdown file*
```sh ```sh
jirac create --project KEY ticket.md jirac create ticket.md
```
*Specify a project*
```sh
jirac create --project KEY
``` ```
## Listing tickets ## Listing tickets

View file

@ -12,7 +12,7 @@ pub struct Cli {
pub enum Commands { pub enum Commands {
Create { Create {
#[arg(long)] #[arg(long)]
project: String, project: Option<String>,
#[arg(value_name = "MARKDOWN_FILE")] #[arg(value_name = "MARKDOWN_FILE")]
markdown_file: Option<PathBuf>, markdown_file: Option<PathBuf>,

View file

@ -1,30 +1,73 @@
use crate::jira_config::JiraConfig; use crate::jira_config::JiraConfig;
use gray_matter::engine::TOML;
use gray_matter::Matter;
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
#[derive(Debug, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct JiraIssueRequest { struct TicketMetadata {
fields: JiraIssueFields, #[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
project: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
assignee: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
parent: Option<String>,
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct JiraIssueRequest {
fields: JiraIssueFields,
update: Option<JiraIssueUpdate>,
}
#[derive(Debug, Serialize, Deserialize)]
struct JiraIssueFields { struct JiraIssueFields {
project: Project, project: Project,
summary: String, summary: String,
description: String, description: String,
issuetype: IssueType, issuetype: IssueType,
#[serde(skip_serializing_if = "Option::is_none")]
assignee: Option<Assignee>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<Status>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct JiraIssueUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
parent: Option<Vec<ParentUpdate>>,
}
#[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 { struct Project {
key: String, key: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, Deserialize)]
struct IssueType { struct IssueType {
name: String, name: String,
} }
@ -53,18 +96,72 @@ struct JiraIssueResponseFields {
created: String, created: String,
} }
#[derive(Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct Status { struct Status {
name: String, name: String,
} }
fn get_ticket_template(
ticket_metadata: &TicketMetadata,
) -> Result<String, Box<dyn std::error::Error>> {
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 { fn get_editor() -> String {
std::env::var("EDITOR") std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL")) .or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| "vi".to_string()) .unwrap_or_else(|_| "vi".to_string())
} }
fn parse_markdown(content: &str) -> (String, String) { fn parse_markdown(
content: &str,
) -> Result<(String, String, TicketMetadata), Box<dyn std::error::Error>> {
let matter = Matter::<TOML>::new();
let result = matter.parse_with_struct::<TicketMetadata>(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(); let mut lines = content.lines();
// Get first non-empty line as title // Get first non-empty line as title
@ -79,13 +176,21 @@ fn parse_markdown(content: &str) -> (String, String) {
// Rest is description // Rest is description
let description = lines.collect::<Vec<&str>>().join("\n"); let description = lines.collect::<Vec<&str>>().join("\n");
(title, description) Ok((title, description, metadata))
} }
fn create_temp_markdown() -> Result<String, Box<dyn std::error::Error>> { fn create_temp_markdown(project_key: Option<String>) -> Result<String, Box<dyn std::error::Error>> {
let mut temp_file = NamedTempFile::new()?; 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.write_all(template.as_bytes())?;
temp_file.flush()?; temp_file.flush()?;
@ -105,20 +210,19 @@ fn create_temp_markdown() -> Result<String, Box<dyn std::error::Error>> {
// temp_file will be automatically deleted when it goes out of scope // temp_file will be automatically deleted when it goes out of scope
Ok(content) Ok(content)
} }
async fn create_jira_issue( async fn create_jira_issue(
_config: &JiraConfig, _config: &JiraConfig,
project_key: &str, project_key: &str,
title: &str, title: &str,
description: &str, description: &str,
metadata: &TicketMetadata,
) -> Result<JiraResponse, Box<dyn std::error::Error>> { ) -> Result<JiraResponse, Box<dyn std::error::Error>> {
let _client = reqwest::Client::new(); let _client = reqwest::Client::new();
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let issue = JiraIssueRequest { let mut fields = JiraIssueFields {
fields: JiraIssueFields {
project: Project { project: Project {
key: project_key.to_string(), key: project_key.to_string(),
}, },
@ -127,13 +231,35 @@ async fn create_jira_issue(
issuetype: IssueType { issuetype: IssueType {
name: "Task".to_string(), 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 { Ok(JiraResponse {
key: "123".to_string(), key: format!("{}-1234", project_key),
}) })
// let response = client // let response = client
@ -149,11 +275,71 @@ async fn create_jira_issue(
// return Err(format!("Failed to create issue: {}", error_text).into()); // return Err(format!("Failed to create issue: {}", error_text).into());
// } // }
// //
// Ok(response.json::<JiraResponse>().await?) // let issue_response = response.json::<JiraResponse>().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<dyn std::error::Error>> {
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::<serde_json::Value>()
.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( pub async fn create(
project: String, project: Option<String>,
markdown_file: Option<PathBuf>, markdown_file: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = JiraConfig::load().map_err(|e| format!("Configuration error: {}", e))?; 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))? fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e))?
} }
None => { None => {
println!("No markdown file specified. Opening editor..."); eprintln!("No markdown file specified. Opening editor...");
create_temp_markdown()? create_temp_markdown(project)?
} }
}; };
@ -172,18 +358,33 @@ pub async fn create(
return Err("Empty content. Aborting ticket creation.".into()); 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 // Confirm creation
println!("\nAbout to create ticket:"); println!("\nAbout to create ticket:");
println!("Title: {}", title); println!("Title: {}", title);
println!("{}...", &description.chars().take(100).collect::<String>()); println!("{}", description);
// println!("{}...", &description.chars().take(100).collect::<String>());
println!("\nPress Enter to continue or Ctrl+C to abort"); println!("\nPress Enter to continue or Ctrl+C to abort");
let mut input = String::new(); let mut input = String::new();
std::io::stdin().read_line(&mut input)?; 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!("Successfully created ticket: {}", response.key);
println!("URL: {}/browse/{}", config.url, response.key); println!("URL: {}/browse/{}", config.url, response.key);