Skip to main content

torii_lib/platforms/bitbucket/
pr.rs

1//! Bitbucket Cloud — pr client.
2
3use 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    /// Bitbucket accepts either `Basic base64(user:apppwd)` for app
30    /// passwords or `Bearer <oauth>` for OAuth tokens. Heuristic: if the
31    /// stored value contains `:`, treat it as `user:pass`; otherwise
32    /// pass it through as a bearer token.
33    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
44/// Translate torii's normalised state (`open`/`closed`/`merged`/`all`)
45/// into Bitbucket's uppercase enum. `closed` maps to DECLINED because
46/// MERGED is a distinct state on Bitbucket.
47fn 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            // Bitbucket's `fast_forward` is the closest analog to git rebase
124            // for a PR merge — it preserves linear history.
125            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        // Bitbucket closes PRs by "declining" them.
139        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        // Normalise back to lowercase to match the rest of torii.
205        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, // Bitbucket doesn't surface a mergeable flag on the list endpoint.
230        created_at: json["created_on"].as_str().unwrap_or("").to_string(),
231    })
232}
233
234// ============================================================================
235// Azure DevOps Repos
236// ============================================================================
237//
238// Azure DevOps uses a 3-level path (`org/project/repo`). The URL
239// parser packs `org/project` into the `owner` slot and the repo name
240// into `repo`; [`split_azure_owner`] unpacks them.
241//
242// Auth: a Personal Access Token (PAT) sent as Basic auth with an
243// empty username — i.e. `Authorization: Basic base64(":PAT")`.
244//
245// Every call needs an `api-version` query parameter; we use `7.0` as
246// the GA baseline. Newer endpoints may require `7.1-preview`; we
247// stick to 7.0 for the surface we expose.
248
249// The client's URLs are hardcoded to api.bitbucket.org, so only the
250// parsing layer is testable without touching production code.
251#[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        // display_name wins over username when both are present.
280        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        // Unknown states pass through lowercased.
302        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        // Falls back to username when display_name is absent.
319        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        // `list` reads Bitbucket's `{"values": [...]}` page shape.
330        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}