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