Migrate project to a workspace

This commit is contained in:
Daan Boerlage 2025-01-21 22:34:37 +01:00
parent 7af1f40188
commit e20f9e6a9d
Signed by: daan
GPG key ID: FCE070E1E4956606
13 changed files with 24 additions and 5 deletions

20
crates/jirac/Cargo.toml Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
pub mod create;
pub mod search;
pub mod view;

View 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(())
}

View 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(())
}

View 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(())
}

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(())
}
}

37
crates/jirac/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?)
}

36
crates/jirac/src/main.rs Normal file
View 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
View 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)
}

View file

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

View 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(())
}