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
13pub struct BitbucketClient {
15 client: Client,
16 base_url: String,
17 project_key: String,
18 repo_slug: String,
19}
20
21impl BitbucketClient {
22 pub fn new(config: &BitbucketConfig) -> Result<Self> {
24 let mut headers = HeaderMap::new();
25
26 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 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 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 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 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 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 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 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 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 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 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 pub async fn get_repository_info(&self) -> Result<RepositoryInfo> {
272 self.get("").await
273 }
274}
275
276#[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#[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#[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#[derive(Debug, Clone, Deserialize)]
308pub struct CloneLink {
309 pub href: String,
310 pub name: String,
311}
312
313#[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(), 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}