Skip to main content

torii_lib/platforms/bitbucket/
issue.rs

1//! Bitbucket Cloud — issue client.
2
3use 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        // Bitbucket issue states: new/open/resolved/on hold/invalid/
42        // duplicate/wontfix/closed. We collapse "open" to the two
43        // active ones and "closed" to the terminal set. Q-syntax is
44        // Bitbucket-specific (`state="new" OR state="open"`).
45        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// ── Azure DevOps (Work Items via WIQL) ──────────────────────────────────────
165//
166// Azure's "issues" are Work Items. The list endpoint takes a WIQL
167// (Work Item Query Language, SQL-like) query that returns IDs, then a
168// second call fetches the full records by id. We bundle that into a
169// single torii `list` call.
170//
171// Work item types depend on the project's process template (Agile /
172// Scrum / Basic / CMMI). The Basic process uses `Issue`; Agile uses
173// `User Story` and `Bug`. We default to `Issue` for create — projects
174// on a non-Basic process will need to extend the create flow later.
175
176// The client's URLs are hardcoded to api.bitbucket.org, so only the
177// parsing layer is testable without touching production code.
178#[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        // `kind` becomes the single label.
206        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        // States outside both sets pass through verbatim.
227        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        // Falls back to username when display_name is absent.
246        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        // `list` reads Bitbucket's `{"values": [...]}` page shape.
256        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}