Add a command client to libjirac
This commit is contained in:
parent
6102233bc5
commit
8a7c989f48
16 changed files with 253 additions and 60 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
@ -982,7 +982,11 @@ name = "libjirac"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"http",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
@ -5,6 +5,8 @@ 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"
|
||||
|
@ -16,4 +18,5 @@ 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"
|
||||
crossterm = "0.28"
|
||||
thiserror = "2"
|
|
@ -2,6 +2,8 @@ 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;
|
||||
|
||||
|
@ -81,13 +83,10 @@ pub async fn exec(output: FormatMode, jql: &str) -> Result<(), Box<dyn std::erro
|
|||
println!("Searching for issues...");
|
||||
}
|
||||
|
||||
let result = match crate::jql::run(&config, jql).await {
|
||||
Ok(x) => x,
|
||||
Err(reason) => {
|
||||
eprintln!("Error fetching issues: {}", reason);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let client = JiraClient::from(&config);
|
||||
|
||||
let jql_cmd = SearchCommand::new(&jql);
|
||||
let result = client.exec(jql_cmd).await?;
|
||||
|
||||
match (output, result.issues.is_empty()) {
|
||||
(FormatMode::Pretty, false) => {
|
||||
|
|
|
@ -2,6 +2,8 @@ use crate::cli::FormatMode;
|
|||
use crate::jira_config::JiraConfig;
|
||||
use crate::term::hyperlink;
|
||||
use crossterm::style::{Color, Stylize};
|
||||
use libjirac::client::commands::{SearchCommand, SelfCommand};
|
||||
use libjirac::client::JiraClient;
|
||||
use libjirac::entities::issue::JiraIssue;
|
||||
use std::io::Write;
|
||||
|
||||
|
@ -124,13 +126,10 @@ pub async fn exec(output: FormatMode, issue_key: &str) -> Result<(), Box<dyn std
|
|||
|
||||
let jql = format!("key = '{}'", issue_key);
|
||||
|
||||
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 client = JiraClient::from(&config);
|
||||
|
||||
let jql_cmd = SearchCommand::new(&jql);
|
||||
let matched_issues = client.exec(jql_cmd).await?;
|
||||
|
||||
let matched_issue = match matched_issues.issues.first() {
|
||||
Some(x) => x,
|
||||
|
@ -140,7 +139,8 @@ pub async fn exec(output: FormatMode, issue_key: &str) -> Result<(), Box<dyn std
|
|||
}
|
||||
};
|
||||
|
||||
let fetched_issue = fetch_issue(&config, &matched_issue.href).await?;
|
||||
let self_cmd = SelfCommand::<JiraIssue>::new(&matched_issue.href);
|
||||
let fetched_issue = client.exec(self_cmd).await?;
|
||||
|
||||
match output {
|
||||
FormatMode::Pretty => pretty_print(&fetched_issue)?,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use config::{Config, ConfigError, File};
|
||||
use directories::ProjectDirs;
|
||||
use libjirac::client::JiraClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
@ -66,3 +67,9 @@ impl JiraConfig {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&JiraConfig> for JiraClient {
|
||||
fn from(value: &JiraConfig) -> Self {
|
||||
Self::new(&value.url, &value.email, &value.api_token)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
use crate::jira_config::JiraConfig;
|
||||
use libjirac::entities::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,6 @@
|
|||
mod cli;
|
||||
mod cmd;
|
||||
mod jira_config;
|
||||
mod jql;
|
||||
mod term;
|
||||
|
||||
use clap::Parser;
|
||||
|
|
|
@ -5,4 +5,8 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
http = { workspace = true }
|
||||
url = { workspace = true }
|
||||
thiserror = { workspace = true }
|
118
crates/libjirac/src/client.rs
Normal file
118
crates/libjirac/src/client.rs
Normal file
|
@ -0,0 +1,118 @@
|
|||
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("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));
|
||||
}
|
||||
|
||||
match response.json::<TCommand::TResponse>().await {
|
||||
Ok(x) => Ok(x),
|
||||
Err(reason) => Err(JiraClientError::ReqwestError(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())
|
||||
}
|
||||
}
|
5
crates/libjirac/src/client/commands.rs
Normal file
5
crates/libjirac/src/client/commands.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod search_command;
|
||||
mod self_command;
|
||||
|
||||
pub use search_command::SearchCommand;
|
||||
pub use self_command::SelfCommand;
|
35
crates/libjirac/src/client/commands/search_command.rs
Normal file
35
crates/libjirac/src/client/commands/search_command.rs
Normal file
|
@ -0,0 +1,35 @@
|
|||
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)
|
||||
}
|
||||
}
|
46
crates/libjirac/src/client/commands/self_command.rs
Normal file
46
crates/libjirac/src/client/commands/self_command.rs
Normal file
|
@ -0,0 +1,46 @@
|
|||
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 +1,2 @@
|
|||
pub mod issue;
|
||||
pub mod search;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct JiraIssue {
|
||||
pub key: String,
|
||||
#[serde(rename = "self")]
|
||||
|
@ -8,7 +8,7 @@ pub struct JiraIssue {
|
|||
pub fields: JiraIssueResponseFields,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct JiraIssueResponseFields {
|
||||
pub summary: String,
|
||||
pub description: Option<String>,
|
||||
|
@ -24,12 +24,12 @@ pub struct JiraIssueResponseFields {
|
|||
pub votes: Votes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Status {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Priority {
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
|
@ -69,7 +69,7 @@ pub struct Comment {
|
|||
pub created: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Votes {
|
||||
#[serde(rename = "self")]
|
||||
pub href: String,
|
||||
|
|
8
crates/libjirac/src/entities/search.rs
Normal file
8
crates/libjirac/src/entities/search.rs
Normal file
|
@ -0,0 +1,8 @@
|
|||
use crate::entities::issue::JiraIssue;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct JiraSearchResponse {
|
||||
pub issues: Vec<JiraIssue>,
|
||||
pub total: u32,
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
pub mod client;
|
||||
pub mod entities;
|
||||
|
|
Loading…
Reference in a new issue