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<()> {
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<()> {
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,
),
)
}