use async_trait::async_trait;
use jira_v3_openapi::additional_apis::issue_attachments_api::add_attachment;
use jira_v3_openapi::apis::issue_search_api::search_and_reconsile_issues_using_jql_post;
use jira_v3_openapi::apis::issues_api::*;
use jira_v3_openapi::models::user::AccountType;
use jira_v3_openapi::models::{
Attachment, CreatedIssue, IssueBean, IssueTransition, SearchAndReconcileRequestBean,
Transitions, User,
};
use jira_v3_openapi::{apis::configuration::Configuration, models::IssueUpdateDetails};
use serde_json::Value;
use std::collections::HashMap;
use std::io::Error;
use std::path::Path;
#[cfg(test)]
use mockall::automock;
#[cfg(feature = "attachment_scan")]
use crate::config::config_file::YaraSection;
#[cfg(feature = "attachment_scan")]
use crate::utils::cached_scanner::CachedYaraScanner;
use crate::args::commands::TransitionArgs;
use crate::{
args::commands::IssueArgs,
config::config_file::{AuthData, ConfigFile},
};
pub struct IssueCmdRunner {
cfg: Configuration,
#[cfg(feature = "attachment_scan")]
yara_config: YaraSection,
}
impl IssueCmdRunner {
pub fn new(cfg_file: &ConfigFile) -> IssueCmdRunner {
let mut config = Configuration::new();
let auth_data = AuthData::from_base64(cfg_file.get_auth_key());
config.base_path = cfg_file.get_jira_url().to_string();
config.basic_auth = Some((auth_data.0, Some(auth_data.1)));
IssueCmdRunner {
cfg: config,
#[cfg(feature = "attachment_scan")]
yara_config: cfg_file.get_yara_section().clone(),
}
}
pub async fn assign_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Value, Box<dyn std::error::Error>> {
let user_data = User {
account_id: Some(params.assignee.expect("Assignee is required")),
account_type: Some(AccountType::Atlassian),
..Default::default()
};
let i_key = if let Some(key) = ¶ms.issue_key {
key.as_str()
} else {
return Err(Box::new(Error::other(
"Error assigning issue: Empty issue key".to_string(),
)));
};
Ok(assign_issue(&self.cfg, i_key, user_data).await?)
}
pub async fn attach_file_to_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Vec<Attachment>, Box<dyn std::error::Error>> {
let i_key = if let Some(key) = ¶ms.issue_key {
key.as_str()
} else {
return Err(Box::new(Error::other(
"Error attaching file to issue: Empty issue key".to_string(),
)));
};
if let Some(path) = ¶ms.attachment_file_path {
let attachment_bytes = std::fs::read(path)?;
let file_name = Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("attachment")
.to_string();
#[cfg(feature = "attachment_scan")]
let _scan_result = self.scan_bytes(&attachment_bytes).await?;
return Ok(
add_attachment(&self.cfg, i_key, attachment_bytes, file_name.as_str()).await?,
);
} else {
Err(Box::new(Error::other(
"Error attaching file to issue: Empty attachment file path".to_string(),
)))
}
}
#[cfg(feature = "attachment_scan")]
async fn scan_bytes(&self, bytes: &[u8]) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let scanner = CachedYaraScanner::from_config(&self.yara_config).await?;
let scan_result = scanner.scan_buffer(bytes).unwrap_or_else(|e| {
eprintln!("⚠️ YARA scan failed: {}", e);
eprintln!(" Proceeding with attachment upload anyway...");
vec![]
});
if !scan_result.is_empty() {
println!(
"⚠️ Attachment file triggered the following YARA scanner rules: {:?}",
scan_result
);
} else {
println!("✅ No issue found by YARA scanner in the attachment file");
}
Ok(scan_result)
}
#[cfg(all(test, feature = "attachment_scan"))]
async fn scan_bytes_with_base_dir(&self, bytes: &[u8], base_dir: std::path::PathBuf) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let scanner = CachedYaraScanner::from_config_with_base_dir(&self.yara_config, base_dir).await?;
let scan_result = scanner.scan_buffer(bytes).unwrap_or_else(|e| {
eprintln!("⚠️ YARA scan failed: {}", e);
eprintln!(" Proceeding with attachment upload anyway...");
vec![]
});
if !scan_result.is_empty() {
println!(
"⚠️ Attachment file triggered the following YARA scanner rules: {:?}",
scan_result
);
} else {
println!("✅ No issue found by YARA scanner in the attachment file");
}
Ok(scan_result)
}
pub async fn create_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<CreatedIssue, Box<dyn std::error::Error>> {
let mut issue_fields = params.issue_fields.unwrap_or_default();
issue_fields.insert(
"project".to_string(),
serde_json::json!({"key": params.project_key.expect("Project Key is required to create an issue!")}),
);
let issue_data = IssueUpdateDetails {
fields: Some(issue_fields),
history_metadata: None,
properties: None,
transition: None,
update: None,
};
Ok(create_issue(&self.cfg, issue_data, None).await?)
}
pub async fn delete_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<(), Box<dyn std::error::Error>> {
let i_key = if let Some(key) = ¶ms.issue_key {
key.as_str()
} else {
return Err(Box::new(Error::other(
"Error deleting issue: Empty issue key".to_string(),
)));
};
Ok(delete_issue(&self.cfg, i_key, Some("true")).await?)
}
pub async fn get_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<IssueBean, Box<dyn std::error::Error>> {
let i_key = if let Some(key) = ¶ms.issue_key {
key.as_str()
} else {
return Err(Box::new(Error::other(
"Error retrieving issue: Empty issue key".to_string(),
)));
};
Ok(get_issue(&self.cfg, i_key, None, None, None, None, None, None).await?)
}
pub async fn search_jira_issues(
&self,
params: IssueCmdParams,
) -> Result<Vec<IssueBean>, Box<dyn std::error::Error>> {
let search_params: SearchAndReconcileRequestBean = SearchAndReconcileRequestBean {
fields: Some(vec!["*navigable".to_string(), "-comment".to_string()]),
jql: params.query,
..Default::default()
};
match search_and_reconsile_issues_using_jql_post(&self.cfg, search_params).await {
Ok(result) => {
if let Some(issues) = result.issues {
Ok(issues)
} else {
Ok(vec![])
}
}
Err(e) => Err(Box::new(e)),
}
}
pub async fn transition_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Value, Box<dyn std::error::Error>> {
let i_key = if let Some(key) = ¶ms.issue_key {
key.as_str()
} else {
return Err(Box::new(Error::other(
"Error with issue transition: Empty issue key".to_string(),
)));
};
let trans = if let Some(transition) = ¶ms.transition {
transition.as_str()
} else {
return Err(Box::new(Error::other(
"Error with issue transition: Empty transition".to_string(),
)));
};
let transition = IssueTransition {
id: Some(trans.to_string()),
..Default::default()
};
let issue_data = IssueUpdateDetails {
fields: params.issue_fields,
history_metadata: None,
properties: None,
transition: Some(transition),
update: None,
};
Ok(do_transition(&self.cfg, i_key, issue_data).await?)
}
pub async fn update_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Value, Box<dyn std::error::Error>> {
let i_key = if let Some(key) = ¶ms.issue_key {
key.as_str()
} else {
return Err(Box::new(Error::other(
"Error updating issue: Empty issue key".to_string(),
)));
};
let issue_data = IssueUpdateDetails {
fields: params.issue_fields,
history_metadata: None,
properties: None,
transition: None,
update: None,
};
Ok(edit_issue(
&self.cfg,
i_key,
issue_data,
None,
None,
None,
Some(true),
None,
)
.await?)
}
pub async fn get_issue_available_transitions(
&self,
params: IssueTransitionCmdParams,
) -> Result<Transitions, Box<dyn std::error::Error>> {
Ok(get_transitions(
&self.cfg,
¶ms.issue_key,
None,
None,
None,
Some(false),
None,
)
.await?)
}
}
pub struct IssueCmdParams {
pub project_key: Option<String>,
pub issue_key: Option<String>,
pub issue_fields: Option<HashMap<String, Value>>,
pub transition: Option<String>,
pub assignee: Option<String>,
pub query: Option<String>,
pub attachment_file_path: Option<String>,
}
impl IssueCmdParams {
pub fn new() -> IssueCmdParams {
IssueCmdParams {
project_key: Some("".to_string()),
issue_key: None,
issue_fields: None,
transition: None,
assignee: None,
query: None,
attachment_file_path: None,
}
}
}
impl From<&IssueArgs> for IssueCmdParams {
fn from(value: &IssueArgs) -> Self {
IssueCmdParams {
project_key: value.project_key.clone(),
issue_key: value.issue_key.clone(),
issue_fields: Some(
value
.issue_fields
.clone()
.unwrap_or_default()
.iter()
.map(|elem| {
(
elem.0.clone(),
serde_json::from_str(elem.1.clone().as_str()).unwrap_or(Value::Null),
)
})
.collect::<HashMap<_, _>>(),
),
transition: value.transition_to.clone(),
assignee: value.assignee.clone(),
query: value.query.clone(),
attachment_file_path: value.attachment_file_path.clone(),
}
}
}
pub struct IssueTransitionCmdParams {
pub issue_key: String,
}
impl IssueTransitionCmdParams {
pub fn new() -> IssueTransitionCmdParams {
IssueTransitionCmdParams {
issue_key: "".to_string(),
}
}
}
impl From<&TransitionArgs> for IssueTransitionCmdParams {
fn from(value: &TransitionArgs) -> Self {
IssueTransitionCmdParams {
issue_key: value.issue_key.clone(),
}
}
}
impl Default for IssueTransitionCmdParams {
fn default() -> Self {
IssueTransitionCmdParams::new()
}
}
impl Default for IssueCmdParams {
fn default() -> Self {
IssueCmdParams::new()
}
}
#[cfg_attr(test, automock)]
#[async_trait(?Send)]
pub trait IssueCmdRunnerApi: Send + Sync {
async fn assign_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Value, Box<dyn std::error::Error>>;
async fn attach_file_to_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Vec<Attachment>, Box<dyn std::error::Error>>;
async fn create_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<CreatedIssue, Box<dyn std::error::Error>>;
async fn delete_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<(), Box<dyn std::error::Error>>;
async fn get_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<IssueBean, Box<dyn std::error::Error>>;
async fn search_jira_issues(
&self,
params: IssueCmdParams,
) -> Result<Vec<IssueBean>, Box<dyn std::error::Error>>;
async fn transition_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Value, Box<dyn std::error::Error>>;
async fn update_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Value, Box<dyn std::error::Error>>;
async fn get_issue_available_transitions(
&self,
params: IssueTransitionCmdParams,
) -> Result<Transitions, Box<dyn std::error::Error>>;
}
#[async_trait(?Send)]
impl IssueCmdRunnerApi for IssueCmdRunner {
async fn assign_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Value, Box<dyn std::error::Error>> {
IssueCmdRunner::assign_jira_issue(self, params).await
}
async fn attach_file_to_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Vec<Attachment>, Box<dyn std::error::Error>> {
IssueCmdRunner::attach_file_to_jira_issue(self, params).await
}
async fn create_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<CreatedIssue, Box<dyn std::error::Error>> {
IssueCmdRunner::create_jira_issue(self, params).await
}
async fn delete_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<(), Box<dyn std::error::Error>> {
IssueCmdRunner::delete_jira_issue(self, params).await
}
async fn get_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<IssueBean, Box<dyn std::error::Error>> {
IssueCmdRunner::get_jira_issue(self, params).await
}
async fn search_jira_issues(
&self,
params: IssueCmdParams,
) -> Result<Vec<IssueBean>, Box<dyn std::error::Error>> {
IssueCmdRunner::search_jira_issues(self, params).await
}
async fn transition_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Value, Box<dyn std::error::Error>> {
IssueCmdRunner::transition_jira_issue(self, params).await
}
async fn update_jira_issue(
&self,
params: IssueCmdParams,
) -> Result<Value, Box<dyn std::error::Error>> {
IssueCmdRunner::update_jira_issue(self, params).await
}
async fn get_issue_available_transitions(
&self,
params: IssueTransitionCmdParams,
) -> Result<Transitions, Box<dyn std::error::Error>> {
IssueCmdRunner::get_issue_available_transitions(self, params).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::config_file::{ConfigFile, YaraSection};
use std::sync::Mutex;
use std::{env, fs};
use tempfile::tempdir;
use toml::Table;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
fn basic_config() -> ConfigFile {
ConfigFile::new(
"dGVzdDp0b2tlbg==".to_string(),
"https://example.atlassian.net".to_string(),
"Done".to_string(),
"Completed".to_string(),
Table::new(),
YaraSection::default(),
)
}
#[tokio::test]
async fn attach_without_issue_key_returns_error() {
let runner = IssueCmdRunner::new(&basic_config());
let mut params = IssueCmdParams::new();
params.attachment_file_path = Some("/tmp/file.txt".to_string());
let result = runner.attach_file_to_jira_issue(params).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Empty issue key"));
}
#[tokio::test]
async fn attach_without_file_path_returns_error() {
let runner = IssueCmdRunner::new(&basic_config());
let mut params = IssueCmdParams::new();
params.issue_key = Some("TEST-123".to_string());
let result = runner.attach_file_to_jira_issue(params).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Empty attachment file path")
);
}
#[cfg(feature = "attachment_scan")]
#[tokio::test]
async fn scan_bytes_uses_local_rules() {
let _guard = ENV_MUTEX.lock().unwrap();
let temp_home = tempdir().expect("temp HOME");
let base_dir = temp_home.path().join(".jirust-cli");
let rules_dir = base_dir.join("rules");
fs::create_dir_all(&rules_dir).expect("create rules dir");
fs::write(rules_dir.join(".version"), "v1").expect("write version marker");
fs::write(
rules_dir.join("test_rule.yar"),
r#"
rule TestRule {
strings:
$a = "hitme"
condition:
$a
}
"#,
)
.expect("write yara rule");
let config = ConfigFile::new(
"dGVzdDp0b2tlbg==".to_string(),
"https://example.atlassian.net".to_string(),
"Done".to_string(),
"Completed".to_string(),
Table::new(),
YaraSection::new(
"local_rules.zip".to_string(),
"rules".to_string(),
"yara_rules.cache".to_string(),
"yara_rules.cache.version".to_string(),
),
);
let runner = IssueCmdRunner::new(&config);
let matches = runner
.scan_bytes_with_base_dir(b"hitme", base_dir.clone())
.await
.expect("scan should succeed");
assert!(matches.contains(&"TestRule".to_string()));
assert!(base_dir.join("yara_rules.cache").exists());
assert!(base_dir.join("yara_rules.cache.version").exists());
}
}