Skip to main content

omni_dev/cli/
atlassian.rs

1//! Atlassian CLI commands for JIRA and Confluence.
2
3pub(crate) mod auth;
4pub mod confluence;
5pub(crate) mod convert;
6pub(crate) mod format;
7pub(crate) mod helpers;
8pub mod jira;
9
10use anyhow::Result;
11use clap::{Args, Parser, Subcommand};
12
13/// `--instance`: per-invocation Atlassian tenant override.
14///
15/// Scoped to the commands that read Atlassian credentials — subtree-global on
16/// `jira` and `confluence`, and a plain flag on `auth status` — per the
17/// placement rule in [`crate::cli::ai_backend_args`]. `convert`, `auth login`,
18/// and `auth logout` never load credentials, so they reject it (#1778).
19#[derive(Args, Debug, Clone, Default)]
20pub struct InstanceArg {
21    /// Overrides the Atlassian instance URL (e.g.
22    /// `https://org.atlassian.net`) for this JIRA/Confluence command.
23    ///
24    /// Takes precedence over `ATLASSIAN_INSTANCE_URL` / settings.json (email
25    /// and API token still come from the environment/settings). Lets a
26    /// multi-site user target a specific tenant per invocation. Equivalent to
27    /// setting `OMNI_DEV_ATLASSIAN_INSTANCE`.
28    #[arg(long, global = true, value_name = "URL")]
29    pub instance: Option<String>,
30}
31
32impl InstanceArg {
33    /// Forwards `--instance` to the env var that
34    /// [`crate::atlassian::auth::load_credentials`] reads (#1117). When absent,
35    /// any existing value is left untouched so the env-var path still works.
36    pub fn apply(&self) {
37        if let Some(instance) = &self.instance {
38            std::env::set_var(
39                crate::atlassian::auth::ATLASSIAN_INSTANCE_OVERRIDE_ENV,
40                instance,
41            );
42        }
43    }
44}
45
46/// Atlassian: JIRA and Confluence operations.
47#[derive(Parser)]
48pub struct AtlassianCommand {
49    /// The Atlassian subcommand to execute.
50    #[command(subcommand)]
51    pub command: AtlassianSubcommands,
52}
53
54/// Atlassian subcommands.
55#[derive(Subcommand)]
56pub enum AtlassianSubcommands {
57    /// JIRA issue management, search, agile boards, and more.
58    Jira(jira::JiraCommand),
59    /// Confluence page management, search, and more.
60    Confluence(confluence::ConfluenceCommand),
61    /// Converts between JFM markdown and ADF JSON.
62    Convert(convert::ConvertCommand),
63    /// Manages Atlassian Cloud credentials.
64    Auth(auth::AuthCommand),
65}
66
67impl AtlassianCommand {
68    /// Executes the Atlassian command.
69    pub async fn execute(self) -> Result<()> {
70        match self.command {
71            AtlassianSubcommands::Jira(cmd) => cmd.execute().await,
72            AtlassianSubcommands::Confluence(cmd) => cmd.execute().await,
73            AtlassianSubcommands::Convert(cmd) => cmd.execute(),
74            AtlassianSubcommands::Auth(cmd) => cmd.execute().await,
75        }
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    // ── InstanceArg (#1778) ──
84
85    const INSTANCE_VAR: &str = "OMNI_DEV_ATLASSIAN_INSTANCE";
86
87    /// Serialises on the shared env lock and restores `OMNI_DEV_ATLASSIAN_INSTANCE`.
88    struct InstanceEnvGuard {
89        _lock: std::sync::MutexGuard<'static, ()>,
90        saved: Option<String>,
91    }
92
93    impl InstanceEnvGuard {
94        fn new() -> Self {
95            let lock = crate::claude::ai::claude_cli::CLI_ENV_LOCK
96                .lock()
97                .unwrap_or_else(std::sync::PoisonError::into_inner);
98            let saved = std::env::var(INSTANCE_VAR).ok();
99            std::env::remove_var(INSTANCE_VAR);
100            Self { _lock: lock, saved }
101        }
102    }
103
104    impl Drop for InstanceEnvGuard {
105        fn drop(&mut self) {
106            match &self.saved {
107                Some(v) => std::env::set_var(INSTANCE_VAR, v),
108                None => std::env::remove_var(INSTANCE_VAR),
109            }
110        }
111    }
112
113    #[test]
114    fn instance_apply_sets_env_var() {
115        let _g = InstanceEnvGuard::new();
116        InstanceArg {
117            instance: Some("https://org.atlassian.net".to_string()),
118        }
119        .apply();
120        assert_eq!(
121            std::env::var(INSTANCE_VAR).ok().as_deref(),
122            Some("https://org.atlassian.net")
123        );
124    }
125
126    #[test]
127    fn instance_apply_absent_leaves_env_var() {
128        let _g = InstanceEnvGuard::new();
129        std::env::set_var(INSTANCE_VAR, "https://preset.atlassian.net");
130        InstanceArg::default().apply();
131        assert_eq!(
132            std::env::var(INSTANCE_VAR).ok().as_deref(),
133            Some("https://preset.atlassian.net")
134        );
135    }
136
137    #[test]
138    fn instance_parses_on_readers_and_is_rejected_elsewhere() {
139        use crate::cli::Cli;
140        use clap::Parser;
141
142        let url = "https://x.atlassian.net";
143        for argv in [
144            &[
145                "omni-dev",
146                "atlassian",
147                "jira",
148                "--instance",
149                url,
150                "read",
151                "K-1",
152            ][..],
153            &[
154                "omni-dev",
155                "atlassian",
156                "jira",
157                "read",
158                "K-1",
159                "--instance",
160                url,
161            ],
162            &[
163                "omni-dev",
164                "atlassian",
165                "confluence",
166                "--instance",
167                url,
168                "read",
169                "1",
170            ],
171            &["omni-dev", "atlassian", "auth", "status", "--instance", url],
172        ] {
173            assert!(Cli::try_parse_from(argv).is_ok(), "{argv:?}");
174        }
175        for argv in [
176            &[
177                "omni-dev",
178                "--instance",
179                url,
180                "atlassian",
181                "jira",
182                "read",
183                "K-1",
184            ][..],
185            &[
186                "omni-dev",
187                "atlassian",
188                "--instance",
189                url,
190                "jira",
191                "read",
192                "K-1",
193            ],
194            &["omni-dev", "atlassian", "auth", "login", "--instance", url],
195            &[
196                "omni-dev",
197                "git",
198                "commit",
199                "message",
200                "view",
201                "--instance",
202                url,
203            ],
204        ] {
205            assert!(Cli::try_parse_from(argv).is_err(), "{argv:?}");
206        }
207    }
208
209    #[test]
210    fn atlassian_subcommands_jira_variant() {
211        let cmd = AtlassianCommand {
212            command: AtlassianSubcommands::Jira(jira::JiraCommand {
213                command: jira::JiraSubcommands::Edit(jira::edit::EditCommand {
214                    key: "PROJ-1".to_string(),
215                }),
216                instance: crate::cli::atlassian::InstanceArg::default(),
217            }),
218        };
219        assert!(matches!(cmd.command, AtlassianSubcommands::Jira(_)));
220    }
221
222    #[test]
223    fn atlassian_subcommands_confluence_variant() {
224        let cmd = AtlassianCommand {
225            command: AtlassianSubcommands::Confluence(confluence::ConfluenceCommand {
226                command: confluence::ConfluenceSubcommands::Edit(confluence::edit::EditCommand {
227                    id: "12345".to_string(),
228                }),
229                instance: crate::cli::atlassian::InstanceArg::default(),
230            }),
231        };
232        assert!(matches!(cmd.command, AtlassianSubcommands::Confluence(_)));
233    }
234
235    #[test]
236    fn atlassian_subcommands_auth_variant() {
237        let cmd = AtlassianCommand {
238            command: AtlassianSubcommands::Auth(auth::AuthCommand {
239                command: auth::AuthSubcommands::Login(auth::LoginCommand),
240            }),
241        };
242        assert!(matches!(cmd.command, AtlassianSubcommands::Auth(_)));
243    }
244
245    #[test]
246    fn atlassian_subcommands_convert_variant() {
247        let cmd = AtlassianCommand {
248            command: AtlassianSubcommands::Convert(convert::ConvertCommand {
249                command: convert::ConvertSubcommands::FromAdf(convert::FromAdfCommand {
250                    file: None,
251                    strip_local_ids: false,
252                }),
253            }),
254        };
255        assert!(matches!(cmd.command, AtlassianSubcommands::Convert(_)));
256    }
257}