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