Compare commits

..

5 commits

11 changed files with 325 additions and 143 deletions

87
Cargo.lock generated
View file

@ -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"

View file

@ -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"
tabwriter = "1.4"
crossterm = "0.28"

View file

@ -24,9 +24,10 @@ cargo install jirac
Usage: jirac <COMMAND>
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] <JQL>
Arguments:
<JQL> 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

View file

@ -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<String>,
/// 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<PathBuf>,
},
/// 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)]

View file

@ -1,2 +1,3 @@
pub mod create;
pub mod list;
pub mod search;

View file

@ -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<chrono::Utc>,
priority: Priority,
assignee: Person,
reporter: Person,
creator: Person,
#[serde(rename = "duedate")]
due_date: Option<chrono::NaiveDate>,
}
#[derive(Deserialize)]
struct JiraSearchResponse {
issues: Vec<JiraIssue>,
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<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"));
// 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::<JiraSearchResponse>().await?)
}
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)?;
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let config = JiraConfig::load().map_err(|e| format!("Configuration error: {}", e))?;
@ -117,7 +7,9 @@ pub async fn list(json: bool) -> Result<(), Box<dyn std::error::Error>> {
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() {

32
src/cmd/search.rs Normal file
View file

@ -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<dyn std::error::Error>> {
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(())
}

37
src/jql.rs Normal file
View 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?)
}

View file

@ -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<dyn std::error::Error>> {
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!");

1
src/types.rs Normal file
View file

@ -0,0 +1 @@
pub mod issue;

129
src/types/issue.rs Normal file
View file

@ -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<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, 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, 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<Comment>,
}
#[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<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,
"\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<dyn std::error::Error>> {
let j = serde_json::to_string_pretty(issues)?;
println!("{}", j);
Ok(())
}