Compare commits
No commits in common. "194e25dc143cc833df9eb026860ed926f2354837" and "e20f9e6a9d3cc9ef3825370651e89b255a34bc45" have entirely different histories.
194e25dc14
...
e20f9e6a9d
25 changed files with 388 additions and 700 deletions
15
Cargo.lock
generated
15
Cargo.lock
generated
|
@ -939,8 +939,8 @@ dependencies = [
|
|||
"crossterm",
|
||||
"directories",
|
||||
"gray_matter",
|
||||
"libjirac",
|
||||
"open",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tabwriter",
|
||||
|
@ -976,19 +976,6 @@ version = "0.2.169"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
|
||||
|
||||
[[package]]
|
||||
name = "libjirac"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"http",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.3"
|
||||
|
|
|
@ -5,8 +5,6 @@ resolver = "2"
|
|||
[workspace.dependencies]
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
http = "1"
|
||||
url = "2"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
@ -18,5 +16,4 @@ gray_matter = { version = "0.2", default-features = false, features = ["toml"] }
|
|||
open = "5.2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tabwriter = "1.4"
|
||||
crossterm = "0.28"
|
||||
thiserror = "2"
|
||||
crossterm = "0.28"
|
|
@ -4,9 +4,8 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
libjirac = { path = "../libjirac" }
|
||||
|
||||
clap = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
use crate::jira_config::JiraConfig;
|
||||
use gray_matter::engine::TOML;
|
||||
use gray_matter::Matter;
|
||||
use libjirac::client::commands::{
|
||||
IssueCreateCommand, IssueTransitionsCommand, IssueTransitionsUpdateCommand,
|
||||
};
|
||||
use libjirac::client::JiraClient;
|
||||
use libjirac::entities::issue_request::*;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
@ -27,6 +23,84 @@ struct IssueMetadata {
|
|||
extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JiraIssueRequest {
|
||||
fields: JiraIssueFields,
|
||||
update: Option<JiraIssueUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct JiraIssueFields {
|
||||
project: Project,
|
||||
summary: String,
|
||||
description: String,
|
||||
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)]
|
||||
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 {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct IssueType {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JiraResponse {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JiraSearchResponse {
|
||||
issues: Vec<JiraIssue>,
|
||||
total: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JiraIssue {
|
||||
key: String,
|
||||
fields: JiraIssueResponseFields,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JiraIssueResponseFields {
|
||||
summary: String,
|
||||
status: Status,
|
||||
created: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Status {
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn get_issue_template(
|
||||
issue_metadata: &IssueMetadata,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
|
@ -142,27 +216,37 @@ async fn create_jira_issue(
|
|||
title: &str,
|
||||
description: &str,
|
||||
metadata: &IssueMetadata,
|
||||
) -> Result<IssueCreateResponse, Box<dyn std::error::Error>> {
|
||||
let client = JiraClient::from(config);
|
||||
) -> Result<JiraResponse, Box<dyn std::error::Error>> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let fields = IssueFields {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
|
||||
let mut fields = JiraIssueFields {
|
||||
project: Project {
|
||||
key: project_key.to_string(),
|
||||
},
|
||||
summary: title.to_string(),
|
||||
description: description.to_string(),
|
||||
issue_type: IssueType {
|
||||
issuetype: IssueType {
|
||||
name: "Task".to_string(),
|
||||
},
|
||||
assignee: None,
|
||||
status: None,
|
||||
};
|
||||
|
||||
// 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(IssueUpdate {
|
||||
update = Some(JiraIssueUpdate {
|
||||
parent: Some(vec![ParentUpdate {
|
||||
add: Parent {
|
||||
key: parent_key.clone(),
|
||||
|
@ -171,24 +255,33 @@ async fn create_jira_issue(
|
|||
});
|
||||
}
|
||||
|
||||
let issue = IssueCreateRequest { fields, update };
|
||||
let issue = JiraIssueRequest { fields, update };
|
||||
|
||||
let create_cmd = IssueCreateCommand::new(issue);
|
||||
let issue_response = client.exec(create_cmd).await?;
|
||||
// println!("{:#?}", issue);
|
||||
// Ok(JiraResponse {
|
||||
// key: format!("{}-1234", project_key),
|
||||
// })
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/rest/api/2/issue", config.url))
|
||||
.basic_auth(&config.email, Some(&config.api_token))
|
||||
.headers(headers)
|
||||
.json(&issue)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await?;
|
||||
return Err(format!("Failed to create issue: {}", error_text).into());
|
||||
}
|
||||
|
||||
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?;
|
||||
}
|
||||
|
||||
// Update assignee if specified
|
||||
if let Some(_assignee) = &metadata.assignee {
|
||||
// Need to look up users by username, because the api wants an account id
|
||||
// fields.assignee = Some(Assignee {
|
||||
// id: assignee.clone(),
|
||||
// });
|
||||
}
|
||||
|
||||
Ok(issue_response)
|
||||
}
|
||||
|
||||
|
@ -197,29 +290,52 @@ async fn update_issue_status(
|
|||
issue_key: &str,
|
||||
status: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = JiraClient::from(config);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let transitions_cmd = IssueTransitionsCommand::new(issue_key);
|
||||
let transitions = client.exec(transitions_cmd).await?;
|
||||
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
|
||||
.iter()
|
||||
.find_map(|t| {
|
||||
if t.to.name.eq_ignore_ascii_case(status) {
|
||||
Some(t.id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
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))?;
|
||||
|
||||
let transition_cmd = IssueTransitionsUpdateCommand::new(issue_key, &transition_id);
|
||||
match client.exec(transition_cmd).await {
|
||||
Ok(x) => Ok(x),
|
||||
Err(reason) => Err(reason.into()),
|
||||
// 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(
|
||||
|
|
|
@ -1,81 +1,6 @@
|
|||
use crate::cli::FormatMode;
|
||||
use crate::jira_config::JiraConfig;
|
||||
use crate::term::hyperlink;
|
||||
use crossterm::style::{Color, Stylize};
|
||||
use libjirac::client::commands::SearchCommand;
|
||||
use libjirac::client::JiraClient;
|
||||
use libjirac::entities::issue::JiraIssue;
|
||||
use std::io::Write;
|
||||
|
||||
pub fn display_issues_pretty(issues: &[JiraIssue]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Found {} issues:", issues.len());
|
||||
println!("{:-<80}", "");
|
||||
|
||||
for issue in issues {
|
||||
let mut tw = tabwriter::TabWriter::new(vec![]);
|
||||
writeln!(tw, "{}:\t{}", "Key".blue(), issue.key)?;
|
||||
writeln!(tw, "{}:\t{}", "Summary".blue(), issue.fields.summary)?;
|
||||
writeln!(tw, "{}:\t{}", "Status".blue(), issue.fields.status.name)?;
|
||||
writeln!(
|
||||
tw,
|
||||
"{}:\t{}",
|
||||
"Created".blue(),
|
||||
issue.fields.created.with_timezone(&chrono::Local)
|
||||
)?;
|
||||
writeln!(
|
||||
tw,
|
||||
"{}:\t{}",
|
||||
"Due Date".blue(),
|
||||
match issue.fields.due_date {
|
||||
None => "None".to_string(),
|
||||
Some(x) => x.to_string(),
|
||||
}
|
||||
)?;
|
||||
writeln!(
|
||||
tw,
|
||||
"{}",
|
||||
&hyperlink(&issue.href, &"Open Issue".green().to_string())
|
||||
)?;
|
||||
|
||||
tw.flush().unwrap();
|
||||
|
||||
let written = String::from_utf8(tw.into_inner().unwrap()).unwrap();
|
||||
print!("{}", written);
|
||||
println!("{:-<80}", "");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn display_issues_compact(issues: &[JiraIssue]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Found {} issues:", issues.len());
|
||||
println!("{:-<80}", "");
|
||||
|
||||
let mut tw = tabwriter::TabWriter::new(vec![]);
|
||||
for issue in issues {
|
||||
writeln!(
|
||||
tw,
|
||||
"{}:\t{}",
|
||||
hyperlink(
|
||||
&issue.href,
|
||||
&issue.key.clone().blue().underline(Color::Blue).to_string()
|
||||
),
|
||||
issue.fields.summary.clone().green()
|
||||
)?;
|
||||
}
|
||||
|
||||
tw.flush().unwrap();
|
||||
let written = String::from_utf8(tw.into_inner().unwrap()).unwrap();
|
||||
print!("{}", written);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn display_issues_json(issues: &[JiraIssue]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let j = serde_json::to_string_pretty(issues)?;
|
||||
println!("{}", j);
|
||||
Ok(())
|
||||
}
|
||||
use crate::types::issue::{display_issues_compact, display_issues_json, display_issues_pretty};
|
||||
|
||||
pub async fn exec(output: FormatMode, jql: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = JiraConfig::load().map_err(|e| format!("Configuration error: {}", e))?;
|
||||
|
@ -83,10 +8,13 @@ pub async fn exec(output: FormatMode, jql: &str) -> Result<(), Box<dyn std::erro
|
|||
println!("Searching for issues...");
|
||||
}
|
||||
|
||||
let client = JiraClient::from(&config);
|
||||
|
||||
let jql_cmd = SearchCommand::new(jql);
|
||||
let result = client.exec(jql_cmd).await?;
|
||||
let result = match crate::jql::run(&config, jql).await {
|
||||
Ok(x) => x,
|
||||
Err(reason) => {
|
||||
eprintln!("Error fetching issues: {}", reason);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match (output, result.issues.is_empty()) {
|
||||
(FormatMode::Pretty, false) => {
|
||||
|
|
|
@ -1,12 +1,32 @@
|
|||
use crate::cli::FormatMode;
|
||||
use crate::jira_config::JiraConfig;
|
||||
use crate::term::hyperlink;
|
||||
use crate::types::issue::JiraIssue;
|
||||
use crossterm::style::{Color, Stylize};
|
||||
use libjirac::client::commands::{SearchCommand, SelfCommand};
|
||||
use libjirac::client::JiraClient;
|
||||
use libjirac::entities::issue::JiraIssue;
|
||||
use std::io::Write;
|
||||
|
||||
async fn fetch_issue(
|
||||
config: &JiraConfig,
|
||||
href: &str,
|
||||
) -> Result<JiraIssue, Box<dyn std::error::Error>> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let response = client
|
||||
.get(href)
|
||||
.basic_auth(&config.email, Some(&config.api_token))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await?;
|
||||
return Err(format!("Failed to fetch issue: {}", error_text).into());
|
||||
}
|
||||
|
||||
let issue_response = response.json::<JiraIssue>().await?;
|
||||
|
||||
Ok(issue_response)
|
||||
}
|
||||
|
||||
fn pretty_print(issue: &JiraIssue) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("\n== Title {:=<71}", "");
|
||||
println!(
|
||||
|
@ -104,10 +124,13 @@ pub async fn exec(output: FormatMode, issue_key: &str) -> Result<(), Box<dyn std
|
|||
|
||||
let jql = format!("key = '{}'", issue_key);
|
||||
|
||||
let client = JiraClient::from(&config);
|
||||
|
||||
let jql_cmd = SearchCommand::new(&jql);
|
||||
let matched_issues = client.exec(jql_cmd).await?;
|
||||
let matched_issues = match crate::jql::run(&config, &jql).await {
|
||||
Ok(x) => x,
|
||||
Err(reason) => {
|
||||
eprintln!("Error fetching issue: {}", reason);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let matched_issue = match matched_issues.issues.first() {
|
||||
Some(x) => x,
|
||||
|
@ -117,8 +140,7 @@ pub async fn exec(output: FormatMode, issue_key: &str) -> Result<(), Box<dyn std
|
|||
}
|
||||
};
|
||||
|
||||
let self_cmd = SelfCommand::<JiraIssue>::new(&matched_issue.href);
|
||||
let fetched_issue = client.exec(self_cmd).await?;
|
||||
let fetched_issue = fetch_issue(&config, &matched_issue.href).await?;
|
||||
|
||||
match output {
|
||||
FormatMode::Pretty => pretty_print(&fetched_issue)?,
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
use config::{Config, ConfigError, File};
|
||||
use directories::ProjectDirs;
|
||||
use libjirac::client::JiraClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
@ -67,9 +66,3 @@ impl JiraConfig {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&JiraConfig> for JiraClient {
|
||||
fn from(value: &JiraConfig) -> Self {
|
||||
Self::new(&value.url, &value.email, &value.api_token)
|
||||
}
|
||||
}
|
||||
|
|
37
crates/jirac/src/jql.rs
Normal file
37
crates/jirac/src/jql.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
use crate::jira_config::JiraConfig;
|
||||
use crate::types::issue::JiraIssue;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JiraSearchResponse {
|
||||
pub issues: Vec<JiraIssue>,
|
||||
pub total: u32,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
config: &JiraConfig,
|
||||
jql: &str,
|
||||
) -> Result<JiraSearchResponse, Box<dyn std::error::Error>> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
|
||||
let query = [("jql", jql)];
|
||||
|
||||
let response = client
|
||||
.get(format!("{}/rest/api/2/search", config.url))
|
||||
.basic_auth(&config.email, Some(&config.api_token))
|
||||
.headers(headers)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await?;
|
||||
return Err(format!("Failed to fetch issues: {}", error_text).into());
|
||||
}
|
||||
|
||||
Ok(response.json::<JiraSearchResponse>().await?)
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
mod cli;
|
||||
mod cmd;
|
||||
mod jira_config;
|
||||
mod jql;
|
||||
mod term;
|
||||
mod types;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Commands};
|
||||
|
|
1
crates/jirac/src/types.rs
Normal file
1
crates/jirac/src/types.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod issue;
|
153
crates/jirac/src/types/issue.rs
Normal file
153
crates/jirac/src/types/issue.rs
Normal file
|
@ -0,0 +1,153 @@
|
|||
use crate::term::hyperlink;
|
||||
use crossterm::style::{Color, Stylize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Write;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct JiraIssue {
|
||||
pub key: String,
|
||||
#[serde(rename = "self")]
|
||||
pub href: String,
|
||||
pub fields: JiraIssueResponseFields,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct JiraIssueResponseFields {
|
||||
pub summary: String,
|
||||
pub description: Option<String>,
|
||||
pub status: Status,
|
||||
pub created: chrono::DateTime<chrono::Utc>,
|
||||
pub priority: Priority,
|
||||
pub assignee: Person,
|
||||
pub reporter: Person,
|
||||
pub creator: Person,
|
||||
#[serde(rename = "duedate")]
|
||||
pub due_date: Option<chrono::NaiveDate>,
|
||||
pub comment: Option<Comments>,
|
||||
pub votes: Votes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Status {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Priority {
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Person {
|
||||
#[serde(rename = "self")]
|
||||
pub href: String,
|
||||
#[serde(rename = "displayName")]
|
||||
pub display_name: String,
|
||||
#[serde(rename = "accountId")]
|
||||
pub account_id: String,
|
||||
#[serde(rename = "emailAddress")]
|
||||
pub email_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||
pub struct Comments {
|
||||
pub total: u32,
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: u32,
|
||||
#[serde(rename = "startAt")]
|
||||
pub start_at: u32,
|
||||
pub comments: Vec<Comment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Comment {
|
||||
#[serde(rename = "self")]
|
||||
pub href: String,
|
||||
pub id: String,
|
||||
pub author: Person,
|
||||
pub body: String,
|
||||
#[serde(rename = "updateAuthor")]
|
||||
pub update_author: Person,
|
||||
pub created: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Votes {
|
||||
#[serde(rename = "self")]
|
||||
pub href: String,
|
||||
#[serde(rename = "votes")]
|
||||
pub count: i32,
|
||||
#[serde(rename = "hasVoted")]
|
||||
pub has_voted: bool,
|
||||
}
|
||||
|
||||
pub fn display_issues_pretty(issues: &[JiraIssue]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Found {} issues:", issues.len());
|
||||
println!("{:-<80}", "");
|
||||
|
||||
for issue in issues {
|
||||
let mut tw = tabwriter::TabWriter::new(vec![]);
|
||||
writeln!(tw, "{}:\t{}", "Key".blue(), issue.key)?;
|
||||
writeln!(tw, "{}:\t{}", "Summary".blue(), issue.fields.summary)?;
|
||||
writeln!(tw, "{}:\t{}", "Status".blue(), issue.fields.status.name)?;
|
||||
writeln!(
|
||||
tw,
|
||||
"{}:\t{}",
|
||||
"Created".blue(),
|
||||
issue.fields.created.with_timezone(&chrono::Local)
|
||||
)?;
|
||||
writeln!(
|
||||
tw,
|
||||
"{}:\t{}",
|
||||
"Due Date".blue(),
|
||||
match issue.fields.due_date {
|
||||
None => "None".to_string(),
|
||||
Some(x) => x.to_string(),
|
||||
}
|
||||
)?;
|
||||
writeln!(
|
||||
tw,
|
||||
"{}",
|
||||
&hyperlink(&issue.href, &"Open Issue".green().to_string())
|
||||
)?;
|
||||
|
||||
tw.flush().unwrap();
|
||||
|
||||
let written = String::from_utf8(tw.into_inner().unwrap()).unwrap();
|
||||
print!("{}", written);
|
||||
println!("{:-<80}", "");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn display_issues_compact(issues: &[JiraIssue]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Found {} issues:", issues.len());
|
||||
println!("{:-<80}", "");
|
||||
|
||||
let mut tw = tabwriter::TabWriter::new(vec![]);
|
||||
for issue in issues {
|
||||
writeln!(
|
||||
tw,
|
||||
"{}:\t{}",
|
||||
hyperlink(
|
||||
&issue.href,
|
||||
&issue.key.clone().blue().underline(Color::Blue).to_string()
|
||||
),
|
||||
issue.fields.summary.clone().green()
|
||||
)?;
|
||||
}
|
||||
|
||||
tw.flush().unwrap();
|
||||
let written = String::from_utf8(tw.into_inner().unwrap()).unwrap();
|
||||
print!("{}", written);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn display_issues_json(issues: &[JiraIssue]) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let j = serde_json::to_string_pretty(issues)?;
|
||||
println!("{}", j);
|
||||
Ok(())
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
[package]
|
||||
name = "libjirac"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
http = { workspace = true }
|
||||
url = { workspace = true }
|
||||
thiserror = { workspace = true }
|
|
@ -1,155 +0,0 @@
|
|||
pub mod commands;
|
||||
|
||||
use http::header::CONTENT_TYPE;
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use std::fmt::Debug;
|
||||
use thiserror::Error;
|
||||
use url::{ParseError, Url};
|
||||
|
||||
pub struct JiraClient {
|
||||
pub url: String,
|
||||
pub email: String,
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
pub trait JiraCommand {
|
||||
type TResponse: DeserializeOwned + Clone;
|
||||
type TPayload: Serialize;
|
||||
const REQUEST_TYPE: JiraRequestType;
|
||||
fn endpoint(&self) -> String;
|
||||
fn request_body(&self) -> Option<&Self::TPayload>;
|
||||
fn query_params(&self) -> Option<Vec<(String, String)>>;
|
||||
}
|
||||
|
||||
pub enum JiraRequestType {
|
||||
Read,
|
||||
Create,
|
||||
Update,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl From<JiraRequestType> for http::Method {
|
||||
fn from(value: JiraRequestType) -> Self {
|
||||
match value {
|
||||
JiraRequestType::Read => Self::GET,
|
||||
JiraRequestType::Create => Self::POST,
|
||||
JiraRequestType::Update => Self::PATCH,
|
||||
JiraRequestType::Delete => Self::DELETE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum JiraClientError {
|
||||
#[error(transparent)]
|
||||
UrlParseError(#[from] ParseError),
|
||||
#[error(transparent)]
|
||||
ReqwestError(#[from] reqwest::Error),
|
||||
#[error(transparent)]
|
||||
SerdeError(#[from] serde_json::Error),
|
||||
#[error("API Error: {0}")]
|
||||
ApiError(String),
|
||||
}
|
||||
|
||||
impl JiraClient {
|
||||
pub fn new(url: &str, email: &str, api_token: &str) -> Self {
|
||||
Self {
|
||||
url: url.to_string(),
|
||||
email: email.to_string(),
|
||||
api_token: api_token.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec<TCommand>(
|
||||
&self,
|
||||
cmd: TCommand,
|
||||
) -> Result<TCommand::TResponse, JiraClientError>
|
||||
where
|
||||
TCommand: JiraCommand + Debug,
|
||||
{
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
|
||||
let url = self.construct_url(&cmd.endpoint())?;
|
||||
|
||||
let mut request_builder = client
|
||||
.request(TCommand::REQUEST_TYPE.into(), url)
|
||||
.basic_auth(&self.email, Some(&self.api_token))
|
||||
.headers(headers);
|
||||
|
||||
if let Some(params) = cmd.query_params() {
|
||||
request_builder = request_builder.query(¶ms);
|
||||
}
|
||||
|
||||
if let Some(body) = cmd.request_body() {
|
||||
request_builder = request_builder.json(body);
|
||||
}
|
||||
|
||||
let response = match request_builder.send().await {
|
||||
Ok(x) => x,
|
||||
Err(reason) => return Err(JiraClientError::ReqwestError(reason)),
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_text = response.text().await?;
|
||||
return Err(JiraClientError::ApiError(error_text));
|
||||
}
|
||||
|
||||
let plain = match response.bytes().await {
|
||||
Ok(x) => x,
|
||||
Err(reason) => return Err(JiraClientError::ReqwestError(reason)),
|
||||
};
|
||||
|
||||
let value = match is_unit::<TCommand::TResponse>() {
|
||||
true => b"null",
|
||||
false => plain.as_ref(),
|
||||
};
|
||||
|
||||
match serde_json::from_slice::<TCommand::TResponse>(value) {
|
||||
Ok(x) => Ok(x),
|
||||
Err(reason) => Err(JiraClientError::SerdeError(reason)),
|
||||
}
|
||||
}
|
||||
|
||||
fn construct_url(&self, endpoint: &str) -> Result<String, JiraClientError> {
|
||||
let mut url = match Url::parse(&self.url) {
|
||||
Ok(x) => x,
|
||||
Err(reason) => {
|
||||
return Err(JiraClientError::UrlParseError(reason));
|
||||
}
|
||||
};
|
||||
|
||||
url.set_path(endpoint);
|
||||
|
||||
Ok(url.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
const fn is_unit<T>() -> bool {
|
||||
size_of::<T>() == 0 && align_of::<T>() == 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_unit_type() {
|
||||
assert!(is_unit::<()>());
|
||||
assert!(!is_unit::<String>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_unit_type() {
|
||||
let value = match is_unit::<()>() {
|
||||
true => b"null".as_ref(),
|
||||
false => b"{}".as_ref(),
|
||||
};
|
||||
|
||||
serde_json::from_slice::<()>(value).unwrap();
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
mod issue_create_command;
|
||||
mod issue_transitions_command;
|
||||
mod issue_transitions_update_command;
|
||||
mod search_command;
|
||||
mod self_command;
|
||||
|
||||
pub use issue_create_command::IssueCreateCommand;
|
||||
pub use issue_transitions_command::IssueTransitionsCommand;
|
||||
pub use issue_transitions_update_command::IssueTransitionsUpdateCommand;
|
||||
pub use search_command::SearchCommand;
|
||||
pub use self_command::SelfCommand;
|
|
@ -1,31 +0,0 @@
|
|||
use crate::client::{JiraCommand, JiraRequestType};
|
||||
use crate::entities::issue_request::{IssueCreateRequest, IssueCreateResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct IssueCreateCommand {
|
||||
issue: IssueCreateRequest,
|
||||
}
|
||||
|
||||
impl IssueCreateCommand {
|
||||
pub fn new(issue: IssueCreateRequest) -> Self {
|
||||
Self { issue }
|
||||
}
|
||||
}
|
||||
|
||||
impl JiraCommand for IssueCreateCommand {
|
||||
type TResponse = IssueCreateResponse;
|
||||
type TPayload = IssueCreateRequest;
|
||||
const REQUEST_TYPE: JiraRequestType = JiraRequestType::Create;
|
||||
|
||||
fn endpoint(&self) -> String {
|
||||
"/rest/api/2/issue".to_string()
|
||||
}
|
||||
|
||||
fn request_body(&self) -> Option<&Self::TPayload> {
|
||||
Some(&self.issue)
|
||||
}
|
||||
|
||||
fn query_params(&self) -> Option<Vec<(String, String)>> {
|
||||
None
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
use crate::client::{JiraCommand, JiraRequestType};
|
||||
use crate::entities::transitions::Transitions;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct IssueTransitionsCommand {
|
||||
issue_key: String,
|
||||
}
|
||||
|
||||
impl IssueTransitionsCommand {
|
||||
pub fn new(issue_key: &str) -> Self {
|
||||
Self {
|
||||
issue_key: issue_key.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JiraCommand for IssueTransitionsCommand {
|
||||
type TResponse = Transitions;
|
||||
type TPayload = ();
|
||||
const REQUEST_TYPE: JiraRequestType = JiraRequestType::Read;
|
||||
|
||||
fn endpoint(&self) -> String {
|
||||
format!("/rest/api/3/issue/{}/transitions", self.issue_key)
|
||||
}
|
||||
|
||||
fn request_body(&self) -> Option<&Self::TPayload> {
|
||||
None
|
||||
}
|
||||
|
||||
fn query_params(&self) -> Option<Vec<(String, String)>> {
|
||||
None
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
use crate::client::{JiraCommand, JiraRequestType};
|
||||
use crate::entities::transitions::{IssueTransitionUpdatePayload, IssueTransitionUpdatePayloadId};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct IssueTransitionsUpdateCommand {
|
||||
pub issue_key: String,
|
||||
pub transition: IssueTransitionUpdatePayload,
|
||||
}
|
||||
|
||||
impl IssueTransitionsUpdateCommand {
|
||||
pub fn new(issue_key: &str, transition_id: &str) -> Self {
|
||||
Self {
|
||||
issue_key: issue_key.to_string(),
|
||||
transition: IssueTransitionUpdatePayload {
|
||||
transition: IssueTransitionUpdatePayloadId {
|
||||
id: transition_id.to_string(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JiraCommand for IssueTransitionsUpdateCommand {
|
||||
type TResponse = ();
|
||||
type TPayload = IssueTransitionUpdatePayload;
|
||||
const REQUEST_TYPE: JiraRequestType = JiraRequestType::Create;
|
||||
|
||||
fn endpoint(&self) -> String {
|
||||
format!("/rest/api/3/issue/{}/transitions", self.issue_key)
|
||||
}
|
||||
|
||||
fn request_body(&self) -> Option<&Self::TPayload> {
|
||||
Some(&self.transition)
|
||||
}
|
||||
|
||||
fn query_params(&self) -> Option<Vec<(String, String)>> {
|
||||
None
|
||||
}
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
use crate::client::{JiraCommand, JiraRequestType};
|
||||
use crate::entities::search::JiraSearchResponse;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SearchCommand {
|
||||
jql: String,
|
||||
}
|
||||
|
||||
impl SearchCommand {
|
||||
pub fn new(jql: &str) -> Self {
|
||||
Self {
|
||||
jql: jql.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JiraCommand for SearchCommand {
|
||||
type TResponse = JiraSearchResponse;
|
||||
type TPayload = ();
|
||||
const REQUEST_TYPE: JiraRequestType = JiraRequestType::Read;
|
||||
|
||||
fn endpoint(&self) -> String {
|
||||
"/rest/api/2/search".to_string()
|
||||
}
|
||||
|
||||
fn request_body(&self) -> Option<&Self::TPayload> {
|
||||
None
|
||||
}
|
||||
|
||||
fn query_params(&self) -> Option<Vec<(String, String)>> {
|
||||
let params = vec![("jql".to_string(), self.jql.clone())];
|
||||
|
||||
Some(params)
|
||||
}
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
use crate::client::{JiraCommand, JiraRequestType};
|
||||
use serde::de::DeserializeOwned;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SelfCommand<T>
|
||||
where
|
||||
T: DeserializeOwned + Clone,
|
||||
{
|
||||
href: String,
|
||||
_marker: std::marker::PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T> SelfCommand<T>
|
||||
where
|
||||
T: DeserializeOwned + Clone,
|
||||
{
|
||||
pub fn new(href: &str) -> Self {
|
||||
Self {
|
||||
href: href.to_owned(),
|
||||
_marker: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> JiraCommand for SelfCommand<T>
|
||||
where
|
||||
T: DeserializeOwned + Clone,
|
||||
{
|
||||
type TResponse = T;
|
||||
type TPayload = ();
|
||||
const REQUEST_TYPE: JiraRequestType = JiraRequestType::Read;
|
||||
|
||||
fn endpoint(&self) -> String {
|
||||
let url = Url::parse(&self.href).unwrap();
|
||||
url.path().to_string()
|
||||
}
|
||||
|
||||
fn request_body(&self) -> Option<&Self::TPayload> {
|
||||
None
|
||||
}
|
||||
|
||||
fn query_params(&self) -> Option<Vec<(String, String)>> {
|
||||
None
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
pub mod issue;
|
||||
pub mod issue_request;
|
||||
pub mod search;
|
||||
pub mod transitions;
|
|
@ -1,80 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct JiraIssue {
|
||||
pub key: String,
|
||||
#[serde(rename = "self")]
|
||||
pub href: String,
|
||||
pub fields: JiraIssueResponseFields,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct JiraIssueResponseFields {
|
||||
pub summary: String,
|
||||
pub description: Option<String>,
|
||||
pub status: Status,
|
||||
pub created: chrono::DateTime<chrono::Utc>,
|
||||
pub priority: Priority,
|
||||
pub assignee: Person,
|
||||
pub reporter: Person,
|
||||
pub creator: Person,
|
||||
#[serde(rename = "duedate")]
|
||||
pub due_date: Option<chrono::NaiveDate>,
|
||||
pub comment: Option<Comments>,
|
||||
pub votes: Votes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Status {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Priority {
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Person {
|
||||
#[serde(rename = "self")]
|
||||
pub href: String,
|
||||
#[serde(rename = "displayName")]
|
||||
pub display_name: String,
|
||||
#[serde(rename = "accountId")]
|
||||
pub account_id: String,
|
||||
#[serde(rename = "emailAddress")]
|
||||
pub email_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
|
||||
pub struct Comments {
|
||||
pub total: u32,
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: u32,
|
||||
#[serde(rename = "startAt")]
|
||||
pub start_at: u32,
|
||||
pub comments: Vec<Comment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Comment {
|
||||
#[serde(rename = "self")]
|
||||
pub href: String,
|
||||
pub id: String,
|
||||
pub author: Person,
|
||||
pub body: String,
|
||||
#[serde(rename = "updateAuthor")]
|
||||
pub update_author: Person,
|
||||
pub created: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Votes {
|
||||
#[serde(rename = "self")]
|
||||
pub href: String,
|
||||
#[serde(rename = "votes")]
|
||||
pub count: i32,
|
||||
#[serde(rename = "hasVoted")]
|
||||
pub has_voted: bool,
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct IssueCreateResponse {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct IssueCreateRequest {
|
||||
pub fields: IssueFields,
|
||||
pub update: Option<IssueUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct IssueUpdate {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<Vec<ParentUpdate>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ParentUpdate {
|
||||
pub add: Parent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Parent {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IssueFields {
|
||||
pub project: Project,
|
||||
pub summary: String,
|
||||
pub description: String,
|
||||
#[serde(rename = "issuetype")]
|
||||
pub issue_type: IssueType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assignee: Option<Assignee>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<Status>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Assignee {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IssueType {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Status {
|
||||
pub name: String,
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
use crate::entities::issue::JiraIssue;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct JiraSearchResponse {
|
||||
pub issues: Vec<JiraIssue>,
|
||||
pub total: u32,
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Transitions {
|
||||
pub transitions: Vec<Transition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Transition {
|
||||
pub id: String,
|
||||
pub to: TransitionTarget,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TransitionTarget {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IssueTransitionUpdatePayload {
|
||||
pub transition: IssueTransitionUpdatePayloadId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IssueTransitionUpdatePayloadId {
|
||||
pub id: String,
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
pub mod client;
|
||||
pub mod entities;
|
Loading…
Reference in a new issue