Skip to main content

omni_dev/cli/atlassian/
jira.rs

1//! JIRA CLI subcommands.
2
3pub(crate) mod attachment;
4pub(crate) mod board;
5pub(crate) mod changelog;
6pub(crate) mod comment;
7pub(crate) mod component;
8pub(crate) mod create;
9pub(crate) mod delete;
10pub(crate) mod dev;
11pub(crate) mod edit;
12pub(crate) mod field;
13pub(crate) mod label;
14pub(crate) mod link;
15pub(crate) mod project;
16pub(crate) mod read;
17pub(crate) mod search;
18pub(crate) mod sprint;
19pub(crate) mod transition;
20pub(crate) mod user;
21pub(crate) mod version;
22pub(crate) mod watcher;
23pub(crate) mod worklog;
24pub(crate) mod write;
25
26use anyhow::Result;
27use clap::{Parser, Subcommand};
28
29/// JIRA issue management, search, agile boards, and more.
30#[derive(Parser)]
31pub struct JiraCommand {
32    /// The JIRA subcommand to execute.
33    #[command(subcommand)]
34    pub command: JiraSubcommands,
35
36    /// `--instance`, inherited by every JIRA subcommand.
37    #[command(flatten)]
38    pub instance: super::InstanceArg,
39}
40
41/// JIRA subcommands.
42#[derive(Subcommand)]
43pub enum JiraSubcommands {
44    /// Fetches a JIRA issue and outputs it as JFM markdown or ADF JSON (mirrors the `jira_read` MCP tool).
45    Read(read::ReadCommand),
46    /// Pushes content to a JIRA issue (mirrors the `jira_write` MCP tool).
47    Write(write::WriteCommand),
48    /// Interactive fetch-edit-push cycle for a JIRA issue.
49    Edit(edit::EditCommand),
50    /// Searches JIRA issues using JQL (mirrors the `jira_search` MCP tool).
51    Search(search::SearchCommand),
52    /// Creates a new JIRA issue (mirrors the `jira_create` MCP tool).
53    Create(create::CreateCommand),
54    /// Lists or executes workflow transitions on a JIRA issue.
55    Transition(transition::TransitionCommand),
56    /// Manages comments on a JIRA issue.
57    Comment(comment::CommentCommand),
58    /// Deletes a JIRA issue (mirrors the `jira_delete` MCP tool).
59    Delete(delete::DeleteCommand),
60    /// Shows development status (linked PRs, branches, repositories) for a JIRA issue (mirrors the `jira_dev` MCP tool).
61    Dev(dev::DevCommand),
62    /// Lists JIRA projects.
63    Project(project::ProjectCommand),
64    /// Manages JIRA field definitions and options.
65    Field(field::FieldCommand),
66    /// Manages JIRA agile boards.
67    Board(board::BoardCommand),
68    /// Manages JIRA agile sprints.
69    Sprint(sprint::SprintCommand),
70    /// Manages JIRA issue links.
71    Link(link::LinkCommand),
72    /// Adds or removes labels on a JIRA issue incrementally.
73    Label(label::LabelCommand),
74    /// Shows change history for JIRA issues (mirrors the `jira_changelog` MCP tool).
75    Changelog(changelog::ChangelogCommand),
76    /// Downloads JIRA issue attachments.
77    Attachment(attachment::AttachmentCommand),
78    /// Manages watchers on a JIRA issue.
79    Watcher(watcher::WatcherCommand),
80    /// Manages worklogs (time tracking) on a JIRA issue.
81    Worklog(worklog::WorklogCommand),
82    /// JIRA user operations (search by name or email).
83    User(user::UserCommand),
84    /// Manages JIRA project versions (release versions).
85    Version(version::VersionCommand),
86    /// Manages JIRA project components.
87    Component(component::ComponentCommand),
88}
89
90impl JiraCommand {
91    /// Executes the JIRA command.
92    pub async fn execute(self) -> Result<()> {
93        self.instance.apply();
94        match self.command {
95            JiraSubcommands::Read(cmd) => cmd.execute().await,
96            JiraSubcommands::Write(cmd) => cmd.execute().await,
97            JiraSubcommands::Edit(cmd) => cmd.execute().await,
98            JiraSubcommands::Search(cmd) => cmd.execute().await,
99            JiraSubcommands::Create(cmd) => cmd.execute().await,
100            JiraSubcommands::Transition(cmd) => cmd.execute().await,
101            JiraSubcommands::Comment(cmd) => cmd.execute().await,
102            JiraSubcommands::Delete(cmd) => cmd.execute().await,
103            JiraSubcommands::Dev(cmd) => cmd.execute().await,
104            JiraSubcommands::Project(cmd) => cmd.execute().await,
105            JiraSubcommands::Field(cmd) => cmd.execute().await,
106            JiraSubcommands::Board(cmd) => cmd.execute().await,
107            JiraSubcommands::Sprint(cmd) => cmd.execute().await,
108            JiraSubcommands::Link(cmd) => cmd.execute().await,
109            JiraSubcommands::Label(cmd) => cmd.execute().await,
110            JiraSubcommands::Changelog(cmd) => cmd.execute().await,
111            JiraSubcommands::Attachment(cmd) => cmd.execute().await,
112            JiraSubcommands::Watcher(cmd) => cmd.execute().await,
113            JiraSubcommands::Worklog(cmd) => cmd.execute().await,
114            JiraSubcommands::User(cmd) => cmd.execute().await,
115            JiraSubcommands::Version(cmd) => cmd.execute().await,
116            JiraSubcommands::Component(cmd) => cmd.execute().await,
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::cli::atlassian::format::{ContentFormat, OutputFormat};
125
126    #[test]
127    fn jira_subcommands_read_variant() {
128        let cmd = JiraCommand {
129            command: JiraSubcommands::Read(read::ReadCommand {
130                key: "PROJ-1".to_string(),
131                out_file: None,
132                output: None,
133                format: ContentFormat::Jfm,
134                fields: vec![],
135                all_fields: false,
136            }),
137            instance: crate::cli::atlassian::InstanceArg::default(),
138        };
139        assert!(matches!(cmd.command, JiraSubcommands::Read(_)));
140    }
141
142    #[test]
143    fn jira_subcommands_write_variant() {
144        let cmd = JiraCommand {
145            command: JiraSubcommands::Write(write::WriteCommand {
146                key: "PROJ-1".to_string(),
147                file: None,
148                format: ContentFormat::Jfm,
149                no_content: false,
150                assignee: None,
151                reporter: None,
152                set_fields: vec![],
153                force: false,
154                dry_run: false,
155            }),
156            instance: crate::cli::atlassian::InstanceArg::default(),
157        };
158        assert!(matches!(cmd.command, JiraSubcommands::Write(_)));
159    }
160
161    #[test]
162    fn jira_subcommands_edit_variant() {
163        let cmd = JiraCommand {
164            command: JiraSubcommands::Edit(edit::EditCommand {
165                key: "PROJ-1".to_string(),
166            }),
167            instance: crate::cli::atlassian::InstanceArg::default(),
168        };
169        assert!(matches!(cmd.command, JiraSubcommands::Edit(_)));
170    }
171
172    #[test]
173    fn jira_subcommands_create_variant() {
174        let cmd = JiraCommand {
175            command: JiraSubcommands::Create(create::CreateCommand {
176                file: None,
177                format: ContentFormat::Jfm,
178                project: Some("PROJ".to_string()),
179                r#type: None,
180                summary: Some("Test".to_string()),
181                set_fields: vec![],
182                dry_run: false,
183            }),
184            instance: crate::cli::atlassian::InstanceArg::default(),
185        };
186        assert!(matches!(cmd.command, JiraSubcommands::Create(_)));
187    }
188
189    #[test]
190    fn jira_subcommands_search_variant() {
191        let cmd = JiraCommand {
192            command: JiraSubcommands::Search(search::SearchCommand {
193                jql: Some("project = PROJ".to_string()),
194                project: None,
195                assignee: None,
196                status: None,
197                limit: 50,
198                output: OutputFormat::Table,
199            }),
200            instance: crate::cli::atlassian::InstanceArg::default(),
201        };
202        assert!(matches!(cmd.command, JiraSubcommands::Search(_)));
203    }
204
205    #[test]
206    fn jira_subcommands_transition_variant() {
207        let cmd = JiraCommand {
208            command: JiraSubcommands::Transition(transition::TransitionCommand {
209                command: transition::TransitionSubcommands::Execute(transition::ExecuteCommand {
210                    key: "PROJ-1".to_string(),
211                    transition: "Done".to_string(),
212                    set_fields: vec![],
213                    resolution: None,
214                    comment: None,
215                }),
216            }),
217            instance: crate::cli::atlassian::InstanceArg::default(),
218        };
219        assert!(matches!(cmd.command, JiraSubcommands::Transition(_)));
220    }
221
222    #[test]
223    fn jira_subcommands_comment_variant() {
224        let cmd = JiraCommand {
225            command: JiraSubcommands::Comment(comment::CommentCommand {
226                command: comment::CommentSubcommands::List(comment::ListCommand {
227                    key: "PROJ-1".to_string(),
228                    output: OutputFormat::Table,
229                    limit: 0,
230                }),
231            }),
232            instance: crate::cli::atlassian::InstanceArg::default(),
233        };
234        assert!(matches!(cmd.command, JiraSubcommands::Comment(_)));
235    }
236
237    #[test]
238    fn jira_subcommands_delete_variant() {
239        let cmd = JiraCommand {
240            command: JiraSubcommands::Delete(delete::DeleteCommand {
241                key: "PROJ-1".to_string(),
242                force: true,
243                dry_run: false,
244            }),
245            instance: crate::cli::atlassian::InstanceArg::default(),
246        };
247        assert!(matches!(cmd.command, JiraSubcommands::Delete(_)));
248    }
249
250    #[test]
251    fn jira_subcommands_dev_variant() {
252        let cmd = JiraCommand {
253            command: JiraSubcommands::Dev(dev::DevCommand {
254                key: "PROJ-1".to_string(),
255                r#type: None,
256                app: None,
257                summary: false,
258                output: OutputFormat::Table,
259            }),
260            instance: crate::cli::atlassian::InstanceArg::default(),
261        };
262        assert!(matches!(cmd.command, JiraSubcommands::Dev(_)));
263    }
264
265    #[test]
266    fn jira_subcommands_project_variant() {
267        let cmd = JiraCommand {
268            command: JiraSubcommands::Project(project::ProjectCommand {
269                command: project::ProjectSubcommands::List(project::ListCommand {
270                    limit: 50,
271                    output: OutputFormat::Table,
272                }),
273            }),
274            instance: crate::cli::atlassian::InstanceArg::default(),
275        };
276        assert!(matches!(cmd.command, JiraSubcommands::Project(_)));
277    }
278
279    #[test]
280    fn jira_subcommands_field_variant() {
281        let cmd = JiraCommand {
282            command: JiraSubcommands::Field(field::FieldCommand {
283                command: field::FieldSubcommands::List(field::ListCommand {
284                    search: None,
285                    output: OutputFormat::Table,
286                }),
287            }),
288            instance: crate::cli::atlassian::InstanceArg::default(),
289        };
290        assert!(matches!(cmd.command, JiraSubcommands::Field(_)));
291    }
292
293    #[test]
294    fn jira_subcommands_board_variant() {
295        let cmd = JiraCommand {
296            command: JiraSubcommands::Board(board::BoardCommand {
297                command: board::BoardSubcommands::List(board::ListCommand {
298                    project: None,
299                    r#type: None,
300                    limit: 50,
301                    output: OutputFormat::Table,
302                }),
303            }),
304            instance: crate::cli::atlassian::InstanceArg::default(),
305        };
306        assert!(matches!(cmd.command, JiraSubcommands::Board(_)));
307    }
308
309    #[test]
310    fn jira_subcommands_sprint_variant() {
311        let cmd = JiraCommand {
312            command: JiraSubcommands::Sprint(sprint::SprintCommand {
313                command: sprint::SprintSubcommands::List(sprint::ListCommand {
314                    board_id: 1,
315                    state: None,
316                    limit: 50,
317                    output: OutputFormat::Table,
318                }),
319            }),
320            instance: crate::cli::atlassian::InstanceArg::default(),
321        };
322        assert!(matches!(cmd.command, JiraSubcommands::Sprint(_)));
323    }
324
325    #[test]
326    fn jira_subcommands_link_variant() {
327        let cmd = JiraCommand {
328            command: JiraSubcommands::Link(link::LinkCommand {
329                command: link::LinkSubcommands::Types(link::TypesCommand {
330                    output: OutputFormat::Table,
331                }),
332            }),
333            instance: crate::cli::atlassian::InstanceArg::default(),
334        };
335        assert!(matches!(cmd.command, JiraSubcommands::Link(_)));
336    }
337
338    #[test]
339    fn jira_subcommands_changelog_variant() {
340        let cmd = JiraCommand {
341            command: JiraSubcommands::Changelog(changelog::ChangelogCommand {
342                keys: "PROJ-1".to_string(),
343                limit: 50,
344                output: OutputFormat::Table,
345            }),
346            instance: crate::cli::atlassian::InstanceArg::default(),
347        };
348        assert!(matches!(cmd.command, JiraSubcommands::Changelog(_)));
349    }
350
351    #[test]
352    fn jira_subcommands_attachment_variant() {
353        let cmd = JiraCommand {
354            command: JiraSubcommands::Attachment(attachment::AttachmentCommand {
355                command: attachment::AttachmentSubcommands::Download(attachment::DownloadCommand {
356                    key: "PROJ-1".to_string(),
357                    output_dir: ".".to_string(),
358                    filter: None,
359                }),
360            }),
361            instance: crate::cli::atlassian::InstanceArg::default(),
362        };
363        assert!(matches!(cmd.command, JiraSubcommands::Attachment(_)));
364    }
365
366    #[test]
367    fn jira_subcommands_watcher_variant() {
368        let cmd = JiraCommand {
369            command: JiraSubcommands::Watcher(watcher::WatcherCommand {
370                command: watcher::WatcherSubcommands::List(watcher::ListCommand {
371                    key: "PROJ-1".to_string(),
372                    output: OutputFormat::Table,
373                }),
374            }),
375            instance: crate::cli::atlassian::InstanceArg::default(),
376        };
377        assert!(matches!(cmd.command, JiraSubcommands::Watcher(_)));
378    }
379
380    #[test]
381    fn jira_subcommands_worklog_variant() {
382        let cmd = JiraCommand {
383            command: JiraSubcommands::Worklog(worklog::WorklogCommand {
384                command: worklog::WorklogSubcommands::List(worklog::ListCommand {
385                    key: "PROJ-1".to_string(),
386                    limit: 50,
387                    output: OutputFormat::Table,
388                }),
389            }),
390            instance: crate::cli::atlassian::InstanceArg::default(),
391        };
392        assert!(matches!(cmd.command, JiraSubcommands::Worklog(_)));
393    }
394
395    #[test]
396    fn jira_subcommands_user_variant() {
397        let cmd = JiraCommand {
398            command: JiraSubcommands::User(user::UserCommand {
399                command: user::UserSubcommands::Search(user::UserSearchCommand {
400                    query: "alice".to_string(),
401                    limit: 25,
402                    output: OutputFormat::Table,
403                }),
404            }),
405            instance: crate::cli::atlassian::InstanceArg::default(),
406        };
407        assert!(matches!(cmd.command, JiraSubcommands::User(_)));
408    }
409
410    #[test]
411    fn jira_subcommands_version_variant() {
412        let cmd = JiraCommand {
413            command: JiraSubcommands::Version(version::VersionCommand {
414                command: version::VersionSubcommands::List(version::ListCommand {
415                    project: "PROJ".to_string(),
416                    released: false,
417                    unreleased: false,
418                    archived: false,
419                    unarchived: false,
420                    output: OutputFormat::Table,
421                }),
422            }),
423            instance: crate::cli::atlassian::InstanceArg::default(),
424        };
425        assert!(matches!(cmd.command, JiraSubcommands::Version(_)));
426    }
427
428    /// Drives `JiraCommand::execute` end-to-end through the new `Version`
429    /// arm so the dispatch line in the match block is exercised. Other
430    /// arms remain pre-existing convention gaps.
431    #[tokio::test]
432    async fn jira_command_execute_version_arm() {
433        use crate::atlassian::auth::test_util::EnvGuard;
434
435        let server = wiremock::MockServer::start().await;
436        wiremock::Mock::given(wiremock::matchers::method("GET"))
437            .and(wiremock::matchers::path(
438                "/rest/api/3/project/PROJ/versions",
439            ))
440            .respond_with(
441                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
442                    {"id": "1", "name": "1.0", "released": false, "archived": false}
443                ])),
444            )
445            .mount(&server)
446            .await;
447
448        let guard = EnvGuard::take();
449        let _home = guard.set_credentials(&server.uri());
450
451        let cmd = JiraCommand {
452            command: JiraSubcommands::Version(version::VersionCommand {
453                command: version::VersionSubcommands::List(version::ListCommand {
454                    project: "PROJ".to_string(),
455                    released: false,
456                    unreleased: false,
457                    archived: false,
458                    unarchived: false,
459                    output: OutputFormat::Yaml,
460                }),
461            }),
462            instance: crate::cli::atlassian::InstanceArg::default(),
463        };
464        assert!(cmd.execute().await.is_ok());
465    }
466
467    /// The `Label` arm — also drives `LabelCommand`'s own dispatch and
468    /// `AddCommand::execute` (create_client + the `update`-verb PUT).
469    #[tokio::test]
470    async fn jira_command_execute_label_arm() {
471        use crate::atlassian::auth::test_util::EnvGuard;
472
473        let server = wiremock::MockServer::start().await;
474        wiremock::Mock::given(wiremock::matchers::method("PUT"))
475            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
476            .respond_with(wiremock::ResponseTemplate::new(204))
477            .mount(&server)
478            .await;
479
480        let guard = EnvGuard::take();
481        let _home = guard.set_credentials(&server.uri());
482
483        let cmd = JiraCommand {
484            command: JiraSubcommands::Label(label::LabelCommand {
485                command: label::LabelSubcommands::Add(label::AddCommand {
486                    key: "PROJ-1".to_string(),
487                    labels: vec!["backend".to_string()],
488                }),
489            }),
490            instance: crate::cli::atlassian::InstanceArg::default(),
491        };
492        assert!(cmd.execute().await.is_ok());
493    }
494
495    /// The `Component` arm — also drives `ComponentCommand`'s own dispatch and
496    /// `ListCommand::execute`.
497    #[tokio::test]
498    async fn jira_command_execute_component_arm() {
499        use crate::atlassian::auth::test_util::EnvGuard;
500
501        let server = wiremock::MockServer::start().await;
502        wiremock::Mock::given(wiremock::matchers::method("GET"))
503            .and(wiremock::matchers::path(
504                "/rest/api/3/project/PROJ/components",
505            ))
506            .respond_with(
507                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
508                    {"id": "10000", "name": "Backend"}
509                ])),
510            )
511            .mount(&server)
512            .await;
513
514        let guard = EnvGuard::take();
515        let _home = guard.set_credentials(&server.uri());
516
517        let cmd = JiraCommand {
518            command: JiraSubcommands::Component(component::ComponentCommand {
519                command: component::ComponentSubcommands::List(component::ListCommand {
520                    project: "PROJ".to_string(),
521                    output: OutputFormat::Yaml,
522                }),
523            }),
524            instance: crate::cli::atlassian::InstanceArg::default(),
525        };
526        assert!(cmd.execute().await.is_ok());
527    }
528}