diff --git a/Cargo.lock b/Cargo.lock index 90244e9..3cac3c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,16 +256,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" -[[package]] -name = "colored" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" -dependencies = [ - "lazy_static", - "windows-sys 0.59.0", -] - [[package]] name = "config" version = "0.15.6" @@ -339,6 +329,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.2" @@ -920,8 +935,8 @@ version = "0.1.0" dependencies = [ "chrono", "clap", - "colored", "config", + "crossterm", "directories", "gray_matter", "open", @@ -955,12 +970,6 @@ dependencies = [ "serde", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.169" @@ -1033,6 +1042,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.52.0", ] @@ -1545,6 +1555,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -2053,6 +2084,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 0efb1d2..ef0a0d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ reqwest = { version = "0.12", features = ["json"] } tokio = { version = "1.0", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -colored = "2.0" toml = "0.8" config = "0.15" directories = "6.0" @@ -17,4 +16,5 @@ tempfile = "3.8" gray_matter = { version = "0.2", default-features = false, features = ["toml"] } open = "5.2" chrono = { version = "0.4", features = ["serde"] } -tabwriter = "1.4" \ No newline at end of file +tabwriter = "1.4" +crossterm = "0.28" \ No newline at end of file diff --git a/readme.md b/readme.md index 9481120..e957457 100644 --- a/readme.md +++ b/readme.md @@ -24,9 +24,10 @@ cargo install jirac Usage: jirac Commands: - create Create a ticket - list Find tickets currently assigned to you - init Setup the configuration + create Create an issue + list Find issues currently assigned to you + search Search for issues + init Set up the configuration help Print this message or the help of the given subcommand(s) Options: @@ -68,7 +69,7 @@ jirac create ticket.md jirac create --project KEY ``` -## Listing tickets +### Listing tickets ``` Find tickets currently assigned to you @@ -80,6 +81,29 @@ Options: -h, --help Print help ``` +### Search for tickets + +``` +Search for issues + +Usage: jirac search [OPTIONS] + +Arguments: + A JQL string + +Options: + --json Print JSON rather than pretty print + -h, --help Print help +``` + +Use [JQL](https://support.atlassian.com/jira-software-cloud/docs/use-advanced-search-with-jira-query-language-jql/) to search for Issues. + +*Find all in-progress tickets in a project* + +``` +jirac search 'project = KEY AND status = "In Progress" ORDER BY created DESC' +``` + ## Configuration Get the following information: @@ -88,7 +112,7 @@ Get the following information: * You email * [An API key](https://id.atlassian.com/manage-profile/security/api-tokens) -Then run the the `jirac init` command +Then run the `jirac init` command ``` Setup the configuration diff --git a/src/cli.rs b/src/cli.rs index 136e472..818a9fb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -10,27 +10,37 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { - /// Create a ticket + /// Create an issue Create { - /// The project key in which to create the ticket + /// The project key in which to create the issue #[arg(long)] project: Option, - /// Open the new ticket in a browser + /// Open the new issue in a browser #[arg(long)] open: bool, - /// A markdown file + /// A Markdown file #[arg(value_name = "MARKDOWN_FILE")] markdown_file: Option, }, - /// Find tickets currently assigned to you + /// Find issues currently assigned to you List { - /// Print json rather than pretty print + /// Print JSON rather than pretty print #[arg(long)] json: bool, }, - /// Setup the configuration + /// Search for issues + Search { + /// Print JSON rather than pretty print + #[arg(long)] + json: bool, + + /// A JQL string + #[arg(value_name = "JQL")] + jql: String, + }, + /// Set up the configuration Init { /// Jira instance URL #[arg(long)] diff --git a/src/cmd.rs b/src/cmd.rs index fa6613e..ab17459 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -1,2 +1,3 @@ pub mod create; pub mod list; +pub mod search; diff --git a/src/cmd/list.rs b/src/cmd/list.rs index aa50486..2b8ea19 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -1,115 +1,5 @@ use crate::jira_config::JiraConfig; -use colored::Colorize; -use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; -use serde::{Deserialize, Serialize}; -use std::io::Write; - -#[derive(Debug, Deserialize, Serialize)] -struct JiraIssue { - key: String, - #[serde(rename = "self")] - href: String, - fields: JiraIssueResponseFields, -} - -#[derive(Debug, Deserialize, Serialize)] -struct JiraIssueResponseFields { - summary: String, - status: Status, - created: chrono::DateTime, - priority: Priority, - assignee: Person, - reporter: Person, - creator: Person, - #[serde(rename = "duedate")] - due_date: Option, -} - -#[derive(Deserialize)] -struct JiraSearchResponse { - issues: Vec, - total: u32, -} - -#[derive(Debug, Deserialize, Serialize)] -struct Status { - name: String, -} - -#[derive(Debug, Deserialize, Serialize)] -struct Priority { - name: String, - id: String, -} - -#[derive(Debug, Deserialize, Serialize)] -struct Person { - #[serde(rename = "self")] - href: String, - #[serde(rename = "displayName")] - display_name: String, - #[serde(rename = "accountId")] - account_id: String, - #[serde(rename = "emailAddress")] - email_address: String, -} - -async fn list_jira_issues( - config: &JiraConfig, -) -> Result> { - let client = reqwest::Client::new(); - - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - - // JQL query to find issues assigned to the specified user - let jql = - "assignee = currentUser() AND resolution = Unresolved order by updated DESC".to_string(); - let query = [("jql", jql.as_str())]; - - 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::().await?) -} - -fn display_issues_pretty(issues: &[JiraIssue]) -> Result<(), Box> { - 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)?; - writeln!(tw, "{}:\t{:?}", "Due Date".blue(), issue.fields.due_date)?; - writeln!(tw, "{}:\t{}", "URL".blue(), issue.href.underline())?; - tw.flush().unwrap(); - - let written = String::from_utf8(tw.into_inner().unwrap()).unwrap(); - print!("{}", written); - println!("{:-<80}", ""); - } - - Ok(()) -} - -fn display_issues_json(issues: &[JiraIssue]) -> Result<(), Box> { - let j = serde_json::to_string_pretty(issues)?; - println!("{}", j); - Ok(()) -} +use crate::types::issue::{display_issues_json, display_issues_pretty}; pub async fn list(json: bool) -> Result<(), Box> { let config = JiraConfig::load().map_err(|e| format!("Configuration error: {}", e))?; @@ -117,7 +7,9 @@ pub async fn list(json: bool) -> Result<(), Box> { println!("Fetching issues assigned..."); } - match list_jira_issues(&config).await { + let jql = "assignee = currentUser() AND resolution = Unresolved order by updated DESC"; + + match crate::jql::run(&config, jql).await { Ok(response) => { if json { if response.issues.is_empty() { diff --git a/src/cmd/search.rs b/src/cmd/search.rs new file mode 100644 index 0000000..1a4bef1 --- /dev/null +++ b/src/cmd/search.rs @@ -0,0 +1,32 @@ +use crate::jira_config::JiraConfig; +use crate::types::issue::{display_issues_json, display_issues_pretty}; + +pub async fn exec(json: bool, jql: String) -> Result<(), Box> { + let config = JiraConfig::load().map_err(|e| format!("Configuration error: {}", e))?; + if !json { + println!("Searching for issues..."); + } + + match crate::jql::run(&config, &jql).await { + Ok(response) => { + if json { + if response.issues.is_empty() { + println!("[]"); + } else { + display_issues_json(&response.issues)?; + } + } else if response.issues.is_empty() { + println!("No results found for query."); + } else { + display_issues_pretty(&response.issues)?; + println!("Total issues: {}", response.total); + } + } + Err(e) => { + eprintln!("Error fetching issues: {}", e); + std::process::exit(1); + } + } + + Ok(()) +} diff --git a/src/jql.rs b/src/jql.rs new file mode 100644 index 0000000..a281f3d --- /dev/null +++ b/src/jql.rs @@ -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, + pub total: u32, +} + +pub async fn run( + config: &JiraConfig, + jql: &str, +) -> Result> { + 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::().await?) +} diff --git a/src/main.rs b/src/main.rs index cc526c8..bedd888 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod cli; mod cmd; mod jira_config; +mod jql; +mod types; use clap::Parser; use cli::{Cli, Commands}; @@ -17,6 +19,7 @@ async fn main() -> Result<(), Box> { markdown_file, } => cmd::create::create(project, open, markdown_file).await?, Commands::List { json } => cmd::list::list(json).await?, + Commands::Search { json, jql } => cmd::search::exec(json, jql).await?, Commands::Init { url, email, token } => { JiraConfig::init(url, email, token).await?; println!("Configuration initialized successfully!"); diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..d93d369 --- /dev/null +++ b/src/types.rs @@ -0,0 +1 @@ +pub mod issue; diff --git a/src/types/issue.rs b/src/types/issue.rs new file mode 100644 index 0000000..8683c1c --- /dev/null +++ b/src/types/issue.rs @@ -0,0 +1,129 @@ +use crossterm::style::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 status: Status, + pub created: chrono::DateTime, + pub priority: Priority, + pub assignee: Person, + pub reporter: Person, + pub creator: Person, + #[serde(rename = "duedate")] + pub due_date: Option, + pub comment: Option, + 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, 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, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Comments { + #[serde(rename = "self")] + pub href: String, + pub total: u32, + #[serde(rename = "maxResults")] + pub max_results: u32, + #[serde(rename = "startAt")] + pub start_at: u32, + pub comments: Vec, +} + +#[derive(Debug, 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, +} + +#[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> { + 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, + "\u{1b}]8;;{}\u{7}{}\u{1b}]8;;\u{7}", + issue.href, + "Open Issue".green() + )?; + + tw.flush().unwrap(); + + let written = String::from_utf8(tw.into_inner().unwrap()).unwrap(); + print!("{}", written); + println!("{:-<80}", ""); + } + + Ok(()) +} + +pub fn display_issues_json(issues: &[JiraIssue]) -> Result<(), Box> { + let j = serde_json::to_string_pretty(issues)?; + println!("{}", j); + Ok(()) +}