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