atlassian-cli 0.9.0

Unified CLI for Atlassian Cloud products
use anyhow::{Context, Result};
use atlassian_cli_api::pagination::{fetch_paged, BitbucketPage, PageLimits};
use serde::{Deserialize, Serialize};

use super::utils::{encode_path_segment, warn_if_truncated_with, BitbucketContext};
use crate::commands::common::{render_success, MutationResult};

#[derive(Deserialize)]
struct Webhook {
    uuid: String,
    url: String,
    #[serde(default)]
    description: Option<String>,
    #[serde(default)]
    active: bool,
    #[serde(default)]
    events: Vec<String>,
}

#[derive(Deserialize)]
struct SshKey {
    uuid: String,
    #[serde(default)]
    label: Option<String>,
    #[serde(default)]
    key: Option<String>,
}

pub async fn list_webhooks(
    ctx: &BitbucketContext<'_>,
    workspace: &str,
    repo_slug: &str,
) -> Result<()> {
    // No pagelen and no cursor follow: Bitbucket's default page bounded this,
    // so a repository with many hooks silently reported a subset.
    let path = format!("/2.0/repositories/{workspace}/{repo_slug}/hooks?pagelen=100");

    let (webhooks, page) =
        fetch_paged::<BitbucketPage<Webhook>>(&ctx.client, &path, PageLimits::new(None))
            .await
            .with_context(|| format!("Failed to list webhooks for {workspace}/{repo_slug}"))?;
    warn_if_truncated_with(&page, webhooks.len(), "webhooks", false);

    #[derive(Serialize)]
    struct Row<'a> {
        uuid: &'a str,
        url: &'a str,
        active: bool,
        events_count: usize,
        description: &'a str,
    }

    let rows: Vec<Row<'_>> = webhooks
        .iter()
        .map(|webhook| Row {
            uuid: webhook.uuid.as_str(),
            url: webhook.url.as_str(),
            active: webhook.active,
            events_count: webhook.events.len(),
            description: webhook.description.as_deref().unwrap_or(""),
        })
        .collect();

    if rows.is_empty() {
        tracing::info!(workspace, repo_slug, "No webhooks found");
    }

    ctx.renderer
        .render_list_or_empty(&rows, "No webhooks found")
}

pub async fn create_webhook(
    ctx: &BitbucketContext<'_>,
    workspace: &str,
    repo_slug: &str,
    url: &str,
    description: Option<&str>,
    events: Vec<String>,
    active: bool,
) -> Result<()> {
    let mut payload = serde_json::json!({
        "url": url,
        "active": active,
        "events": events
    });

    if let Some(desc) = description {
        payload["description"] = serde_json::json!(desc);
    }

    let path = format!("/2.0/repositories/{workspace}/{repo_slug}/hooks");
    let webhook: Webhook = ctx
        .client
        .post(&path, &payload)
        .await
        .with_context(|| format!("Failed to create webhook on {workspace}/{repo_slug}"))?;

    tracing::info!(
        webhook_uuid = webhook.uuid.as_str(),
        url,
        workspace,
        repo_slug,
        "Webhook created successfully"
    );

    #[derive(Serialize)]
    struct Created<'a> {
        uuid: &'a str,
        url: &'a str,
        active: bool,
        events_count: usize,
    }

    let created = Created {
        uuid: webhook.uuid.as_str(),
        url: webhook.url.as_str(),
        active: webhook.active,
        events_count: webhook.events.len(),
    };

    ctx.renderer.render(&created)
}

pub async fn delete_webhook(
    ctx: &BitbucketContext<'_>,
    workspace: &str,
    repo_slug: &str,
    webhook_uuid: &str,
) -> Result<()> {
    // A uuid of `..` normalises to the repository endpoint -- a delete with no
    // confirmation at all -- and one carrying `#` truncates to a different hook.
    // The slug is encoded too, not just the uuid. A slug of `r#x` truncates the
    // path to the repository endpoint, turning "delete webhook" -- which has no
    // confirmation prompt -- into a repository delete.
    let path = format!(
        "/2.0/repositories/{}/{}/hooks/{}",
        encode_path_segment(workspace)?,
        encode_path_segment(repo_slug)?,
        encode_path_segment(webhook_uuid)?
    );
    let _: serde_json::Value = ctx.client.delete(&path).await.with_context(|| {
        format!("Failed to delete webhook {webhook_uuid} from {workspace}/{repo_slug}")
    })?;

    tracing::info!(
        webhook_uuid,
        workspace,
        repo_slug,
        "Webhook deleted successfully"
    );

    render_success(
        ctx.renderer,
        &format!("✅ Webhook {webhook_uuid} deleted from {workspace}/{repo_slug}"),
        &MutationResult::with_id(
            format!("Webhook deleted from {workspace}/{repo_slug}"),
            webhook_uuid,
        ),
    )
}

pub async fn list_ssh_keys(
    ctx: &BitbucketContext<'_>,
    workspace: &str,
    repo_slug: &str,
) -> Result<()> {
    let path = format!("/2.0/repositories/{workspace}/{repo_slug}/deploy-keys?pagelen=100");

    let (keys, page) =
        fetch_paged::<BitbucketPage<SshKey>>(&ctx.client, &path, PageLimits::new(None))
            .await
            .with_context(|| format!("Failed to list SSH keys for {workspace}/{repo_slug}"))?;
    warn_if_truncated_with(&page, keys.len(), "SSH keys", false);

    #[derive(Serialize)]
    struct Row<'a> {
        uuid: &'a str,
        label: &'a str,
        key_preview: &'a str,
    }

    let rows: Vec<Row<'_>> = keys
        .iter()
        .map(|key| Row {
            uuid: key.uuid.as_str(),
            label: key.label.as_deref().unwrap_or(""),
            key_preview: key
                .key
                .as_deref()
                .map(|k| {
                    let preview: String = k.chars().take(40).collect();
                    preview.leak() as &str
                })
                .unwrap_or(""),
        })
        .collect();

    if rows.is_empty() {
        tracing::info!(workspace, repo_slug, "No SSH keys found");
    }

    ctx.renderer
        .render_list_or_empty(&rows, "No SSH keys found")
}

pub async fn add_ssh_key(
    ctx: &BitbucketContext<'_>,
    workspace: &str,
    repo_slug: &str,
    label: &str,
    key: &str,
) -> Result<()> {
    let payload = serde_json::json!({
        "label": label,
        "key": key
    });

    let path = format!("/2.0/repositories/{workspace}/{repo_slug}/deploy-keys");
    let ssh_key: SshKey = ctx
        .client
        .post(&path, &payload)
        .await
        .with_context(|| format!("Failed to add SSH key to {workspace}/{repo_slug}"))?;

    tracing::info!(
        key_uuid = ssh_key.uuid.as_str(),
        label,
        workspace,
        repo_slug,
        "SSH key added successfully"
    );

    render_success(
        ctx.renderer,
        &format!("✅ SSH key '{label}' added to {workspace}/{repo_slug}"),
        &MutationResult::new(format!(
            "SSH key '{label}' added to {workspace}/{repo_slug}"
        )),
    )
}

pub async fn delete_ssh_key(
    ctx: &BitbucketContext<'_>,
    workspace: &str,
    repo_slug: &str,
    key_uuid: &str,
) -> Result<()> {
    let path = format!(
        "/2.0/repositories/{}/{}/deploy-keys/{}",
        encode_path_segment(workspace)?,
        encode_path_segment(repo_slug)?,
        encode_path_segment(key_uuid)?
    );
    let _: serde_json::Value = ctx.client.delete(&path).await.with_context(|| {
        format!("Failed to delete SSH key {key_uuid} from {workspace}/{repo_slug}")
    })?;

    tracing::info!(
        key_uuid,
        workspace,
        repo_slug,
        "SSH key deleted successfully"
    );

    render_success(
        ctx.renderer,
        &format!("✅ SSH key {key_uuid} deleted from {workspace}/{repo_slug}"),
        &MutationResult::with_id(
            format!("SSH key deleted from {workspace}/{repo_slug}"),
            key_uuid,
        ),
    )
}