Add a front-matter with potential issue properties to the create command markdown
This commit is contained in:
parent
0d244cf80d
commit
f5e31c3a73
5 changed files with 265 additions and 36 deletions
25
Cargo.lock
generated
25
Cargo.lock
generated
|
@ -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"
|
||||||
|
|
|
@ -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"] }
|
12
readme.md
12
readme.md
|
@ -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
|
||||||
|
|
|
@ -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>,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in a new issue