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