use crate::error::{Result, ToriiError};
use crate::platforms::issue::*;
use reqwest::blocking::Client;
pub struct BitbucketIssueClient {
token: String,
}
impl BitbucketIssueClient {
pub fn new() -> Result<Self> {
let token = crate::auth::resolve_token("bitbucket", ".")
.value
.ok_or_else(|| ToriiError::Auth {
provider: "bitbucket".into(),
message: "Bitbucket token not found. Create an app password at \
https://bitbucket.org/account/settings/app-passwords/ \
and run: torii auth set bitbucket USERNAME:APP_PASSWORD"
.to_string(),
})?;
Ok(Self { token })
}
fn client(&self) -> Client {
crate::http::make_client()
}
fn auth(&self) -> String {
if self.token.contains(':') {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&self.token);
format!("Basic {}", b64)
} else {
format!("Bearer {}", self.token)
}
}
}
impl IssueClient for BitbucketIssueClient {
fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<Issue>> {
let q = match state {
"open" => r#"state="new" OR state="open""#.to_string(),
"closed" => r#"state="resolved" OR state="closed" OR state="invalid" OR state="duplicate" OR state="wontfix""#.to_string(),
_ => String::new(),
};
let mut url = format!(
"https://api.bitbucket.org/2.0/repositories/{}/{}/issues?pagelen=50",
owner, repo
);
if !q.is_empty() {
url.push_str(&format!("&q={}", crate::url::encode(&q)));
}
let req = self
.client()
.get(&url)
.header("Authorization", self.auth())
.header("Accept", "application/json");
let json = crate::http::send_json(req, &format!("Bitbucket (url: {})", url))?;
let arr = json["values"]
.as_array()
.ok_or_else(|| ToriiError::MalformedResponse {
provider: "bitbucket".into(),
message: format!(
"Bitbucket returned no `values` array — does the repo have issues enabled? \
Body: {}",
json
),
})?;
arr.iter().map(parse_bitbucket_issue).collect()
}
fn create(&self, owner: &str, repo: &str, opts: CreateIssueOptions) -> Result<Issue> {
let url = format!(
"https://api.bitbucket.org/2.0/repositories/{}/{}/issues",
owner, repo
);
let body = serde_json::json!({
"title": opts.title,
"content": {
"raw": opts.body.unwrap_or_default(),
"markup": "markdown",
},
});
let req = self
.client()
.post(&url)
.header("Authorization", self.auth())
.header("Accept", "application/json")
.json(&body);
let json = crate::http::send_json(req, "Bitbucket create issue")?;
parse_bitbucket_issue(&json)
}
fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
let url = format!(
"https://api.bitbucket.org/2.0/repositories/{}/{}/issues/{}",
owner, repo, number
);
let body = serde_json::json!({ "state": "resolved" });
let req = self
.client()
.put(&url)
.header("Authorization", self.auth())
.header("Accept", "application/json")
.json(&body);
crate::http::send_empty(req, "Bitbucket close issue")
}
fn comment(&self, owner: &str, repo: &str, number: u64, body: &str) -> Result<()> {
let url = format!(
"https://api.bitbucket.org/2.0/repositories/{}/{}/issues/{}/comments",
owner, repo, number
);
let payload = serde_json::json!({
"content": { "raw": body, "markup": "markdown" },
});
let req = self
.client()
.post(&url)
.header("Authorization", self.auth())
.header("Accept", "application/json")
.json(&payload);
crate::http::send_empty(req, "Bitbucket comment issue")
}
}
fn parse_bitbucket_issue(json: &serde_json::Value) -> Result<Issue> {
Ok(Issue {
number: json["id"].as_u64().unwrap_or(0),
title: json["title"].as_str().unwrap_or("").to_string(),
body: json["content"]["raw"].as_str().map(String::from),
state: match json["state"].as_str().unwrap_or("") {
"new" | "open" => "open".to_string(),
"resolved" | "closed" | "invalid" | "duplicate" | "wontfix" => "closed".to_string(),
other => other.to_string(),
},
author: json["reporter"]["display_name"]
.as_str()
.or_else(|| json["reporter"]["username"].as_str())
.unwrap_or("")
.to_string(),
url: json["links"]["html"]["href"]
.as_str()
.unwrap_or("")
.to_string(),
labels: json["kind"]
.as_str()
.map(|k| vec![k.to_string()])
.unwrap_or_default(),
assignees: json["assignee"]["display_name"]
.as_str()
.or_else(|| json["assignee"]["username"].as_str())
.map(|s| vec![s.to_string()])
.unwrap_or_default(),
created_at: json["created_on"].as_str().unwrap_or("").to_string(),
comments: 0,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn issue_json(id: u64, state: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"title": "Crash on save",
"content": { "raw": "steps to reproduce", "markup": "markdown" },
"state": state,
"kind": "bug",
"reporter": { "display_name": "Bob Smith", "username": "bob" },
"assignee": { "display_name": "Alice Doe", "username": "alice" },
"links": { "html": { "href": "https://bitbucket.org/w/r/issues/14" } },
"created_on": "2026-05-06T07:08:09.000000+00:00",
})
}
#[test]
fn parse_bitbucket_issue_extracts_all_fields() {
let i = parse_bitbucket_issue(&issue_json(14, "new")).unwrap();
assert_eq!(i.number, 14);
assert_eq!(i.title, "Crash on save");
assert_eq!(i.body.as_deref(), Some("steps to reproduce"));
assert_eq!(i.state, "open");
assert_eq!(i.author, "Bob Smith");
assert_eq!(i.url, "https://bitbucket.org/w/r/issues/14");
assert_eq!(i.labels, vec!["bug".to_string()]);
assert_eq!(i.assignees, vec!["Alice Doe".to_string()]);
assert_eq!(i.created_at, "2026-05-06T07:08:09.000000+00:00");
assert_eq!(i.comments, 0);
}
#[test]
fn parse_bitbucket_issue_collapses_states() {
for s in ["new", "open"] {
assert_eq!(
parse_bitbucket_issue(&issue_json(1, s)).unwrap().state,
"open"
);
}
for s in ["resolved", "closed", "invalid", "duplicate", "wontfix"] {
assert_eq!(
parse_bitbucket_issue(&issue_json(1, s)).unwrap().state,
"closed"
);
}
assert_eq!(
parse_bitbucket_issue(&issue_json(1, "on hold"))
.unwrap()
.state,
"on hold"
);
}
#[test]
fn parse_bitbucket_issue_defaults_when_optionals_missing() {
let json = serde_json::json!({
"id": 2,
"title": "t",
"state": "new",
"reporter": { "username": "bob" },
});
let i = parse_bitbucket_issue(&json).unwrap();
assert_eq!(i.body, None);
assert_eq!(i.author, "bob");
assert!(i.labels.is_empty());
assert!(i.assignees.is_empty());
assert_eq!(i.url, "");
assert_eq!(i.created_at, "");
}
#[test]
fn parses_issues_out_of_paginated_values_envelope() {
let page = serde_json::json!({
"pagelen": 50,
"size": 2,
"values": [issue_json(1, "new"), issue_json(2, "wontfix")],
});
let issues: Vec<Issue> = page["values"]
.as_array()
.unwrap()
.iter()
.map(|v| parse_bitbucket_issue(v).unwrap())
.collect();
assert_eq!(issues.len(), 2);
assert_eq!(issues[0].state, "open");
assert_eq!(issues[1].state, "closed");
}
}