Initial commit
This commit is contained in:
commit
3a2a5612c0
10 changed files with 2667 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
2142
Cargo.lock
generated
Normal file
2142
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
[package]
|
||||||
|
name = "jirac"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
|
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"
|
||||||
|
tempfile = "3.8"
|
99
readme.md
Normal file
99
readme.md
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
# Jirac
|
||||||
|
|
||||||
|
A CLI for creating and managing Jira tickets directly from your terminal.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Create Jira tickets from markdown files or using your preferred editor
|
||||||
|
- List tickets assigned to specific users
|
||||||
|
- Simple configuration using TOML
|
||||||
|
- Interactive ticket creation with templates
|
||||||
|
- Execute JQL
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### from crates.io
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo install jirac
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: jirac <COMMAND>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
create
|
||||||
|
list
|
||||||
|
init
|
||||||
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help Print help
|
||||||
|
-V, --version Print version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating a ticket
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: jirac create --project <PROJECT> [MARKDOWN_FILE]
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
[MARKDOWN_FILE]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--project <PROJECT>
|
||||||
|
-h, --help Print help
|
||||||
|
```
|
||||||
|
|
||||||
|
*Using your favourite editor*
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jirac create --project KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
*From a markdown file*
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jirac create --project KEY ticket.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Listing tickets
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: jirac list
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help Print help
|
||||||
|
```
|
||||||
|
|
||||||
|
For now this only lists tickets assigned to the calling user.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Get the following information:
|
||||||
|
|
||||||
|
* Jira instance URL
|
||||||
|
* You email
|
||||||
|
* [An API key](https://id.atlassian.com/manage-profile/security/api-tokens)
|
||||||
|
|
||||||
|
Then run the the `jirac init` command
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: jirac init --url <URL> --email <EMAIL> --token <TOKEN>
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--url <URL>
|
||||||
|
--email <EMAIL>
|
||||||
|
--token <TOKEN>
|
||||||
|
-h, --help Print help
|
||||||
|
```
|
||||||
|
|
||||||
|
The config file is stored at:
|
||||||
|
|
||||||
|
| OS | Location |
|
||||||
|
|---------|----------------------------------------------------------------|
|
||||||
|
| Windows | `~/.config/jirac/config.toml` |
|
||||||
|
| MacOS | `~/Library/Application Support/com.runebaas.jirac/config.toml` |
|
||||||
|
| Linux | `%APPDATA%\runebaas\jirac\config\config.toml` |
|
29
src/cli.rs
Normal file
29
src/cli.rs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
Create {
|
||||||
|
#[arg(long)]
|
||||||
|
project: String,
|
||||||
|
|
||||||
|
#[arg(value_name = "MARKDOWN_FILE")]
|
||||||
|
markdown_file: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
List,
|
||||||
|
Init {
|
||||||
|
#[arg(long)]
|
||||||
|
url: String,
|
||||||
|
#[arg(long)]
|
||||||
|
email: String,
|
||||||
|
#[arg(long)]
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
|
}
|
2
src/cmd.rs
Normal file
2
src/cmd.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod create;
|
||||||
|
pub mod list;
|
192
src/cmd/create.rs
Normal file
192
src/cmd/create.rs
Normal file
|
@ -0,0 +1,192 @@
|
||||||
|
use crate::jira_config::JiraConfig;
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct JiraIssueRequest {
|
||||||
|
fields: JiraIssueFields,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct JiraIssueFields {
|
||||||
|
project: Project,
|
||||||
|
summary: String,
|
||||||
|
description: String,
|
||||||
|
issuetype: IssueType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct Project {
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
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(Deserialize)]
|
||||||
|
struct Status {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
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) -> (String, String) {
|
||||||
|
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");
|
||||||
|
|
||||||
|
(title, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_temp_markdown() -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let mut temp_file = NamedTempFile::new()?;
|
||||||
|
|
||||||
|
let template = r#"# "#;
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> 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 issue = JiraIssueRequest {
|
||||||
|
fields: JiraIssueFields {
|
||||||
|
project: Project {
|
||||||
|
key: project_key.to_string(),
|
||||||
|
},
|
||||||
|
summary: title.to_string(),
|
||||||
|
description: description.to_string(),
|
||||||
|
issuetype: IssueType {
|
||||||
|
name: "Task".to_string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{:#?}", issue);
|
||||||
|
|
||||||
|
Ok(JiraResponse {
|
||||||
|
key: "123".to_string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Ok(response.json::<JiraResponse>().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
project: String,
|
||||||
|
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 => {
|
||||||
|
println!("No markdown file specified. Opening editor...");
|
||||||
|
create_temp_markdown()?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return Err("Empty content. Aborting ticket creation.".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (title, description) = parse_markdown(&content);
|
||||||
|
|
||||||
|
// Confirm creation
|
||||||
|
println!("\nAbout to create ticket:");
|
||||||
|
println!("Title: {}", title);
|
||||||
|
println!("{}...", &description.chars().take(100).collect::<String>());
|
||||||
|
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, &project, &title, &description).await?;
|
||||||
|
|
||||||
|
println!("Successfully created ticket: {}", response.key);
|
||||||
|
println!("URL: {}/browse/{}", config.url, response.key);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
92
src/cmd/list.rs
Normal file
92
src/cmd/list.rs
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
use crate::jira_config::JiraConfig;
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JiraIssue {
|
||||||
|
key: String,
|
||||||
|
fields: JiraIssueResponseFields,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JiraIssueResponseFields {
|
||||||
|
summary: String,
|
||||||
|
status: Status,
|
||||||
|
created: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JiraSearchResponse {
|
||||||
|
issues: Vec<JiraIssue>,
|
||||||
|
total: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Status {
|
||||||
|
name: 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(issues: &[JiraIssue], jira_url: &str) {
|
||||||
|
println!("Found {} issues:", issues.len());
|
||||||
|
println!("{:-<80}", "");
|
||||||
|
|
||||||
|
for issue in issues {
|
||||||
|
println!("Key: {}", issue.key);
|
||||||
|
println!("Summary: {}", issue.fields.summary);
|
||||||
|
println!("Status: {}", issue.fields.status.name);
|
||||||
|
println!("Created: {}", issue.fields.created);
|
||||||
|
println!("URL: {}/browse/{}", jira_url, issue.key);
|
||||||
|
println!("{:-<80}", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let config = JiraConfig::load().map_err(|e| format!("Configuration error: {}", e))?;
|
||||||
|
println!("Fetching issues assigned...");
|
||||||
|
|
||||||
|
match list_jira_issues(&config).await {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.issues.is_empty() {
|
||||||
|
println!("No open issues found for assigned to you");
|
||||||
|
} else {
|
||||||
|
display_issues(&response.issues, &config.url);
|
||||||
|
println!("Total issues: {}", response.total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error fetching issues: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
68
src/jira_config.rs
Normal file
68
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(())
|
||||||
|
}
|
||||||
|
}
|
26
src/main.rs
Normal file
26
src/main.rs
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
mod cli;
|
||||||
|
mod cmd;
|
||||||
|
mod jira_config;
|
||||||
|
|
||||||
|
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,
|
||||||
|
markdown_file,
|
||||||
|
} => cmd::create::create(project, markdown_file).await?,
|
||||||
|
Commands::List => cmd::list::list().await?,
|
||||||
|
Commands::Init { url, email, token } => {
|
||||||
|
JiraConfig::init(url, email, token).await?;
|
||||||
|
println!("Configuration initialized successfully!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
Loading…
Reference in a new issue