Migrate project to a workspace
This commit is contained in:
parent
7af1f40188
commit
e20f9e6a9d
13 changed files with 24 additions and 5 deletions
20
crates/jirac/Cargo.toml
Normal file
20
crates/jirac/Cargo.toml
Normal file
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "jirac"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
config = { workspace = true }
|
||||
directories = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
gray_matter = { workspace = true }
|
||||
open = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tabwriter = { workspace = true }
|
||||
crossterm = { workspace = true }
|
81
crates/jirac/src/cli.rs
Normal file
81
crates/jirac/src/cli.rs
Normal file
|
@ -0,0 +1,81 @@
|
|||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(ValueEnum, Debug, Copy, Clone, Eq, PartialEq)]
|
||||
pub enum FormatMode {
|
||||
Pretty,
|
||||
Json,
|
||||
Compact,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FormatMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.to_possible_value()
|
||||
.expect("no values are skipped")
|
||||
.get_name()
|
||||
.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Create an issue
|
||||
Create {
|
||||
/// The project key in which to create the issue
|
||||
#[arg(short, long)]
|
||||
project: Option<String>,
|
||||
|
||||
/// Open the new issue in a browser
|
||||
#[arg(long)]
|
||||
open: bool,
|
||||
|
||||
/// A Markdown file
|
||||
#[arg(value_name = "MARKDOWN_FILE")]
|
||||
markdown_file: Option<PathBuf>,
|
||||
},
|
||||
/// Find issues currently assigned to you
|
||||
List {
|
||||
/// Pick an output formatter
|
||||
#[arg(short, long, default_value_t = FormatMode::Pretty)]
|
||||
output: FormatMode,
|
||||
},
|
||||
/// Search for issues
|
||||
Search {
|
||||
/// Pick an output formatter
|
||||
#[arg(short, long, default_value_t = FormatMode::Pretty)]
|
||||
output: FormatMode,
|
||||
|
||||
/// A JQL string
|
||||
#[arg(value_name = "JQL")]
|
||||
jql: String,
|
||||
},
|
||||
/// View an issue
|
||||
View {
|
||||
/// Pick an output formatter
|
||||
#[arg(short, long, default_value_t = FormatMode::Pretty)]
|
||||
output: FormatMode,
|
||||
|
||||
/// An issue key, for example, KEY-123
|
||||
#[arg(value_name = "ISSUE")]
|
||||
issue: String,
|
||||
},
|
||||
/// Set up the configuration
|
||||
Init {
|
||||
/// Jira instance URL
|
||||
#[arg(long)]
|
||||
url: String,
|
||||
/// User email
|
||||
#[arg(long)]
|
||||
email: String,
|
||||
/// User Token
|
||||
#[arg(long)]
|
||||
token: String,
|
||||
},
|
||||
}
|
3
crates/jirac/src/cmd.rs
Normal file
3
crates/jirac/src/cmd.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod create;
|
||||
pub mod search;
|
||||
pub mod view;
|
410
crates/jirac/src/cmd/create.rs
Normal file
410
crates/jirac/src/cmd/create.rs
Normal file
|
@ -0,0 +1,410 @@
|
|||
use crate::jira_config::JiraConfig;
|
||||
use gray_matter::engine::TOML;
|
||||
use gray_matter::Matter;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct IssueMetadata {
|
||||
#[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)]
|
||||
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>> {
|
||||
let matter = toml::to_string(issue_metadata)?;
|
||||
let has_project = issue_metadata.project.is_some();
|
||||
|
||||
let template = format!(
|
||||
r#"---
|
||||
# The issue 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 {
|
||||
std::env::var("EDITOR")
|
||||
.or_else(|_| std::env::var("VISUAL"))
|
||||
.unwrap_or_else(|_| "vi".to_string())
|
||||
}
|
||||
|
||||
fn parse_markdown(
|
||||
content: &str,
|
||||
) -> Result<(String, String, IssueMetadata), Box<dyn std::error::Error>> {
|
||||
let matter = Matter::<TOML>::new();
|
||||
let result = matter.parse_with_struct::<IssueMetadata>(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(|| IssueMetadata {
|
||||
// status: None,
|
||||
// project: None,
|
||||
// assignee: None,
|
||||
// parent: None,
|
||||
// extra: HashMap::new(),
|
||||
// });
|
||||
//
|
||||
// let content = result.content;
|
||||
let mut lines = content.lines();
|
||||
|
||||
// Get first non-empty line as title
|
||||
let title = lines
|
||||
.by_ref()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('#')
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Rest is description
|
||||
let description = lines.collect::<Vec<&str>>().join("\n");
|
||||
|
||||
Ok((title, description, metadata))
|
||||
}
|
||||
|
||||
fn create_temp_markdown(project_key: Option<String>) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut temp_file = NamedTempFile::new()?;
|
||||
|
||||
let metadata = IssueMetadata {
|
||||
status: None,
|
||||
project: project_key,
|
||||
assignee: None,
|
||||
parent: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
|
||||
let template = get_issue_template(&metadata)?;
|
||||
|
||||
temp_file.write_all(template.as_bytes())?;
|
||||
temp_file.flush()?;
|
||||
|
||||
let editor = get_editor();
|
||||
let status = std::process::Command::new(editor)
|
||||
.arg(temp_file.path())
|
||||
.status()?;
|
||||
|
||||
if !status.success() {
|
||||
return Err("Editor exited with non-zero status".into());
|
||||
}
|
||||
|
||||
// Read the modified content
|
||||
let content = fs::read_to_string(temp_file.path())?;
|
||||
|
||||
// temp_file will be automatically deleted when it goes out of scope
|
||||
Ok(content)
|
||||
}
|
||||
async fn create_jira_issue(
|
||||
config: &JiraConfig,
|
||||
project_key: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
metadata: &IssueMetadata,
|
||||
) -> Result<JiraResponse, 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 mut fields = JiraIssueFields {
|
||||
project: Project {
|
||||
key: project_key.to_string(),
|
||||
},
|
||||
summary: title.to_string(),
|
||||
description: description.to_string(),
|
||||
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(JiraIssueUpdate {
|
||||
parent: Some(vec![ParentUpdate {
|
||||
add: Parent {
|
||||
key: parent_key.clone(),
|
||||
},
|
||||
}]),
|
||||
});
|
||||
}
|
||||
|
||||
let issue = JiraIssueRequest { fields, update };
|
||||
|
||||
// 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?;
|
||||
}
|
||||
|
||||
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(
|
||||
project: Option<String>,
|
||||
open: bool,
|
||||
markdown_file: Option<PathBuf>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = JiraConfig::load().map_err(|e| format!("Configuration error: {}", e))?;
|
||||
|
||||
let content = match markdown_file {
|
||||
Some(path) => {
|
||||
fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e))?
|
||||
}
|
||||
None => {
|
||||
eprintln!("No markdown file specified. Opening editor...");
|
||||
create_temp_markdown(project)?
|
||||
}
|
||||
};
|
||||
|
||||
if content.trim().is_empty() {
|
||||
return Err("Empty content. Aborting issue creation.".into());
|
||||
}
|
||||
|
||||
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
|
||||
println!("\nAbout to create an issue:");
|
||||
println!("Project: {}", selected_project);
|
||||
if let Some(status) = &metadata.status {
|
||||
println!("Status: {}", status);
|
||||
}
|
||||
if let Some(assignee) = &metadata.assignee {
|
||||
println!("Assignee: {}", assignee);
|
||||
}
|
||||
if let Some(parent) = &metadata.parent {
|
||||
println!("Parent: {}", parent);
|
||||
}
|
||||
println!("Title: {}", title);
|
||||
|
||||
println!("Description:\n{}", description);
|
||||
println!("\nPress Enter to continue or Ctrl+C to abort");
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
let response =
|
||||
create_jira_issue(&config, &selected_project, &title, &description, &metadata).await?;
|
||||
|
||||
let url = format!("{}/browse/{}", config.url, response.key);
|
||||
|
||||
println!("Successfully created issue: {}", response.key);
|
||||
println!("URL: {}", url);
|
||||
|
||||
if open {
|
||||
open::that(url)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
36
crates/jirac/src/cmd/search.rs
Normal file
36
crates/jirac/src/cmd/search.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
use crate::cli::FormatMode;
|
||||
use crate::jira_config::JiraConfig;
|
||||
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))?;
|
||||
if output != FormatMode::Json {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
match (output, result.issues.is_empty()) {
|
||||
(FormatMode::Pretty, false) => {
|
||||
display_issues_pretty(&result.issues)?;
|
||||
println!("Total issues: {}", result.total);
|
||||
}
|
||||
(FormatMode::Pretty, true) => {
|
||||
println!("No results found for query.");
|
||||
}
|
||||
(FormatMode::Json, false) => display_issues_json(&result.issues)?,
|
||||
(FormatMode::Json, true) => println!("[]"),
|
||||
(FormatMode::Compact, false) => display_issues_compact(&result.issues)?,
|
||||
(FormatMode::Compact, true) => {
|
||||
println!("No results found for query.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
152
crates/jirac/src/cmd/view.rs
Normal file
152
crates/jirac/src/cmd/view.rs
Normal file
|
@ -0,0 +1,152 @@
|
|||
use crate::cli::FormatMode;
|
||||
use crate::jira_config::JiraConfig;
|
||||
use crate::term::hyperlink;
|
||||
use crate::types::issue::JiraIssue;
|
||||
use crossterm::style::{Color, Stylize};
|
||||
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!(
|
||||
"{}: {}",
|
||||
issue.key.clone().green(),
|
||||
issue.fields.summary.clone().bold().green()
|
||||
);
|
||||
|
||||
println!("\n== Details {:=<69}", "");
|
||||
let mut tw = tabwriter::TabWriter::new(vec![]);
|
||||
writeln!(tw, "{}:\t{}", "Priority".blue(), issue.fields.priority.name)?;
|
||||
writeln!(tw, "{}:\t{}", "Status".blue(), issue.fields.status.name)?;
|
||||
writeln!(
|
||||
tw,
|
||||
"{}:\t{} <{}>",
|
||||
"Assignee".blue(),
|
||||
issue.fields.assignee.display_name,
|
||||
issue
|
||||
.fields
|
||||
.assignee
|
||||
.email_address
|
||||
.clone()
|
||||
.unwrap_or("invalid@example.com".to_string())
|
||||
)?;
|
||||
writeln!(
|
||||
tw,
|
||||
"{}:\t{} <{}>",
|
||||
"Reporter".blue(),
|
||||
issue.fields.reporter.display_name,
|
||||
issue
|
||||
.fields
|
||||
.reporter
|
||||
.email_address
|
||||
.clone()
|
||||
.unwrap_or("invalid@example.com".to_string())
|
||||
)?;
|
||||
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(),
|
||||
}
|
||||
)?;
|
||||
|
||||
tw.flush().unwrap();
|
||||
|
||||
let written = String::from_utf8(tw.into_inner().unwrap()).unwrap();
|
||||
print!("{}", written);
|
||||
println!("\n== Description {:=<65}", "");
|
||||
match issue.fields.description.clone() {
|
||||
Some(x) => println!("{}", x),
|
||||
None => println!("(Issue does not have a description)"),
|
||||
}
|
||||
|
||||
println!("\n== Comments {:=<68}", "");
|
||||
for comment in issue.fields.comment.clone().unwrap_or_default().comments {
|
||||
println!(
|
||||
"{} at {}",
|
||||
comment.author.display_name.red(),
|
||||
comment.created.with_timezone(&chrono::Local)
|
||||
);
|
||||
println!("{}", comment.body);
|
||||
}
|
||||
println!("\n== Actions {:=<69}", "");
|
||||
println!(
|
||||
"{}",
|
||||
hyperlink(
|
||||
&issue.href,
|
||||
&"Open Issue".green().underline(Color::Green).to_string()
|
||||
)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn json_print(issues: &JiraIssue) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let j = serde_json::to_string_pretty(issues)?;
|
||||
println!("{}", j);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn exec(output: FormatMode, issue_key: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = JiraConfig::load().map_err(|e| format!("Configuration error: {}", e))?;
|
||||
if output != FormatMode::Json {
|
||||
println!("Loading issue data");
|
||||
}
|
||||
|
||||
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 matched_issue = match matched_issues.issues.first() {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
eprintln!("No issue found with that key");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let fetched_issue = fetch_issue(&config, &matched_issue.href).await?;
|
||||
|
||||
match output {
|
||||
FormatMode::Pretty => pretty_print(&fetched_issue)?,
|
||||
FormatMode::Json => json_print(&fetched_issue)?,
|
||||
FormatMode::Compact => todo!(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
68
crates/jirac/src/jira_config.rs
Normal file
68
crates/jirac/src/jira_config.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
use config::{Config, ConfigError, File};
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct JiraConfig {
|
||||
pub url: String,
|
||||
pub email: String,
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
impl JiraConfig {
|
||||
pub fn load() -> Result<Self, ConfigError> {
|
||||
let config_path = JiraConfig::get_config_path()?;
|
||||
|
||||
if !config_path.exists() {
|
||||
return Err(ConfigError::NotFound(
|
||||
"Config file not found. Run 'init' command first.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let settings = Config::builder()
|
||||
.add_source(File::with_name(config_path.to_str().unwrap()))
|
||||
.build()?;
|
||||
|
||||
settings.try_deserialize()
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config_path = JiraConfig::get_config_path()?;
|
||||
|
||||
// Ensure config directory exists
|
||||
if let Some(parent) = config_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let toml = toml::to_string(self)?;
|
||||
fs::write(&config_path, toml)?;
|
||||
|
||||
println!("Configuration saved to: {}", config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_config_path() -> Result<PathBuf, ConfigError> {
|
||||
let proj_dirs = ProjectDirs::from("com", "runebaas", "jirac").ok_or_else(|| {
|
||||
ConfigError::NotFound("Could not determine config directory".to_string())
|
||||
})?;
|
||||
|
||||
Ok(proj_dirs.config_dir().join("config.toml"))
|
||||
}
|
||||
|
||||
pub async fn init(
|
||||
url: String,
|
||||
email: String,
|
||||
token: String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = JiraConfig {
|
||||
url,
|
||||
email,
|
||||
api_token: token,
|
||||
};
|
||||
|
||||
config.save()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
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?)
|
||||
}
|
36
crates/jirac/src/main.rs
Normal file
36
crates/jirac/src/main.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
mod cli;
|
||||
mod cmd;
|
||||
mod jira_config;
|
||||
mod jql;
|
||||
mod term;
|
||||
mod types;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Commands};
|
||||
use jira_config::JiraConfig;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Create {
|
||||
project,
|
||||
open,
|
||||
markdown_file,
|
||||
} => cmd::create::create(project, open, markdown_file).await?,
|
||||
Commands::List { output } => {
|
||||
let jql =
|
||||
"assignee = currentUser() AND resolution = Unresolved order by project,updated ASC";
|
||||
cmd::search::exec(output, jql).await?
|
||||
}
|
||||
Commands::Search { output, jql } => cmd::search::exec(output, &jql).await?,
|
||||
Commands::View { output, issue } => cmd::view::exec(output, &issue).await?,
|
||||
Commands::Init { url, email, token } => {
|
||||
JiraConfig::init(url, email, token).await?;
|
||||
println!("Configuration initialized successfully!");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
3
crates/jirac/src/term.rs
Normal file
3
crates/jirac/src/term.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub fn hyperlink(url: &str, text: &str) -> String {
|
||||
format!("\u{1b}]8;;{}\u{7}{}\u{1b}]8;;\u{7}", url, text)
|
||||
}
|
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(())
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue