jirust_cli/runners/jira_cmd_runners/issue_cmd_runner.rs
1use async_trait::async_trait;
2use jira_v3_openapi::additional_apis::issue_attachments_api::add_attachment;
3use jira_v3_openapi::apis::issue_search_api::search_and_reconsile_issues_using_jql_post;
4use jira_v3_openapi::apis::issues_api::*;
5use jira_v3_openapi::models::user::AccountType;
6use jira_v3_openapi::models::{
7 Attachment, CreatedIssue, IssueBean, IssueTransition, SearchAndReconcileRequestBean,
8 Transitions, User,
9};
10use jira_v3_openapi::{apis::configuration::Configuration, models::IssueUpdateDetails};
11use serde_json::Value;
12use std::collections::HashMap;
13use std::io::Error;
14use std::path::Path;
15
16#[cfg(test)]
17use mockall::automock;
18
19#[cfg(feature = "attachment_scan")]
20use crate::config::config_file::YaraSection;
21#[cfg(feature = "attachment_scan")]
22use crate::utils::cached_scanner::CachedYaraScanner;
23
24use crate::args::commands::TransitionArgs;
25use crate::{
26 args::commands::IssueArgs,
27 config::config_file::{AuthData, ConfigFile},
28};
29
30/// Issue command runner
31/// This struct is responsible for running the issue command
32/// It uses the Jira API to perform the operations
33///
34/// # Fields
35///
36/// * `cfg` - Configuration object
37pub struct IssueCmdRunner {
38 /// Configuration object
39 cfg: Configuration,
40 #[cfg(feature = "attachment_scan")]
41 yara_config: YaraSection,
42}
43
44/// Implementation of IssueCmdRunner
45///
46/// # Methods
47///
48/// * `new` - Creates a new instance of IssueCmdRunner
49/// * `assign_jira_issue` - Assigns a Jira issue to a user
50/// * `create_jira_issue` - Creates a Jira issue
51/// * `delete_jira_issue` - Deletes a Jira issue
52/// * `get_jira_issue` - Gets a Jira issue
53/// * `transition_jira_issue` - Transitions a Jira issue
54/// * `update_jira_issue` - Updates a Jira issue
55/// * `get_issue_available_transitions` - Gets available transitions for a Jira issue
56impl IssueCmdRunner {
57 /// Creates a new instance of IssueCmdRunner
58 ///
59 /// # Arguments
60 ///
61 /// * `cfg_file` - Configuration file
62 ///
63 /// # Returns
64 ///
65 /// * `IssueCmdRunner` - Instance of IssueCmdRunner
66 ///
67 /// # Examples
68 ///
69 /// ```
70 /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
71 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueCmdRunner;
72 /// use toml::Table;
73 ///
74 /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
75 ///
76 /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
77 /// ```
78 pub fn new(cfg_file: &ConfigFile) -> IssueCmdRunner {
79 let mut config = Configuration::new();
80 let auth_data = AuthData::from_base64(cfg_file.get_auth_key());
81 config.base_path = cfg_file.get_jira_url().to_string();
82 config.basic_auth = Some((auth_data.0, Some(auth_data.1)));
83 IssueCmdRunner {
84 cfg: config,
85 #[cfg(feature = "attachment_scan")]
86 yara_config: cfg_file.get_yara_section().clone(),
87 }
88 }
89
90 /// Assigns a Jira issue to a user
91 ///
92 /// # Arguments
93 ///
94 /// * `params` - Issue command parameters
95 ///
96 /// # Returns
97 ///
98 /// * `Value` - JSON value
99 ///
100 /// # Examples
101 ///
102 /// ```no_run
103 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
104 /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
105 /// use toml::Table;
106 ///
107 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
108 /// # tokio_test::block_on(async {
109 /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
110 /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
111 /// let mut params = IssueCmdParams::new();
112 /// params.assignee = Some("assignee".to_string());
113 ///
114 /// let result = issue_cmd_runner.assign_jira_issue(params).await?;
115 /// # Ok(())
116 /// # })
117 /// # }
118 /// ```
119 pub async fn assign_jira_issue(
120 &self,
121 params: IssueCmdParams,
122 ) -> Result<Value, Box<dyn std::error::Error>> {
123 let user_data = User {
124 account_id: Some(params.assignee.expect("Assignee is required")),
125 account_type: Some(AccountType::Atlassian),
126 ..Default::default()
127 };
128
129 let i_key = if let Some(key) = ¶ms.issue_key {
130 key.as_str()
131 } else {
132 return Err(Box::new(Error::other(
133 "Error assigning issue: Empty issue key".to_string(),
134 )));
135 };
136
137 Ok(assign_issue(&self.cfg, i_key, user_data).await?)
138 }
139
140 /// Attaches a file to a Jira issue
141 ///
142 /// # Arguments
143 ///
144 /// * `params` - Issue command parameters
145 ///
146 /// # Returns
147 ///
148 /// * `Attachment` - Attachment object
149 ///
150 /// # Examples
151 ///
152 /// ```no_run
153 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
154 /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
155 /// use toml::Table;
156 ///
157 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
158 /// # tokio_test::block_on(async {
159 /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
160 /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
161 /// let mut params = IssueCmdParams::new();
162 /// params.issue_key = Some("issue_key".to_string());
163 /// params.attachment_file_path = Some("path/to/file".to_string());
164 ///
165 /// let result = issue_cmd_runner.attach_file_to_jira_issue(params).await?;
166 /// # Ok(())
167 /// # })
168 /// # }
169 /// ```
170 pub async fn attach_file_to_jira_issue(
171 &self,
172 params: IssueCmdParams,
173 ) -> Result<Vec<Attachment>, Box<dyn std::error::Error>> {
174 let i_key = if let Some(key) = ¶ms.issue_key {
175 key.as_str()
176 } else {
177 return Err(Box::new(Error::other(
178 "Error attaching file to issue: Empty issue key".to_string(),
179 )));
180 };
181
182 if let Some(path) = ¶ms.attachment_file_path {
183 let attachment_bytes = std::fs::read(path)?;
184 let file_name = Path::new(path)
185 .file_name()
186 .and_then(|name| name.to_str())
187 .unwrap_or("attachment")
188 .to_string();
189
190 #[cfg(feature = "attachment_scan")]
191 let _scan_result = self.scan_bytes(&attachment_bytes).await?;
192
193 return Ok(
194 add_attachment(&self.cfg, i_key, attachment_bytes, file_name.as_str()).await?,
195 );
196 } else {
197 Err(Box::new(Error::other(
198 "Error attaching file to issue: Empty attachment file path".to_string(),
199 )))
200 }
201 }
202
203 #[cfg(feature = "attachment_scan")]
204 async fn scan_bytes(&self, bytes: &[u8]) -> Result<Vec<String>, Box<dyn std::error::Error>> {
205 let scanner = CachedYaraScanner::from_config(&self.yara_config).await?;
206 let scan_result = scanner.scan_buffer(bytes).unwrap_or_else(|e| {
207 eprintln!("⚠️ YARA scan failed: {}", e);
208 eprintln!(" Proceeding with attachment upload anyway...");
209 vec![]
210 });
211 if !scan_result.is_empty() {
212 println!(
213 "⚠️ Attachment file triggered the following YARA scanner rules: {:?}",
214 scan_result
215 );
216 } else {
217 println!("✅ No issue found by YARA scanner in the attachment file");
218 }
219
220 Ok(scan_result)
221 }
222
223 #[cfg(all(test, feature = "attachment_scan"))]
224 async fn scan_bytes_with_base_dir(&self, bytes: &[u8], base_dir: std::path::PathBuf) -> Result<Vec<String>, Box<dyn std::error::Error>> {
225 let scanner = CachedYaraScanner::from_config_with_base_dir(&self.yara_config, base_dir).await?;
226 let scan_result = scanner.scan_buffer(bytes).unwrap_or_else(|e| {
227 eprintln!("⚠️ YARA scan failed: {}", e);
228 eprintln!(" Proceeding with attachment upload anyway...");
229 vec![]
230 });
231 if !scan_result.is_empty() {
232 println!(
233 "⚠️ Attachment file triggered the following YARA scanner rules: {:?}",
234 scan_result
235 );
236 } else {
237 println!("✅ No issue found by YARA scanner in the attachment file");
238 }
239
240 Ok(scan_result)
241 }
242
243 /// Creates a Jira issue
244 ///
245 /// # Arguments
246 ///
247 /// * `params` - Issue command parameters
248 ///
249 /// # Returns
250 ///
251 /// * `CreatedIssue` - Created issue
252 ///
253 /// # Examples
254 ///
255 /// ```no_run
256 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
257 /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
258 /// use toml::Table;
259 ///
260 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
261 /// # tokio_test::block_on(async {
262 /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
263 /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
264 /// let params = IssueCmdParams::new();
265 ///
266 /// let result = issue_cmd_runner.create_jira_issue(params).await?;
267 /// # Ok(())
268 /// # })
269 /// # }
270 /// ```
271 pub async fn create_jira_issue(
272 &self,
273 params: IssueCmdParams,
274 ) -> Result<CreatedIssue, Box<dyn std::error::Error>> {
275 let mut issue_fields = params.issue_fields.unwrap_or_default();
276 issue_fields.insert(
277 "project".to_string(),
278 serde_json::json!({"key": params.project_key.expect("Project Key is required to create an issue!")}),
279 );
280 let issue_data = IssueUpdateDetails {
281 fields: Some(issue_fields),
282 history_metadata: None,
283 properties: None,
284 transition: None,
285 update: None,
286 };
287 Ok(create_issue(&self.cfg, issue_data, None).await?)
288 }
289
290 /// Deletes a Jira issue
291 ///
292 /// # Arguments
293 ///
294 /// * `params` - Issue command parameters
295 ///
296 /// # Returns
297 ///
298 /// * `()` - Empty tuple
299 ///
300 /// # Examples
301 ///
302 /// ```no_run
303 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
304 /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
305 /// use toml::Table;
306 ///
307 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
308 /// # tokio_test::block_on(async {
309 /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
310 /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
311 /// let mut params = IssueCmdParams::new();
312 /// params.issue_key = Some("issue_key".to_string());
313 ///
314 /// let result = issue_cmd_runner.delete_jira_issue(params).await?;
315 /// # Ok(())
316 /// # })
317 /// # }
318 /// ```
319 pub async fn delete_jira_issue(
320 &self,
321 params: IssueCmdParams,
322 ) -> Result<(), Box<dyn std::error::Error>> {
323 let i_key = if let Some(key) = ¶ms.issue_key {
324 key.as_str()
325 } else {
326 return Err(Box::new(Error::other(
327 "Error deleting issue: Empty issue key".to_string(),
328 )));
329 };
330
331 Ok(delete_issue(&self.cfg, i_key, Some("true")).await?)
332 }
333
334 /// Gets a Jira issue
335 ///
336 /// # Arguments
337 ///
338 /// * `params` - Issue command parameters
339 ///
340 /// # Returns
341 ///
342 /// * `IssueBean` - Jira issue
343 ///
344 /// # Examples
345 ///
346 /// ```no_run
347 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
348 /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
349 /// use toml::Table;
350 ///
351 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
352 /// # tokio_test::block_on(async {
353 /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
354 /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
355 /// let mut params = IssueCmdParams::new();
356 /// params.issue_key = Some("issue_key".to_string());
357 ///
358 /// let result = issue_cmd_runner.get_jira_issue(params).await?;
359 /// # Ok(())
360 /// # })
361 /// # }
362 /// ```
363 pub async fn get_jira_issue(
364 &self,
365 params: IssueCmdParams,
366 ) -> Result<IssueBean, Box<dyn std::error::Error>> {
367 let i_key = if let Some(key) = ¶ms.issue_key {
368 key.as_str()
369 } else {
370 return Err(Box::new(Error::other(
371 "Error retrieving issue: Empty issue key".to_string(),
372 )));
373 };
374 Ok(get_issue(&self.cfg, i_key, None, None, None, None, None, None).await?)
375 }
376
377 /// This method searches for Jira issues using the provided JQL query parameters.
378 ///
379 /// # Arguments
380 ///
381 /// * `params` - Issue command parameters
382 ///
383 /// # Returns
384 ///
385 /// * `Vec<IssueBean>` - A vector of Jira issue beans
386 ///
387 /// # Examples
388 ///
389 /// ```no_run
390 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
391 /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
392 /// use toml::Table;
393 ///
394 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
395 /// # tokio_test::block_on(async {
396 /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
397 /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
398 /// let mut params = IssueCmdParams::new();
399 /// params.query = Some("field=value".to_string());
400 ///
401 /// let result = issue_cmd_runner.search_jira_issues(params).await?;
402 /// # Ok(())
403 /// # })
404 /// # }
405 /// ```
406 pub async fn search_jira_issues(
407 &self,
408 params: IssueCmdParams,
409 ) -> Result<Vec<IssueBean>, Box<dyn std::error::Error>> {
410 let search_params: SearchAndReconcileRequestBean = SearchAndReconcileRequestBean {
411 fields: Some(vec!["*navigable".to_string(), "-comment".to_string()]),
412 jql: params.query,
413 ..Default::default()
414 };
415 match search_and_reconsile_issues_using_jql_post(&self.cfg, search_params).await {
416 Ok(result) => {
417 if let Some(issues) = result.issues {
418 Ok(issues)
419 } else {
420 Ok(vec![])
421 }
422 }
423 Err(e) => Err(Box::new(e)),
424 }
425 }
426
427 /// Transitions a Jira issue
428 ///
429 /// # Arguments
430 ///
431 /// * `params` - Issue command parameters
432 ///
433 /// # Returns
434 ///
435 /// * `Value` - Jira issue transition
436 ///
437 /// # Examples
438 ///
439 /// ```no_run
440 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
441 /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
442 /// use toml::Table;
443 ///
444 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
445 /// # tokio_test::block_on(async {
446 /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
447 /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
448 ///
449 /// let mut params = IssueCmdParams::new();
450 /// params.transition = Some("transition_id".to_string());
451 ///
452 /// let result = issue_cmd_runner.transition_jira_issue(params).await?;
453 /// # Ok(())
454 /// # })
455 /// # }
456 /// ```
457 pub async fn transition_jira_issue(
458 &self,
459 params: IssueCmdParams,
460 ) -> Result<Value, Box<dyn std::error::Error>> {
461 let i_key = if let Some(key) = ¶ms.issue_key {
462 key.as_str()
463 } else {
464 return Err(Box::new(Error::other(
465 "Error with issue transition: Empty issue key".to_string(),
466 )));
467 };
468
469 let trans = if let Some(transition) = ¶ms.transition {
470 transition.as_str()
471 } else {
472 return Err(Box::new(Error::other(
473 "Error with issue transition: Empty transition".to_string(),
474 )));
475 };
476
477 let transition = IssueTransition {
478 id: Some(trans.to_string()),
479 ..Default::default()
480 };
481 let issue_data = IssueUpdateDetails {
482 fields: params.issue_fields,
483 history_metadata: None,
484 properties: None,
485 transition: Some(transition),
486 update: None,
487 };
488 Ok(do_transition(&self.cfg, i_key, issue_data).await?)
489 }
490
491 /// Updates a Jira issue
492 ///
493 /// # Arguments
494 ///
495 /// * `params` - Issue command parameters
496 ///
497 /// # Returns
498 ///
499 /// * `Value` - Jira issue update
500 ///
501 /// # Examples
502 ///
503 /// ```no_run
504 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueCmdParams};
505 /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
506 /// use toml::Table;
507 ///
508 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
509 /// # tokio_test::block_on(async {
510 /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
511 /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
512 /// let params = IssueCmdParams::new();
513 ///
514 /// let result = issue_cmd_runner.update_jira_issue(params).await?;
515 /// # Ok(())
516 /// # })
517 /// # }
518 /// ```
519 pub async fn update_jira_issue(
520 &self,
521 params: IssueCmdParams,
522 ) -> Result<Value, Box<dyn std::error::Error>> {
523 let i_key = if let Some(key) = ¶ms.issue_key {
524 key.as_str()
525 } else {
526 return Err(Box::new(Error::other(
527 "Error updating issue: Empty issue key".to_string(),
528 )));
529 };
530
531 let issue_data = IssueUpdateDetails {
532 fields: params.issue_fields,
533 history_metadata: None,
534 properties: None,
535 transition: None,
536 update: None,
537 };
538 Ok(edit_issue(
539 &self.cfg,
540 i_key,
541 issue_data,
542 None,
543 None,
544 None,
545 Some(true),
546 None,
547 )
548 .await?)
549 }
550
551 /// Gets available transitions for a Jira issue
552 ///
553 /// # Arguments
554 ///
555 /// * `params` - Issue command parameters
556 ///
557 /// # Returns
558 ///
559 /// * `Transitions` - Jira issue transitions
560 ///
561 /// # Examples
562 ///
563 /// ```no_run
564 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::{IssueCmdRunner, IssueTransitionCmdParams};
565 /// use jirust_cli::config::config_file::{ConfigFile, YaraSection};
566 /// use toml::Table;
567 ///
568 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
569 /// # tokio_test::block_on(async {
570 /// let cfg_file = ConfigFile::new("dXNlcm5hbWU6YXBpX2tleQ==".to_string(), "jira_url".to_string(), "standard_resolution".to_string(), "standard_resolution_comment".to_string(), Table::new(), YaraSection::default());
571 /// let issue_cmd_runner = IssueCmdRunner::new(&cfg_file);
572 /// let mut params = IssueTransitionCmdParams::new();
573 /// params.issue_key = "issue_key".to_string();
574 ///
575 /// let result = issue_cmd_runner.get_issue_available_transitions(params).await?;
576 /// # Ok(())
577 /// # })
578 /// # }
579 /// ```
580 pub async fn get_issue_available_transitions(
581 &self,
582 params: IssueTransitionCmdParams,
583 ) -> Result<Transitions, Box<dyn std::error::Error>> {
584 Ok(get_transitions(
585 &self.cfg,
586 ¶ms.issue_key,
587 None,
588 None,
589 None,
590 Some(false),
591 None,
592 )
593 .await?)
594 }
595}
596
597/// Issue command parameters
598///
599/// # Fields
600///
601/// * `project_key` - Jira project key
602/// * `issue_key` - Jira issue key
603/// * `issue_fields` - Jira issue fields
604/// * `transition` - Jira issue transition
605/// * `assignee` - Jira issue assignee
606/// * `query` - Jira issue query
607pub struct IssueCmdParams {
608 /// Jira project key
609 pub project_key: Option<String>,
610 /// Jira issue key
611 pub issue_key: Option<String>,
612 /// Jira issue fields
613 pub issue_fields: Option<HashMap<String, Value>>,
614 /// Jira issue transition
615 pub transition: Option<String>,
616 /// Jira issue assignee
617 pub assignee: Option<String>,
618 /// Jira issue query
619 pub query: Option<String>,
620 /// Jira issue file path for attachment
621 pub attachment_file_path: Option<String>,
622}
623
624/// Implementation of IssueCmdParams struct
625///
626/// # Methods
627///
628/// * `new` - Creates a new IssueCmdParams instance
629impl IssueCmdParams {
630 /// Creates a new IssueCmdParams instance
631 ///
632 /// # Returns
633 ///
634 /// * `IssueCmdParams` - Issue command parameters
635 ///
636 /// # Examples
637 ///
638 /// ```
639 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueCmdParams;
640 ///
641 /// let params = IssueCmdParams::new();
642 /// ```
643 pub fn new() -> IssueCmdParams {
644 IssueCmdParams {
645 project_key: Some("".to_string()),
646 issue_key: None,
647 issue_fields: None,
648 transition: None,
649 assignee: None,
650 query: None,
651 attachment_file_path: None,
652 }
653 }
654}
655
656/// Implementation of From trait for IssueCmdParams struct
657/// to convert IssueArgs struct to IssueCmdParams struct
658impl From<&IssueArgs> for IssueCmdParams {
659 /// Converts IssueArgs struct to IssueCmdParams struct
660 /// to create a new IssueCmdParams instance
661 ///
662 /// # Arguments
663 ///
664 /// * `value` - IssueArgs struct
665 ///
666 /// # Returns
667 ///
668 /// * `IssueCmdParams` - Issue command parameters
669 ///
670 /// # Examples
671 ///
672 /// ```
673 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueCmdParams;
674 /// use jirust_cli::args::commands::{IssueArgs, PaginationArgs, OutputArgs, IssueActionValues};
675 /// use std::collections::HashMap;
676 /// use serde_json::Value;
677 ///
678 /// let issue_args = IssueArgs {
679 /// issue_act: IssueActionValues::Get,
680 /// project_key: Some("project_key".to_string()),
681 /// issue_key: Some("issue_key".to_string()),
682 /// issue_fields: Some(vec![("key".to_string(), r#"{ "key": "value" }"#.to_string())]),
683 /// transition_to: Some("transition_to".to_string()),
684 /// assignee: Some("assignee".to_string()),
685 /// query: None,
686 /// attachment_file_path: None,
687 /// pagination: PaginationArgs { page_size: Some(20), page_offset: None },
688 /// output: OutputArgs { output_format: None, output_type: None },
689 /// };
690 ///
691 /// let params = IssueCmdParams::from(&issue_args);
692 ///
693 /// assert_eq!(params.project_key, Some("project_key".to_string()));
694 /// assert_eq!(params.issue_key.unwrap(), "issue_key".to_string());
695 /// assert_eq!(params.transition.unwrap(), "transition_to".to_string());
696 /// assert_eq!(params.assignee.unwrap(), "assignee".to_string());
697 /// ```
698 fn from(value: &IssueArgs) -> Self {
699 IssueCmdParams {
700 project_key: value.project_key.clone(),
701 issue_key: value.issue_key.clone(),
702 issue_fields: Some(
703 value
704 .issue_fields
705 .clone()
706 .unwrap_or_default()
707 .iter()
708 .map(|elem| {
709 (
710 elem.0.clone(),
711 serde_json::from_str(elem.1.clone().as_str()).unwrap_or(Value::Null),
712 )
713 })
714 .collect::<HashMap<_, _>>(),
715 ),
716 transition: value.transition_to.clone(),
717 assignee: value.assignee.clone(),
718 query: value.query.clone(),
719 attachment_file_path: value.attachment_file_path.clone(),
720 }
721 }
722}
723
724/// Issue transition command parameters
725///
726/// # Fields
727///
728/// * `issue_key` - Jira issue key
729pub struct IssueTransitionCmdParams {
730 /// Jira issue key
731 pub issue_key: String,
732}
733
734/// Implementation of IssueTransitionCmdParams struct
735///
736/// # Methods
737///
738/// * `new` - Creates a new IssueTransitionCmdParams instance
739impl IssueTransitionCmdParams {
740 /// Creates a new IssueTransitionCmdParams instance
741 ///
742 /// # Returns
743 ///
744 /// * `IssueTransitionCmdParams` - Issue transition command parameters
745 ///
746 /// # Examples
747 ///
748 /// ```
749 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueTransitionCmdParams;
750 ///
751 /// let params = IssueTransitionCmdParams::new();
752 /// ```
753 pub fn new() -> IssueTransitionCmdParams {
754 IssueTransitionCmdParams {
755 issue_key: "".to_string(),
756 }
757 }
758}
759
760/// Implementation of From trait for IssueTransitionCmdParams struct
761/// to convert TransitionArgs struct to IssueTransitionCmdParams struct
762impl From<&TransitionArgs> for IssueTransitionCmdParams {
763 /// Converts TransitionArgs struct to IssueTransitionCmdParams struct
764 /// to create a new IssueTransitionCmdParams instance
765 ///
766 /// # Arguments
767 ///
768 /// * `value` - TransitionArgs struct
769 ///
770 /// # Returns
771 ///
772 /// * `IssueTransitionCmdParams` - Issue transition command parameters
773 ///
774 /// # Examples
775 ///
776 /// ```
777 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueTransitionCmdParams;
778 /// use jirust_cli::args::commands::{TransitionArgs, TransitionActionValues, OutputArgs};
779 ///
780 /// let transition_args = TransitionArgs {
781 /// transition_act: TransitionActionValues::List,
782 /// issue_key: "issue_key".to_string(),
783 /// output: OutputArgs { output_format: None, output_type: None },
784 /// };
785 ///
786 /// let params = IssueTransitionCmdParams::from(&transition_args);
787 ///
788 /// assert_eq!(params.issue_key, "issue_key".to_string());
789 /// ```
790 fn from(value: &TransitionArgs) -> Self {
791 IssueTransitionCmdParams {
792 issue_key: value.issue_key.clone(),
793 }
794 }
795}
796
797/// Default implementation for IssueCmdParams struct
798impl Default for IssueTransitionCmdParams {
799 /// Creates a default IssueTransitionCmdParams instance
800 ///
801 /// # Returns
802 ///
803 /// A IssueTransitionCmdParams instance with default values
804 ///
805 /// # Examples
806 ///
807 /// ```
808 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueTransitionCmdParams;
809 ///
810 /// let params = IssueTransitionCmdParams::default();
811 ///
812 /// assert_eq!(params.issue_key, "".to_string());
813 /// ```
814 fn default() -> Self {
815 IssueTransitionCmdParams::new()
816 }
817}
818
819/// Default implementation for IssueCmdParams struct
820impl Default for IssueCmdParams {
821 /// Creates a default IssueCmdParams instance
822 ///
823 /// # Returns
824 ///
825 /// A IssueCmdParams instance with default values
826 ///
827 /// # Examples
828 ///
829 /// ```
830 /// use jirust_cli::runners::jira_cmd_runners::issue_cmd_runner::IssueCmdParams;
831 ///
832 /// let params = IssueCmdParams::default();
833 ///
834 /// assert_eq!(params.project_key, Some("".to_string()));
835 /// assert_eq!(params.issue_key, None);
836 /// assert_eq!(params.issue_fields, None);
837 /// assert_eq!(params.transition, None);
838 /// assert_eq!(params.assignee, None);
839 /// ```
840 fn default() -> Self {
841 IssueCmdParams::new()
842 }
843}
844
845/// API contract for running Jira issue operations.
846#[cfg_attr(test, automock)]
847#[async_trait(?Send)]
848pub trait IssueCmdRunnerApi: Send + Sync {
849 /// Assigns a Jira issue to a user or placeholder account.
850 async fn assign_jira_issue(
851 &self,
852 params: IssueCmdParams,
853 ) -> Result<Value, Box<dyn std::error::Error>>;
854
855 /// Attaches a file to a Jira issue.
856 async fn attach_file_to_jira_issue(
857 &self,
858 params: IssueCmdParams,
859 ) -> Result<Vec<Attachment>, Box<dyn std::error::Error>>;
860
861 /// Creates a Jira issue using the provided parameters.
862 async fn create_jira_issue(
863 &self,
864 params: IssueCmdParams,
865 ) -> Result<CreatedIssue, Box<dyn std::error::Error>>;
866
867 /// Deletes a Jira issue by key.
868 async fn delete_jira_issue(
869 &self,
870 params: IssueCmdParams,
871 ) -> Result<(), Box<dyn std::error::Error>>;
872
873 /// Retrieves a Jira issue by key.
874 async fn get_jira_issue(
875 &self,
876 params: IssueCmdParams,
877 ) -> Result<IssueBean, Box<dyn std::error::Error>>;
878
879 /// Executes a search query and returns the matching issues.
880 async fn search_jira_issues(
881 &self,
882 params: IssueCmdParams,
883 ) -> Result<Vec<IssueBean>, Box<dyn std::error::Error>>;
884
885 /// Transitions an issue to a new status.
886 async fn transition_jira_issue(
887 &self,
888 params: IssueCmdParams,
889 ) -> Result<Value, Box<dyn std::error::Error>>;
890
891 /// Updates an issue fields payload.
892 async fn update_jira_issue(
893 &self,
894 params: IssueCmdParams,
895 ) -> Result<Value, Box<dyn std::error::Error>>;
896
897 /// Fetches the available transitions for an issue.
898 async fn get_issue_available_transitions(
899 &self,
900 params: IssueTransitionCmdParams,
901 ) -> Result<Transitions, Box<dyn std::error::Error>>;
902}
903
904#[async_trait(?Send)]
905impl IssueCmdRunnerApi for IssueCmdRunner {
906 async fn assign_jira_issue(
907 &self,
908 params: IssueCmdParams,
909 ) -> Result<Value, Box<dyn std::error::Error>> {
910 IssueCmdRunner::assign_jira_issue(self, params).await
911 }
912
913 async fn attach_file_to_jira_issue(
914 &self,
915 params: IssueCmdParams,
916 ) -> Result<Vec<Attachment>, Box<dyn std::error::Error>> {
917 IssueCmdRunner::attach_file_to_jira_issue(self, params).await
918 }
919
920 async fn create_jira_issue(
921 &self,
922 params: IssueCmdParams,
923 ) -> Result<CreatedIssue, Box<dyn std::error::Error>> {
924 IssueCmdRunner::create_jira_issue(self, params).await
925 }
926
927 async fn delete_jira_issue(
928 &self,
929 params: IssueCmdParams,
930 ) -> Result<(), Box<dyn std::error::Error>> {
931 IssueCmdRunner::delete_jira_issue(self, params).await
932 }
933
934 async fn get_jira_issue(
935 &self,
936 params: IssueCmdParams,
937 ) -> Result<IssueBean, Box<dyn std::error::Error>> {
938 IssueCmdRunner::get_jira_issue(self, params).await
939 }
940
941 async fn search_jira_issues(
942 &self,
943 params: IssueCmdParams,
944 ) -> Result<Vec<IssueBean>, Box<dyn std::error::Error>> {
945 IssueCmdRunner::search_jira_issues(self, params).await
946 }
947
948 async fn transition_jira_issue(
949 &self,
950 params: IssueCmdParams,
951 ) -> Result<Value, Box<dyn std::error::Error>> {
952 IssueCmdRunner::transition_jira_issue(self, params).await
953 }
954
955 async fn update_jira_issue(
956 &self,
957 params: IssueCmdParams,
958 ) -> Result<Value, Box<dyn std::error::Error>> {
959 IssueCmdRunner::update_jira_issue(self, params).await
960 }
961
962 async fn get_issue_available_transitions(
963 &self,
964 params: IssueTransitionCmdParams,
965 ) -> Result<Transitions, Box<dyn std::error::Error>> {
966 IssueCmdRunner::get_issue_available_transitions(self, params).await
967 }
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973 use crate::config::config_file::{ConfigFile, YaraSection};
974 use std::sync::Mutex;
975 use std::{env, fs};
976 use tempfile::tempdir;
977 use toml::Table;
978
979 static ENV_MUTEX: Mutex<()> = Mutex::new(());
980
981 fn basic_config() -> ConfigFile {
982 ConfigFile::new(
983 "dGVzdDp0b2tlbg==".to_string(),
984 "https://example.atlassian.net".to_string(),
985 "Done".to_string(),
986 "Completed".to_string(),
987 Table::new(),
988 YaraSection::default(),
989 )
990 }
991
992 #[tokio::test]
993 async fn attach_without_issue_key_returns_error() {
994 let runner = IssueCmdRunner::new(&basic_config());
995 let mut params = IssueCmdParams::new();
996 params.attachment_file_path = Some("/tmp/file.txt".to_string());
997
998 let result = runner.attach_file_to_jira_issue(params).await;
999 assert!(result.is_err());
1000 assert!(result.unwrap_err().to_string().contains("Empty issue key"));
1001 }
1002
1003 #[tokio::test]
1004 async fn attach_without_file_path_returns_error() {
1005 let runner = IssueCmdRunner::new(&basic_config());
1006 let mut params = IssueCmdParams::new();
1007 params.issue_key = Some("TEST-123".to_string());
1008
1009 let result = runner.attach_file_to_jira_issue(params).await;
1010 assert!(result.is_err());
1011 assert!(
1012 result
1013 .unwrap_err()
1014 .to_string()
1015 .contains("Empty attachment file path")
1016 );
1017 }
1018
1019 #[cfg(feature = "attachment_scan")]
1020 #[tokio::test]
1021 async fn scan_bytes_uses_local_rules() {
1022 let _guard = ENV_MUTEX.lock().unwrap();
1023 let temp_home = tempdir().expect("temp HOME");
1024 let base_dir = temp_home.path().join(".jirust-cli");
1025 let rules_dir = base_dir.join("rules");
1026 fs::create_dir_all(&rules_dir).expect("create rules dir");
1027
1028 fs::write(rules_dir.join(".version"), "v1").expect("write version marker");
1029 fs::write(
1030 rules_dir.join("test_rule.yar"),
1031 r#"
1032rule TestRule {
1033 strings:
1034 $a = "hitme"
1035 condition:
1036 $a
1037}
1038"#,
1039 )
1040 .expect("write yara rule");
1041
1042 let config = ConfigFile::new(
1043 "dGVzdDp0b2tlbg==".to_string(),
1044 "https://example.atlassian.net".to_string(),
1045 "Done".to_string(),
1046 "Completed".to_string(),
1047 Table::new(),
1048 YaraSection::new(
1049 "local_rules.zip".to_string(),
1050 "rules".to_string(),
1051 "yara_rules.cache".to_string(),
1052 "yara_rules.cache.version".to_string(),
1053 ),
1054 );
1055
1056 let runner = IssueCmdRunner::new(&config);
1057 let matches = runner
1058 .scan_bytes_with_base_dir(b"hitme", base_dir.clone())
1059 .await
1060 .expect("scan should succeed");
1061
1062 assert!(matches.contains(&"TestRule".to_string()));
1063 assert!(base_dir.join("yara_rules.cache").exists());
1064 assert!(base_dir.join("yara_rules.cache.version").exists());
1065 }
1066}