1use crate::error::{Result, ToriiError};
4use crate::platforms::pr::*;
5use reqwest::blocking::Client;
6
7pub struct BitbucketPrClient {
8 token: String,
9}
10
11impl BitbucketPrClient {
12 pub fn new() -> Result<Self> {
13 let token = crate::auth::resolve_token("bitbucket", ".")
14 .value
15 .ok_or_else(|| ToriiError::Auth {
16 provider: "bitbucket".into(),
17 message: "Bitbucket token not found. Create an app password at \
18 https://bitbucket.org/account/settings/app-passwords/ \
19 and run: torii auth set bitbucket USERNAME:APP_PASSWORD"
20 .to_string(),
21 })?;
22 Ok(Self { token })
23 }
24
25 fn client(&self) -> Client {
26 crate::http::make_client()
27 }
28
29 fn auth(&self) -> String {
34 if self.token.contains(':') {
35 use base64::Engine;
36 let b64 = base64::engine::general_purpose::STANDARD.encode(&self.token);
37 format!("Basic {}", b64)
38 } else {
39 format!("Bearer {}", self.token)
40 }
41 }
42}
43
44fn bitbucket_state(state: &str) -> &'static str {
48 match state {
49 "open" => "OPEN",
50 "closed" => "DECLINED",
51 "merged" => "MERGED",
52 _ => "OPEN",
53 }
54}
55
56impl PrClient for BitbucketPrClient {
57 fn create(&self, owner: &str, repo: &str, opts: CreatePrOptions) -> Result<PullRequest> {
58 let url = format!(
59 "https://api.bitbucket.org/2.0/repositories/{}/{}/pullrequests",
60 owner, repo
61 );
62 let body = serde_json::json!({
63 "title": opts.title,
64 "description": opts.body.unwrap_or_default(),
65 "source": { "branch": { "name": opts.head } },
66 "destination": { "branch": { "name": opts.base } },
67 "draft": opts.draft,
68 });
69 let req = self
70 .client()
71 .post(&url)
72 .header("Authorization", self.auth())
73 .header("Accept", "application/json")
74 .json(&body);
75 let json = crate::http::send_json(req, "Bitbucket create PR")?;
76 parse_bitbucket_pr(&json)
77 }
78
79 fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<PullRequest>> {
80 let url = format!(
81 "https://api.bitbucket.org/2.0/repositories/{}/{}/pullrequests?state={}&pagelen=50",
82 owner,
83 repo,
84 bitbucket_state(state)
85 );
86 let req = self
87 .client()
88 .get(&url)
89 .header("Authorization", self.auth())
90 .header("Accept", "application/json");
91 let json = crate::http::send_json(req, &format!("Bitbucket (url: {})", url))?;
92 let arr = json["values"]
93 .as_array()
94 .ok_or_else(|| ToriiError::MalformedResponse {
95 provider: "bitbucket".into(),
96 message: format!("Bitbucket returned no `values` array. Body: {}", json),
97 })?;
98 arr.iter().map(parse_bitbucket_pr).collect()
99 }
100
101 fn get(&self, owner: &str, repo: &str, number: u64) -> Result<PullRequest> {
102 let url = format!(
103 "https://api.bitbucket.org/2.0/repositories/{}/{}/pullrequests/{}",
104 owner, repo, number
105 );
106 let req = self
107 .client()
108 .get(&url)
109 .header("Authorization", self.auth())
110 .header("Accept", "application/json");
111 let json = crate::http::send_json(req, &format!("Bitbucket PR #{}", number))?;
112 parse_bitbucket_pr(&json)
113 }
114
115 fn merge(&self, owner: &str, repo: &str, number: u64, method: MergeMethod) -> Result<()> {
116 let url = format!(
117 "https://api.bitbucket.org/2.0/repositories/{}/{}/pullrequests/{}/merge",
118 owner, repo, number
119 );
120 let strategy = match method {
121 MergeMethod::Merge => "merge_commit",
122 MergeMethod::Squash => "squash",
123 MergeMethod::Rebase => "fast_forward",
126 };
127 let body = serde_json::json!({ "merge_strategy": strategy });
128 let req = self
129 .client()
130 .post(&url)
131 .header("Authorization", self.auth())
132 .header("Accept", "application/json")
133 .json(&body);
134 crate::http::send_empty(req, "Bitbucket merge PR")
135 }
136
137 fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
138 let url = format!(
140 "https://api.bitbucket.org/2.0/repositories/{}/{}/pullrequests/{}/decline",
141 owner, repo, number
142 );
143 let req = self
144 .client()
145 .post(&url)
146 .header("Authorization", self.auth())
147 .header("Accept", "application/json");
148 crate::http::send_empty(req, "Bitbucket decline PR")
149 }
150
151 fn update(&self, owner: &str, repo: &str, number: u64, opts: UpdatePrOptions) -> Result<()> {
152 let url = format!(
153 "https://api.bitbucket.org/2.0/repositories/{}/{}/pullrequests/{}",
154 owner, repo, number
155 );
156 let mut body = serde_json::Map::new();
157 if let Some(t) = opts.title {
158 body.insert("title".into(), serde_json::Value::String(t));
159 }
160 if let Some(b) = opts.body {
161 body.insert("description".into(), serde_json::Value::String(b));
162 }
163 if let Some(base) = opts.base {
164 body.insert(
165 "destination".into(),
166 serde_json::json!({ "branch": { "name": base } }),
167 );
168 }
169 if body.is_empty() {
170 return Ok(());
171 }
172 let req = self
173 .client()
174 .put(&url)
175 .header("Authorization", self.auth())
176 .header("Accept", "application/json")
177 .json(&serde_json::Value::Object(body));
178 crate::http::send_empty(req, "Bitbucket update PR")
179 }
180
181 fn delete_branch(&self, owner: &str, repo: &str, branch: &str) -> Result<()> {
182 let url = format!(
183 "https://api.bitbucket.org/2.0/repositories/{}/{}/refs/branches/{}",
184 owner, repo, branch
185 );
186 let req = self
187 .client()
188 .delete(&url)
189 .header("Authorization", self.auth())
190 .header("Accept", "application/json");
191 crate::http::send_empty(req, "Bitbucket delete branch")
192 }
193
194 fn checkout_branch(&self, pr: &PullRequest) -> String {
195 pr.head.clone()
196 }
197}
198
199fn parse_bitbucket_pr(json: &serde_json::Value) -> Result<PullRequest> {
200 Ok(PullRequest {
201 number: json["id"].as_u64().unwrap_or(0),
202 title: json["title"].as_str().unwrap_or("").to_string(),
203 body: json["description"].as_str().map(String::from),
204 state: match json["state"].as_str().unwrap_or("") {
206 "OPEN" => "open".to_string(),
207 "MERGED" => "merged".to_string(),
208 "DECLINED" => "closed".to_string(),
209 other => other.to_lowercase(),
210 },
211 head: json["source"]["branch"]["name"]
212 .as_str()
213 .unwrap_or("")
214 .to_string(),
215 base: json["destination"]["branch"]["name"]
216 .as_str()
217 .unwrap_or("")
218 .to_string(),
219 author: json["author"]["display_name"]
220 .as_str()
221 .or_else(|| json["author"]["username"].as_str())
222 .unwrap_or("")
223 .to_string(),
224 url: json["links"]["html"]["href"]
225 .as_str()
226 .unwrap_or("")
227 .to_string(),
228 draft: json["draft"].as_bool().unwrap_or(false),
229 mergeable: None, created_at: json["created_on"].as_str().unwrap_or("").to_string(),
231 })
232}
233
234#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn pr_json(id: u64, state: &str) -> serde_json::Value {
256 serde_json::json!({
257 "id": id,
258 "title": "Add login flow",
259 "description": "implements OAuth",
260 "state": state,
261 "source": { "branch": { "name": "feature/login" } },
262 "destination": { "branch": { "name": "main" } },
263 "author": { "display_name": "Alice Doe", "username": "alice" },
264 "links": { "html": { "href": "https://bitbucket.org/w/r/pull-requests/3" } },
265 "draft": true,
266 "created_on": "2026-04-05T06:07:08.123456+00:00",
267 })
268 }
269
270 #[test]
271 fn parse_bitbucket_pr_extracts_all_fields() {
272 let pr = parse_bitbucket_pr(&pr_json(3, "OPEN")).unwrap();
273 assert_eq!(pr.number, 3);
274 assert_eq!(pr.title, "Add login flow");
275 assert_eq!(pr.body.as_deref(), Some("implements OAuth"));
276 assert_eq!(pr.state, "open");
277 assert_eq!(pr.head, "feature/login");
278 assert_eq!(pr.base, "main");
279 assert_eq!(pr.author, "Alice Doe");
281 assert_eq!(pr.url, "https://bitbucket.org/w/r/pull-requests/3");
282 assert!(pr.draft);
283 assert_eq!(pr.mergeable, None);
284 assert_eq!(pr.created_at, "2026-04-05T06:07:08.123456+00:00");
285 }
286
287 #[test]
288 fn parse_bitbucket_pr_normalizes_states_to_lowercase() {
289 assert_eq!(
290 parse_bitbucket_pr(&pr_json(1, "OPEN")).unwrap().state,
291 "open"
292 );
293 assert_eq!(
294 parse_bitbucket_pr(&pr_json(1, "MERGED")).unwrap().state,
295 "merged"
296 );
297 assert_eq!(
298 parse_bitbucket_pr(&pr_json(1, "DECLINED")).unwrap().state,
299 "closed"
300 );
301 assert_eq!(
303 parse_bitbucket_pr(&pr_json(1, "SUPERSEDED")).unwrap().state,
304 "superseded"
305 );
306 }
307
308 #[test]
309 fn parse_bitbucket_pr_defaults_when_optionals_missing() {
310 let json = serde_json::json!({
311 "id": 9,
312 "title": "t",
313 "state": "OPEN",
314 "author": { "username": "bob" },
315 });
316 let pr = parse_bitbucket_pr(&json).unwrap();
317 assert_eq!(pr.body, None);
318 assert_eq!(pr.author, "bob");
320 assert!(!pr.draft);
321 assert_eq!(pr.head, "");
322 assert_eq!(pr.base, "");
323 assert_eq!(pr.url, "");
324 assert_eq!(pr.created_at, "");
325 }
326
327 #[test]
328 fn parses_prs_out_of_paginated_values_envelope() {
329 let page = serde_json::json!({
331 "pagelen": 50,
332 "size": 2,
333 "values": [pr_json(1, "OPEN"), pr_json(2, "MERGED")],
334 });
335 let prs: Vec<PullRequest> = page["values"]
336 .as_array()
337 .unwrap()
338 .iter()
339 .map(|v| parse_bitbucket_pr(v).unwrap())
340 .collect();
341 assert_eq!(prs.len(), 2);
342 assert_eq!(prs[0].number, 1);
343 assert_eq!(prs[1].state, "merged");
344 }
345
346 #[test]
347 fn bitbucket_state_maps_normalized_states_to_uppercase_enum() {
348 assert_eq!(bitbucket_state("open"), "OPEN");
349 assert_eq!(bitbucket_state("closed"), "DECLINED");
350 assert_eq!(bitbucket_state("merged"), "MERGED");
351 assert_eq!(bitbucket_state("anything-else"), "OPEN");
352 }
353}