Initial commit

This commit is contained in:
Daan Boerlage 2025-01-21 15:55:34 +01:00
commit 3a2a5612c0
Signed by: daan
GPG key ID: FCE070E1E4956606
10 changed files with 2667 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2142
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

16
Cargo.toml Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
pub mod create;
pub mod list;

192
src/cmd/create.rs Normal file
View 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
View 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
View 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
View 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(())
}