torii_lib/platforms/bitbucket/
issue.rs1use crate::error::{Result, ToriiError};
4use crate::platforms::issue::*;
5use reqwest::blocking::Client;
6
7pub struct BitbucketIssueClient {
8 token: String,
9}
10
11impl BitbucketIssueClient {
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 fn auth(&self) -> String {
29 if self.token.contains(':') {
30 use base64::Engine;
31 let b64 = base64::engine::general_purpose::STANDARD.encode(&self.token);
32 format!("Basic {}", b64)
33 } else {
34 format!("Bearer {}", self.token)
35 }
36 }
37}
38
39impl IssueClient for BitbucketIssueClient {
40 fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<Issue>> {
41 let q = match state {
46 "open" => r#"state="new" OR state="open""#.to_string(),
47 "closed" => r#"state="resolved" OR state="closed" OR state="invalid" OR state="duplicate" OR state="wontfix""#.to_string(),
48 _ => String::new(),
49 };
50 let mut url = format!(
51 "https://api.bitbucket.org/2.0/repositories/{}/{}/issues?pagelen=50",
52 owner, repo
53 );
54 if !q.is_empty() {
55 url.push_str(&format!("&q={}", crate::url::encode(&q)));
56 }
57 let req = self
58 .client()
59 .get(&url)
60 .header("Authorization", self.auth())
61 .header("Accept", "application/json");
62 let json = crate::http::send_json(req, &format!("Bitbucket (url: {})", url))?;
63 let arr = json["values"]
64 .as_array()
65 .ok_or_else(|| ToriiError::MalformedResponse {
66 provider: "bitbucket".into(),
67 message: format!(
68 "Bitbucket returned no `values` array — does the repo have issues enabled? \
69 Body: {}",
70 json
71 ),
72 })?;
73 arr.iter().map(parse_bitbucket_issue).collect()
74 }
75
76 fn create(&self, owner: &str, repo: &str, opts: CreateIssueOptions) -> Result<Issue> {
77 let url = format!(
78 "https://api.bitbucket.org/2.0/repositories/{}/{}/issues",
79 owner, repo
80 );
81 let body = serde_json::json!({
82 "title": opts.title,
83 "content": {
84 "raw": opts.body.unwrap_or_default(),
85 "markup": "markdown",
86 },
87 });
88 let req = self
89 .client()
90 .post(&url)
91 .header("Authorization", self.auth())
92 .header("Accept", "application/json")
93 .json(&body);
94 let json = crate::http::send_json(req, "Bitbucket create issue")?;
95 parse_bitbucket_issue(&json)
96 }
97
98 fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
99 let url = format!(
100 "https://api.bitbucket.org/2.0/repositories/{}/{}/issues/{}",
101 owner, repo, number
102 );
103 let body = serde_json::json!({ "state": "resolved" });
104 let req = self
105 .client()
106 .put(&url)
107 .header("Authorization", self.auth())
108 .header("Accept", "application/json")
109 .json(&body);
110 crate::http::send_empty(req, "Bitbucket close issue")
111 }
112
113 fn comment(&self, owner: &str, repo: &str, number: u64, body: &str) -> Result<()> {
114 let url = format!(
115 "https://api.bitbucket.org/2.0/repositories/{}/{}/issues/{}/comments",
116 owner, repo, number
117 );
118 let payload = serde_json::json!({
119 "content": { "raw": body, "markup": "markdown" },
120 });
121 let req = self
122 .client()
123 .post(&url)
124 .header("Authorization", self.auth())
125 .header("Accept", "application/json")
126 .json(&payload);
127 crate::http::send_empty(req, "Bitbucket comment issue")
128 }
129}
130
131fn parse_bitbucket_issue(json: &serde_json::Value) -> Result<Issue> {
132 Ok(Issue {
133 number: json["id"].as_u64().unwrap_or(0),
134 title: json["title"].as_str().unwrap_or("").to_string(),
135 body: json["content"]["raw"].as_str().map(String::from),
136 state: match json["state"].as_str().unwrap_or("") {
137 "new" | "open" => "open".to_string(),
138 "resolved" | "closed" | "invalid" | "duplicate" | "wontfix" => "closed".to_string(),
139 other => other.to_string(),
140 },
141 author: json["reporter"]["display_name"]
142 .as_str()
143 .or_else(|| json["reporter"]["username"].as_str())
144 .unwrap_or("")
145 .to_string(),
146 url: json["links"]["html"]["href"]
147 .as_str()
148 .unwrap_or("")
149 .to_string(),
150 labels: json["kind"]
151 .as_str()
152 .map(|k| vec![k.to_string()])
153 .unwrap_or_default(),
154 assignees: json["assignee"]["display_name"]
155 .as_str()
156 .or_else(|| json["assignee"]["username"].as_str())
157 .map(|s| vec![s.to_string()])
158 .unwrap_or_default(),
159 created_at: json["created_on"].as_str().unwrap_or("").to_string(),
160 comments: 0,
161 })
162}
163
164#[cfg(test)]
179mod tests {
180 use super::*;
181
182 fn issue_json(id: u64, state: &str) -> serde_json::Value {
183 serde_json::json!({
184 "id": id,
185 "title": "Crash on save",
186 "content": { "raw": "steps to reproduce", "markup": "markdown" },
187 "state": state,
188 "kind": "bug",
189 "reporter": { "display_name": "Bob Smith", "username": "bob" },
190 "assignee": { "display_name": "Alice Doe", "username": "alice" },
191 "links": { "html": { "href": "https://bitbucket.org/w/r/issues/14" } },
192 "created_on": "2026-05-06T07:08:09.000000+00:00",
193 })
194 }
195
196 #[test]
197 fn parse_bitbucket_issue_extracts_all_fields() {
198 let i = parse_bitbucket_issue(&issue_json(14, "new")).unwrap();
199 assert_eq!(i.number, 14);
200 assert_eq!(i.title, "Crash on save");
201 assert_eq!(i.body.as_deref(), Some("steps to reproduce"));
202 assert_eq!(i.state, "open");
203 assert_eq!(i.author, "Bob Smith");
204 assert_eq!(i.url, "https://bitbucket.org/w/r/issues/14");
205 assert_eq!(i.labels, vec!["bug".to_string()]);
207 assert_eq!(i.assignees, vec!["Alice Doe".to_string()]);
208 assert_eq!(i.created_at, "2026-05-06T07:08:09.000000+00:00");
209 assert_eq!(i.comments, 0);
210 }
211
212 #[test]
213 fn parse_bitbucket_issue_collapses_states() {
214 for s in ["new", "open"] {
215 assert_eq!(
216 parse_bitbucket_issue(&issue_json(1, s)).unwrap().state,
217 "open"
218 );
219 }
220 for s in ["resolved", "closed", "invalid", "duplicate", "wontfix"] {
221 assert_eq!(
222 parse_bitbucket_issue(&issue_json(1, s)).unwrap().state,
223 "closed"
224 );
225 }
226 assert_eq!(
228 parse_bitbucket_issue(&issue_json(1, "on hold"))
229 .unwrap()
230 .state,
231 "on hold"
232 );
233 }
234
235 #[test]
236 fn parse_bitbucket_issue_defaults_when_optionals_missing() {
237 let json = serde_json::json!({
238 "id": 2,
239 "title": "t",
240 "state": "new",
241 "reporter": { "username": "bob" },
242 });
243 let i = parse_bitbucket_issue(&json).unwrap();
244 assert_eq!(i.body, None);
245 assert_eq!(i.author, "bob");
247 assert!(i.labels.is_empty());
248 assert!(i.assignees.is_empty());
249 assert_eq!(i.url, "");
250 assert_eq!(i.created_at, "");
251 }
252
253 #[test]
254 fn parses_issues_out_of_paginated_values_envelope() {
255 let page = serde_json::json!({
257 "pagelen": 50,
258 "size": 2,
259 "values": [issue_json(1, "new"), issue_json(2, "wontfix")],
260 });
261 let issues: Vec<Issue> = page["values"]
262 .as_array()
263 .unwrap()
264 .iter()
265 .map(|v| parse_bitbucket_issue(v).unwrap())
266 .collect();
267 assert_eq!(issues.len(), 2);
268 assert_eq!(issues[0].state, "open");
269 assert_eq!(issues[1].state, "closed");
270 }
271}