omni-dev 0.37.0

AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, and Datadog.
Documentation
//! CLI commands for incremental JIRA issue label management.
//!
//! Unlike `jira write --set labels=…` (which replaces the whole array), these
//! commands add or remove individual labels via the JIRA `update` verb, leaving
//! the issue's other labels untouched.

use anyhow::Result;
use clap::{Parser, Subcommand};

use crate::cli::atlassian::helpers::create_client;

/// Adds or removes labels on a JIRA issue incrementally.
#[derive(Parser)]
pub struct LabelCommand {
    /// The label subcommand to execute.
    #[command(subcommand)]
    pub command: LabelSubcommands,
}

/// Label subcommands.
#[derive(Subcommand)]
pub enum LabelSubcommands {
    /// Adds one or more labels to a JIRA issue (mirrors the `jira_label_add` MCP tool).
    Add(AddCommand),
    /// Removes one or more labels from a JIRA issue (mirrors the `jira_label_remove` MCP tool).
    Remove(RemoveCommand),
}

impl LabelCommand {
    /// Executes the label command.
    pub async fn execute(self) -> Result<()> {
        match self.command {
            LabelSubcommands::Add(cmd) => cmd.execute().await,
            LabelSubcommands::Remove(cmd) => cmd.execute().await,
        }
    }
}

/// Adds labels to a JIRA issue.
#[derive(Parser)]
pub struct AddCommand {
    /// JIRA issue key (e.g., PROJ-123).
    pub key: String,

    /// Labels to add (comma-separated). JIRA labels cannot contain spaces.
    #[arg(long, value_delimiter = ',')]
    pub labels: Vec<String>,
}

impl AddCommand {
    /// Adds the labels.
    pub async fn execute(self) -> Result<()> {
        if self.labels.is_empty() {
            anyhow::bail!("No labels supplied: pass --labels a,b,c");
        }
        let (client, _instance_url) = create_client()?;
        client
            .modify_issue_labels(&self.key, &self.labels, &[])
            .await?;
        println!("Added label(s) {} to {}.", self.labels.join(", "), self.key);
        Ok(())
    }
}

/// Removes labels from a JIRA issue.
#[derive(Parser)]
pub struct RemoveCommand {
    /// JIRA issue key (e.g., PROJ-123).
    pub key: String,

    /// Labels to remove (comma-separated).
    #[arg(long, value_delimiter = ',')]
    pub labels: Vec<String>,
}

impl RemoveCommand {
    /// Removes the labels.
    pub async fn execute(self) -> Result<()> {
        if self.labels.is_empty() {
            anyhow::bail!("No labels supplied: pass --labels a,b,c");
        }
        let (client, _instance_url) = create_client()?;
        client
            .modify_issue_labels(&self.key, &[], &self.labels)
            .await?;
        println!(
            "Removed label(s) {} from {}.",
            self.labels.join(", "),
            self.key
        );
        Ok(())
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    #[test]
    fn label_command_add_variant() {
        let cmd = LabelCommand {
            command: LabelSubcommands::Add(AddCommand {
                key: "PROJ-1".to_string(),
                labels: vec!["backend".to_string()],
            }),
        };
        assert!(matches!(cmd.command, LabelSubcommands::Add(_)));
    }

    #[test]
    fn label_command_remove_variant() {
        let cmd = LabelCommand {
            command: LabelSubcommands::Remove(RemoveCommand {
                key: "PROJ-1".to_string(),
                labels: vec!["stale".to_string()],
            }),
        };
        assert!(matches!(cmd.command, LabelSubcommands::Remove(_)));
    }

    #[tokio::test]
    async fn add_with_no_labels_errors_before_client() {
        let cmd = AddCommand {
            key: "PROJ-1".to_string(),
            labels: vec![],
        };
        let err = cmd.execute().await.unwrap_err();
        assert!(err.to_string().contains("No labels supplied"));
    }

    #[tokio::test]
    async fn remove_with_no_labels_errors_before_client() {
        let cmd = RemoveCommand {
            key: "PROJ-1".to_string(),
            labels: vec![],
        };
        let err = cmd.execute().await.unwrap_err();
        assert!(err.to_string().contains("No labels supplied"));
    }

    // ── execute() end-to-end (drives create_client + the PUT) ──

    fn issue_put_mock() -> wiremock::Mock {
        wiremock::Mock::given(wiremock::matchers::method("PUT"))
            .and(wiremock::matchers::path("/rest/api/3/issue/PROJ-1"))
            .respond_with(wiremock::ResponseTemplate::new(204))
            .expect(1)
    }

    #[tokio::test]
    async fn add_execute_drives_create_client_and_puts() {
        use crate::test_support::atlassian_env::AtlassianEnvGuard;
        let server = wiremock::MockServer::start().await;
        issue_put_mock().mount(&server).await;
        let _env = AtlassianEnvGuard::new(&server.uri(), "u@t.com", "tok");
        AddCommand {
            key: "PROJ-1".to_string(),
            labels: vec!["backend".to_string(), "reviewed".to_string()],
        }
        .execute()
        .await
        .unwrap();
    }

    #[tokio::test]
    async fn remove_execute_drives_create_client_and_puts() {
        use crate::test_support::atlassian_env::AtlassianEnvGuard;
        let server = wiremock::MockServer::start().await;
        issue_put_mock().mount(&server).await;
        let _env = AtlassianEnvGuard::new(&server.uri(), "u@t.com", "tok");
        // Routed through the parent so the `Remove` dispatch arm is covered too.
        LabelCommand {
            command: LabelSubcommands::Remove(RemoveCommand {
                key: "PROJ-1".to_string(),
                labels: vec!["stale".to_string()],
            }),
        }
        .execute()
        .await
        .unwrap();
    }
}