cascade_cli/bitbucket/
client.rs

1use crate::cli::output::Output;
2use crate::config::BitbucketConfig;
3use crate::errors::{CascadeError, Result};
4use base64::Engine;
5use reqwest::{
6    header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE},
7    Client,
8};
9use serde::{Deserialize, Serialize};
10use std::time::Duration;
11use tracing::{debug, trace};
12
13/// Bitbucket Server API client
14pub struct BitbucketClient {
15    client: Client,
16    base_url: String,
17    project_key: String,
18    repo_slug: String,
19}
20
21impl BitbucketClient {
22    /// Create a new Bitbucket client
23    pub fn new(config: &BitbucketConfig) -> Result<Self> {
24        let mut headers = HeaderMap::new();
25
26        // Set up authentication
27        let auth_header = match (&config.username, &config.token) {
28            (Some(username), Some(token)) => {
29                let auth_string = format!("{username}:{token}");
30                let auth_encoded = base64::engine::general_purpose::STANDARD.encode(auth_string);
31                format!("Basic {auth_encoded}")
32            }
33            (None, Some(token)) => {
34                format!("Bearer {token}")
35            }
36            _ => {
37                return Err(CascadeError::config(
38                    "Bitbucket authentication credentials not configured",
39                ))
40            }
41        };
42
43        headers.insert(
44            AUTHORIZATION,
45            HeaderValue::from_str(&auth_header)
46                .map_err(|e| CascadeError::config(format!("Invalid auth header: {e}")))?,
47        );
48
49        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
50
51        let mut client_builder = Client::builder()
52            .timeout(Duration::from_secs(30))
53            .default_headers(headers);
54
55        // Add TLS configuration for corporate environments
56        if let Some(accept_invalid_certs) = config.accept_invalid_certs {
57            if accept_invalid_certs {
58                Output::warning(
59                    "⚠️  Accepting invalid TLS certificates - use only in development!",
60                );
61                client_builder = client_builder.danger_accept_invalid_certs(true);
62            }
63        }
64
65        // Add custom CA bundle if specified
66        if let Some(ca_bundle_path) = &config.ca_bundle_path {
67            let ca_bundle = std::fs::read(ca_bundle_path).map_err(|e| {
68                CascadeError::config(format!(
69                    "Failed to read CA bundle from {ca_bundle_path}: {e}"
70                ))
71            })?;
72
73            let cert = reqwest::Certificate::from_pem(&ca_bundle).map_err(|e| {
74                CascadeError::config(format!("Invalid CA certificate in {ca_bundle_path}: {e}"))
75            })?;
76
77            client_builder = client_builder.add_root_certificate(cert);
78            Output::info(format!("Using custom CA bundle: {ca_bundle_path}"));
79        }
80
81        let client = client_builder
82            .build()
83            .map_err(|e| CascadeError::config(format!("Failed to create HTTP client: {e}")))?;
84
85        Ok(Self {
86            client,
87            base_url: config.url.clone(),
88            project_key: config.project.clone(),
89            repo_slug: config.repo.clone(),
90        })
91    }
92
93    /// Get the base API URL for this repository
94    fn api_url(&self, path: &str) -> String {
95        format!(
96            "{}/rest/api/1.0/projects/{}/repos/{}/{}",
97            self.base_url.trim_end_matches('/'),
98            self.project_key,
99            self.repo_slug,
100            path.trim_start_matches('/')
101        )
102    }
103
104    /// Make a GET request to the Bitbucket API
105    pub async fn get<T>(&self, path: &str) -> Result<T>
106    where
107        T: for<'de> Deserialize<'de>,
108    {
109        let url = self.api_url(path);
110        debug!("GET {}", url);
111
112        let response = self
113            .client
114            .get(&url)
115            .send()
116            .await
117            .map_err(|e| CascadeError::bitbucket(format!("GET request failed: {e}")))?;
118
119        self.handle_response(response).await
120    }
121
122    /// Make a POST request to the Bitbucket API
123    pub async fn post<T, U>(&self, path: &str, body: &T) -> Result<U>
124    where
125        T: Serialize,
126        U: for<'de> Deserialize<'de>,
127    {
128        let url = self.api_url(path);
129        debug!("POST {}", url);
130
131        let response = self
132            .client
133            .post(&url)
134            .json(body)
135            .send()
136            .await
137            .map_err(|e| CascadeError::bitbucket(format!("POST request failed: {e}")))?;
138
139        self.handle_response(response).await
140    }
141
142    /// Make a PUT request to the Bitbucket API
143    pub async fn put<T, U>(&self, path: &str, body: &T) -> Result<U>
144    where
145        T: Serialize,
146        U: for<'de> Deserialize<'de>,
147    {
148        let url = self.api_url(path);
149        debug!("PUT {}", url);
150
151        let response = self
152            .client
153            .put(&url)
154            .json(body)
155            .send()
156            .await
157            .map_err(|e| CascadeError::bitbucket(format!("PUT request failed: {e}")))?;
158
159        self.handle_response(response).await
160    }
161
162    /// Fetch build statuses via the build-status API (base path differs from core REST API)
163    pub async fn get_build_statuses<T>(&self, commit_hash: &str) -> Result<T>
164    where
165        T: for<'de> Deserialize<'de>,
166    {
167        let url = format!(
168            "{}/rest/build-status/1.0/commits/{}",
169            self.base_url.trim_end_matches('/'),
170            commit_hash
171        );
172        debug!("GET {}", url);
173
174        let response = self
175            .client
176            .get(&url)
177            .send()
178            .await
179            .map_err(|e| CascadeError::bitbucket(format!("GET request failed: {e}")))?;
180
181        self.handle_response(response).await
182    }
183
184    /// Make a DELETE request to the Bitbucket API
185    pub async fn delete(&self, path: &str) -> Result<()> {
186        let url = self.api_url(path);
187        debug!("DELETE {}", url);
188
189        let response = self
190            .client
191            .delete(&url)
192            .send()
193            .await
194            .map_err(|e| CascadeError::bitbucket(format!("DELETE request failed: {e}")))?;
195
196        if response.status().is_success() {
197            Ok(())
198        } else {
199            let status = response.status();
200            let text = response
201                .text()
202                .await
203                .unwrap_or_else(|_| "Unknown error".to_string());
204            Err(CascadeError::bitbucket(format!(
205                "DELETE failed with status {status}: {text}"
206            )))
207        }
208    }
209
210    /// Handle HTTP response and deserialize JSON
211    async fn handle_response<T>(&self, response: reqwest::Response) -> Result<T>
212    where
213        T: for<'de> Deserialize<'de>,
214    {
215        let status = response.status();
216
217        if status.is_success() {
218            let text = response.text().await.map_err(|e| {
219                CascadeError::bitbucket(format!("Failed to read response body: {e}"))
220            })?;
221
222            trace!("Response body: {}", text);
223
224            serde_json::from_str(&text)
225                .map_err(|e| CascadeError::bitbucket(format!("Failed to parse JSON response: {e}")))
226        } else {
227            let text = response
228                .text()
229                .await
230                .unwrap_or_else(|_| "Unknown error".to_string());
231            Err(CascadeError::bitbucket(format!(
232                "Request failed with status {status}: {text}"
233            )))
234        }
235    }
236
237    /// Test the connection to Bitbucket Server
238    pub async fn test_connection(&self) -> Result<()> {
239        let url = format!(
240            "{}/rest/api/1.0/projects/{}/repos/{}",
241            self.base_url.trim_end_matches('/'),
242            self.project_key,
243            self.repo_slug
244        );
245
246        debug!("Testing connection to {}", url);
247
248        let response = self
249            .client
250            .get(&url)
251            .send()
252            .await
253            .map_err(|e| CascadeError::bitbucket(format!("Connection test failed: {e}")))?;
254
255        if response.status().is_success() {
256            debug!("Connection test successful");
257            Ok(())
258        } else {
259            let status = response.status();
260            let text = response
261                .text()
262                .await
263                .unwrap_or_else(|_| "Unknown error".to_string());
264            Err(CascadeError::bitbucket(format!(
265                "Connection test failed with status {status}: {text}"
266            )))
267        }
268    }
269
270    /// Get repository information
271    pub async fn get_repository_info(&self) -> Result<RepositoryInfo> {
272        self.get("").await
273    }
274}
275
276/// Repository information from Bitbucket
277#[derive(Debug, Clone, Deserialize)]
278pub struct RepositoryInfo {
279    pub id: u64,
280    pub name: String,
281    pub slug: String,
282    pub description: Option<String>,
283    pub public: bool,
284    pub project: ProjectInfo,
285    pub links: RepositoryLinks,
286}
287
288/// Project information
289#[derive(Debug, Clone, Deserialize)]
290pub struct ProjectInfo {
291    pub id: u64,
292    pub key: String,
293    pub name: String,
294    pub description: Option<String>,
295    pub public: bool,
296}
297
298/// Repository links
299#[derive(Debug, Clone, Deserialize)]
300pub struct RepositoryLinks {
301    pub clone: Vec<CloneLink>,
302    #[serde(rename = "self")]
303    pub self_link: Vec<SelfLink>,
304}
305
306/// Clone link information
307#[derive(Debug, Clone, Deserialize)]
308pub struct CloneLink {
309    pub href: String,
310    pub name: String,
311}
312
313/// Self link information
314#[derive(Debug, Clone, Deserialize)]
315pub struct SelfLink {
316    pub href: String,
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_api_url_generation() {
325        let config = BitbucketConfig {
326            url: "https://bitbucket.example.com".to_string(),
327            project: "TEST".to_string(),
328            repo: "my-repo".to_string(),
329            username: Some("user".to_string()),
330            token: Some("token".to_string()),
331            default_reviewers: Vec::new(),
332            accept_invalid_certs: None,
333            ca_bundle_path: None,
334        };
335
336        let client = BitbucketClient::new(&config).unwrap();
337
338        assert_eq!(
339            client.api_url("pull-requests"),
340            "https://bitbucket.example.com/rest/api/1.0/projects/TEST/repos/my-repo/pull-requests"
341        );
342
343        assert_eq!(
344            client.api_url("/pull-requests/123"),
345            "https://bitbucket.example.com/rest/api/1.0/projects/TEST/repos/my-repo/pull-requests/123"
346        );
347    }
348
349    #[test]
350    fn test_url_trimming() {
351        let config = BitbucketConfig {
352            url: "https://bitbucket.example.com/".to_string(), // Note trailing slash
353            project: "TEST".to_string(),
354            repo: "my-repo".to_string(),
355            username: Some("user".to_string()),
356            token: Some("token".to_string()),
357            default_reviewers: Vec::new(),
358            accept_invalid_certs: None,
359            ca_bundle_path: None,
360        };
361
362        let client = BitbucketClient::new(&config).unwrap();
363
364        assert_eq!(
365            client.api_url("pull-requests"),
366            "https://bitbucket.example.com/rest/api/1.0/projects/TEST/repos/my-repo/pull-requests"
367        );
368    }
369}