use async_trait::async_trait;
use chrono::{DateTime, Utc};
use reqwest::Client;
use std::path::PathBuf;
use super::{AuthStatus, Provider, ProviderError};
use crate::cache;
use crate::config::BitbucketConfig;
use crate::models::{
PrIdentifier, PrState, PullRequest, ReviewStatus, Reviewer, ReviewerState, User,
};
fn deserialise_epoch_ms<'de, D>(d: D) -> Result<DateTime<Utc>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
let ms = i64::deserialize(d)?;
Ok(DateTime::from_timestamp(ms / 1000, 0).unwrap_or_default())
}
#[derive(serde::Deserialize)]
struct DataCenterPage {
values: Vec<DataCenterPr>,
#[serde(rename = "isLastPage")]
is_last_page: bool,
#[serde(rename = "nextPageStart")]
next_page_start: Option<u64>,
}
#[derive(serde::Deserialize)]
struct DataCenterPr {
id: u64,
title: String,
links: DcLinks,
author: DcParticipant,
#[serde(rename = "fromRef")]
from_ref: DcRef,
#[serde(rename = "toRef")]
to_ref: DcRef,
reviewers: Vec<DcReviewer>,
#[serde(rename = "createdDate", deserialize_with = "deserialise_epoch_ms")]
created_date: DateTime<Utc>,
#[serde(rename = "updatedDate", deserialize_with = "deserialise_epoch_ms")]
updated_date: DateTime<Utc>,
properties: Option<DcProperties>,
}
#[derive(serde::Deserialize)]
struct DcLinks {
#[serde(rename = "self")]
self_links: Vec<DcHref>,
}
#[derive(serde::Deserialize)]
struct DcHref {
href: String,
}
#[derive(serde::Deserialize)]
struct DcParticipant {
user: DcUser,
}
#[derive(serde::Deserialize)]
struct DcUser {
slug: String,
#[serde(rename = "displayName")]
display_name: String,
}
#[derive(serde::Deserialize)]
struct DcRef {
#[serde(rename = "displayId")]
display_id: String,
repository: DcRepository,
}
#[derive(serde::Deserialize)]
struct DcRepository {
slug: String,
project: DcProject,
}
#[derive(serde::Deserialize)]
struct DcProject {
key: String,
}
#[derive(serde::Deserialize)]
struct DcReviewer {
user: DcUser,
approved: bool,
status: String,
}
#[derive(serde::Deserialize)]
struct DcProperties {
#[serde(rename = "commentCount")]
comment_count: Option<u32>,
}
#[derive(serde::Deserialize)]
struct CloudUserResponse {
uuid: String,
}
#[derive(serde::Deserialize)]
struct CloudPrPage {
values: Vec<CloudPr>,
next: Option<String>,
#[allow(dead_code)]
size: Option<u64>,
}
#[derive(serde::Deserialize)]
struct CloudPr {
id: u64,
title: String,
links: CloudLinks,
author: CloudAuthor,
source: CloudEndpoint,
destination: CloudEndpoint,
reviewers: Vec<CloudReviewer>,
participants: Vec<CloudParticipant>,
created_on: DateTime<Utc>,
updated_on: DateTime<Utc>,
#[serde(rename = "comment_count")]
comment_count: Option<u32>,
#[serde(default)]
draft: Option<bool>,
}
#[derive(serde::Deserialize)]
struct CloudLinks {
html: CloudHref,
}
#[derive(serde::Deserialize)]
struct CloudHref {
href: String,
}
#[derive(serde::Deserialize)]
struct CloudAuthor {
account_id: Option<String>,
display_name: String,
nickname: Option<String>,
}
#[derive(serde::Deserialize)]
struct CloudEndpoint {
branch: CloudBranch,
repository: CloudRepository,
}
#[derive(serde::Deserialize)]
struct CloudBranch {
name: String,
}
#[derive(serde::Deserialize)]
struct CloudRepository {
slug: String,
workspace: CloudWorkspace,
#[serde(rename = "full_name")]
full_name: String,
}
#[derive(serde::Deserialize)]
struct CloudWorkspace {
slug: String,
}
#[derive(serde::Deserialize)]
struct CloudReviewer {
account_id: String,
display_name: String,
approved: Option<bool>,
}
#[derive(serde::Deserialize)]
struct CloudParticipant {
role: String,
approved: bool,
state: Option<String>,
user: CloudParticipantUser,
}
#[derive(serde::Deserialize)]
struct CloudParticipantUser {
account_id: Option<String>,
display_name: String,
nickname: Option<String>,
}
fn map_reviewer_state(approved: bool, status: &str) -> ReviewerState {
match (approved, status) {
(true, _) => ReviewerState::Approved,
(false, "needs_work") | (false, "NEEDS_WORK") | (false, "NEEDS-WORK") => {
ReviewerState::ChangesRequested
}
_ => ReviewerState::Pending,
}
}
const CLOUD_BASE_URL: &str = "https://api.bitbucket.org";
#[derive(Debug, Clone, PartialEq)]
pub enum BitbucketMode {
Cloud,
DataCenter,
}
impl BitbucketMode {
pub fn from_base_url(url: &str) -> Self {
if url.contains("api.bitbucket.org") {
BitbucketMode::Cloud
} else {
BitbucketMode::DataCenter
}
}
}
pub struct BitbucketProvider {
config: BitbucketConfig,
base_url: Option<String>,
cache_path: PathBuf,
mode: BitbucketMode,
}
impl BitbucketProvider {
pub fn new(config: BitbucketConfig) -> Self {
let base_url_str = Self::resolve_base_url(&config)
.unwrap_or_else(|| CLOUD_BASE_URL.to_string());
let mode = BitbucketMode::from_base_url(&base_url_str);
let cache_path = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("prlens")
.join("bitbucket.json");
Self {
config,
base_url: None,
cache_path,
mode,
}
}
pub fn new_with_base_url(
config: BitbucketConfig,
base_url: String,
cache_path: PathBuf,
) -> Self {
let mode = BitbucketMode::from_base_url(&base_url);
Self {
config,
base_url: Some(base_url),
cache_path,
mode,
}
}
pub fn new_with_mode(
config: BitbucketConfig,
base_url: String,
cache_path: PathBuf,
mode: BitbucketMode,
) -> Self {
Self {
config,
base_url: Some(base_url),
cache_path,
mode,
}
}
fn effective_cache_path(&self) -> Result<PathBuf, ProviderError> {
if let Some(parent) = self.cache_path.parent() {
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
return Err(ProviderError::IoError {
provider: "bitbucket".to_string(),
message: format!(
"Cannot create cache directory {:?}: {}",
parent, e
),
});
}
}
}
Ok(self.cache_path.clone())
}
pub fn resolve_token(config: &BitbucketConfig) -> Option<String> {
if let Ok(token) = std::env::var("BB_TOKEN") {
if !token.is_empty() {
tracing::debug!("Bitbucket token resolved from BB_TOKEN env var");
return Some(token);
}
}
if let Some(ref token) = config.token {
if !token.is_empty() {
tracing::debug!("Bitbucket token resolved from config [bitbucket] token field");
return Some(token.clone());
}
}
tracing::debug!(
"Bitbucket token not found: BB_TOKEN unset or empty, config token absent"
);
None
}
pub fn resolve_base_url(config: &BitbucketConfig) -> Option<String> {
if let Ok(url) = std::env::var("BB_SERVER_URL") {
if !url.is_empty() {
tracing::debug!("Bitbucket base URL resolved from BB_SERVER_URL env var");
return Some(url);
}
}
if let Some(ref url) = config.base_url {
if !url.is_empty() {
tracing::debug!("Bitbucket base URL resolved from config [bitbucket] base_url");
return Some(url.clone());
}
}
None
}
fn effective_base_url(&self) -> String {
if let Some(ref url) = self.base_url {
return url.clone();
}
Self::resolve_base_url(&self.config)
.unwrap_or_else(|| CLOUD_BASE_URL.to_string())
}
fn validate_base_url_https(base: &str) -> Result<(), ProviderError> {
if base.starts_with("http://") {
let after_scheme = &base["http://".len()..];
let host = after_scheme.split('/').next().unwrap_or("").split(':').next().unwrap_or("");
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return Ok(());
}
return Err(ProviderError::ApiError {
provider: "bitbucket".to_string(),
status: 0,
message: "base_url must use HTTPS".to_string(),
});
}
Ok(())
}
fn same_origin(base: &str, cursor: &str) -> bool {
fn origin(url: &str) -> &str {
let rest = url.splitn(3, '/').collect::<Vec<_>>();
if rest.len() >= 2 { url.get(..url.len() - rest.last().map_or(0, |s| s.len())).unwrap_or(url) } else { url }
}
origin(base) == origin(cursor)
}
async fn fetch_data_center(&self, token: &str) -> Result<Vec<PullRequest>, ProviderError> {
let base = self.effective_base_url();
Self::validate_base_url_https(&base)?;
let url = format!("{}/rest/api/1.0/dashboard/pull-requests", base);
let client = Client::new();
let mut prs: Vec<PullRequest> = Vec::new();
let mut start = 0u64;
loop {
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.query(&[
("role", "REVIEWER"),
("state", "OPEN"),
("limit", "100"),
("start", &start.to_string()),
])
.send()
.await
.map_err(|e| ProviderError::ApiError {
provider: "bitbucket".to_string(),
status: 0,
message: e.to_string(),
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(ProviderError::ApiError {
provider: "bitbucket".to_string(),
status,
message,
});
}
let page: DataCenterPage = resp.json().await.map_err(|e| ProviderError::ParseError {
provider: "bitbucket".to_string(),
message: e.to_string(),
})?;
for dc_pr in page.values {
prs.push(normalize_dc_pr(dc_pr, "bitbucket"));
}
if page.is_last_page {
break;
}
match page.next_page_start {
Some(next) => start = next,
None => { tracing::warn!("Bitbucket DC: isLastPage=false but nextPageStart absent — stopping to avoid infinite loop"); break; }
}
}
Ok(prs)
}
async fn fetch_cloud(&self, token: &str) -> Result<Vec<PullRequest>, ProviderError> {
let username = self.config.username.as_ref().ok_or_else(|| {
ProviderError::AuthMissing {
provider: "bitbucket".to_string(),
reason: "Bitbucket Cloud requires [bitbucket] username = \"your@atlassian.email\" \
in config.toml. Set this to your Atlassian account email (for API tokens) \
or your Bitbucket username (for app passwords, deprecated June 2026)."
.to_string(),
}
})?;
let base = self.effective_base_url();
Self::validate_base_url_https(&base)?;
let client = Client::new();
let user_resp = client
.get(format!("{}/2.0/user", base))
.basic_auth(username, Some(token))
.send()
.await
.map_err(|e| ProviderError::ApiError {
provider: "bitbucket".to_string(),
status: 0,
message: e.to_string(),
})?;
if !user_resp.status().is_success() {
let status = user_resp.status().as_u16();
let message = user_resp.text().await.unwrap_or_default();
return Err(ProviderError::ApiError {
provider: "bitbucket".to_string(),
status,
message,
});
}
let user_data: CloudUserResponse =
user_resp.json().await.map_err(|e| ProviderError::ParseError {
provider: "bitbucket".to_string(),
message: e.to_string(),
})?;
let uuid = user_data.uuid;
let repos: Vec<(String, String)> = if !self.config.watch_repos.is_empty() {
self.config
.watch_repos
.iter()
.filter_map(|entry| {
let mut parts = entry.splitn(2, '/');
let ws = parts.next()?.to_string();
let slug = parts.next()?.to_string();
Some((ws, slug))
})
.collect()
} else if let Some(ref workspace) = self.config.workspace {
self.enumerate_workspace_repos(&client, &base, username, token, workspace)
.await?
} else {
return Err(ProviderError::AuthMissing {
provider: "bitbucket".to_string(),
reason: "Bitbucket Cloud requires either [bitbucket] watch_repos entries \
(format: 'workspace/repo-slug') or [bitbucket] workspace = 'your-workspace' \
in config.toml to enumerate repositories."
.to_string(),
});
};
let futures: Vec<_> = repos
.into_iter()
.map(|(ws, slug)| {
let client = client.clone();
let base = base.clone();
let uuid = uuid.clone();
let username = username.clone();
let token = token.to_string();
async move {
fetch_cloud_repo_prs(&client, &base, &username, &token, &ws, &slug, &uuid)
.await
}
})
.collect();
let results = futures::future::join_all(futures).await;
let mut all_prs: Vec<PullRequest> = Vec::new();
let mut first_error: Option<ProviderError> = None;
let mut error_count = 0usize;
let total_repos = results.len();
for result in results {
match result {
Ok(prs) => all_prs.extend(prs),
Err(e) => {
tracing::warn!("Bitbucket Cloud per-repo fetch error: {}", e);
if first_error.is_none() {
first_error = Some(e);
}
error_count = error_count.saturating_add(1);
}
}
}
if error_count > 0 && error_count == total_repos {
return Err(first_error.expect("error_count > 0 implies first_error is Some"));
}
let mut seen = std::collections::HashSet::new();
all_prs.retain(|pr| seen.insert((pr.repo_full_name.clone(), pr.number)));
Ok(all_prs)
}
async fn enumerate_workspace_repos(
&self,
client: &Client,
base: &str,
username: &str,
token: &str,
workspace: &str,
) -> Result<Vec<(String, String)>, ProviderError> {
let mut repos = Vec::new();
let mut url = Some(format!(
"{}/2.0/repositories/{}?role=contributor&pagelen=100",
base, workspace
));
while let Some(current_url) = url {
let resp = client
.get(¤t_url)
.basic_auth(username, Some(token))
.send()
.await
.map_err(|e| ProviderError::ApiError {
provider: "bitbucket".to_string(),
status: 0,
message: e.to_string(),
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(ProviderError::ApiError {
provider: "bitbucket".to_string(),
status,
message,
});
}
#[derive(serde::Deserialize)]
struct RepoPage {
values: Vec<RepoEntry>,
next: Option<String>,
}
#[derive(serde::Deserialize)]
struct RepoEntry {
slug: String,
}
let page: RepoPage = resp.json().await.map_err(|e| ProviderError::ParseError {
provider: "bitbucket".to_string(),
message: e.to_string(),
})?;
for repo in page.values {
repos.push((workspace.to_string(), repo.slug));
}
url = page.next.filter(|next| Self::same_origin(base, next));
}
Ok(repos)
}
}
async fn fetch_cloud_repo_prs(
client: &Client,
base: &str,
username: &str,
token: &str,
workspace: &str,
slug: &str,
uuid: &str,
) -> Result<Vec<PullRequest>, ProviderError> {
let mut prs = Vec::new();
let q = format!(r#"state="OPEN" AND reviewers.uuid="{}""#, uuid);
let initial_url = format!(
"{}/2.0/repositories/{}/{}/pullrequests",
base, workspace, slug
);
let mut current_url = initial_url;
let mut first = true;
loop {
let req = if first {
first = false;
client
.get(¤t_url)
.basic_auth(username, Some(token))
.query(&[("q", q.as_str()), ("pagelen", "50")])
} else {
client
.get(¤t_url)
.basic_auth(username, Some(token))
};
let resp = req
.send()
.await
.map_err(|e| ProviderError::ApiError {
provider: "bitbucket".to_string(),
status: 0,
message: e.to_string(),
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(ProviderError::ApiError {
provider: "bitbucket".to_string(),
status,
message,
});
}
let page: CloudPrPage = resp.json().await.map_err(|e| ProviderError::ParseError {
provider: "bitbucket".to_string(),
message: e.to_string(),
})?;
for cloud_pr in page.values {
prs.push(normalize_cloud_pr(cloud_pr));
}
match page.next {
Some(url) if BitbucketProvider::same_origin(base, &url) => current_url = url,
Some(_) => break, None => break,
}
}
Ok(prs)
}
fn normalize_dc_pr(pr: DataCenterPr, provider_name: &str) -> PullRequest {
let url = pr
.links
.self_links
.first()
.map(|h| h.href.clone())
.unwrap_or_default();
let reviewers: Vec<Reviewer> = pr
.reviewers
.into_iter()
.map(|r| Reviewer {
user: User {
login: r.user.slug.clone(),
display_name: Some(r.user.display_name.clone()),
avatar_url: None,
},
state: map_reviewer_state(r.approved, &r.status),
})
.collect();
PullRequest {
id: PrIdentifier {
provider: provider_name.to_string(),
owner: pr.to_ref.repository.project.key.clone(),
repo: pr.to_ref.repository.slug.clone(),
number: pr.id,
},
number: pr.id,
title: pr.title,
url,
author: User {
login: pr.author.user.slug,
display_name: Some(pr.author.user.display_name),
avatar_url: None,
},
reviewers,
repo_full_name: format!(
"{}/{}",
pr.to_ref.repository.project.key, pr.to_ref.repository.slug
),
provider: provider_name.to_string(),
head_branch: pr.from_ref.display_id,
base_branch: pr.to_ref.display_id,
state: PrState::Open,
review_status: ReviewStatus::NeedsReview,
ci_status: None,
draft: false, created_at: pr.created_date,
updated_at: pr.updated_date,
labels: vec![],
comment_count: pr
.properties
.and_then(|p| p.comment_count)
.unwrap_or(0),
additions: None,
deletions: None,
}
}
fn normalize_cloud_pr(pr: CloudPr) -> PullRequest {
let reviewers: Vec<Reviewer> = pr
.reviewers
.iter()
.map(|r| {
let participant = pr
.participants
.iter()
.find(|p| p.role == "REVIEWER" && p.user.account_id.as_deref() == Some(&r.account_id));
let (approved, status) = match participant {
Some(p) => (p.approved, p.state.as_deref().unwrap_or("")),
None => (r.approved.unwrap_or(false), ""),
};
Reviewer {
user: User {
login: r.account_id.clone(),
display_name: Some(r.display_name.clone()),
avatar_url: None,
},
state: map_reviewer_state(approved, status),
}
})
.collect();
PullRequest {
id: PrIdentifier {
provider: "bitbucket".to_string(),
owner: pr.destination.repository.workspace.slug.clone(),
repo: pr.destination.repository.slug.clone(),
number: pr.id,
},
number: pr.id,
title: pr.title,
url: pr.links.html.href,
author: User {
login: pr
.author
.nickname
.clone()
.or(pr.author.account_id.clone())
.unwrap_or_default(),
display_name: Some(pr.author.display_name),
avatar_url: None,
},
reviewers,
repo_full_name: pr.destination.repository.full_name,
provider: "bitbucket".to_string(),
head_branch: pr.source.branch.name,
base_branch: pr.destination.branch.name,
state: PrState::Open,
review_status: ReviewStatus::NeedsReview,
ci_status: None,
draft: pr.draft.unwrap_or(false),
created_at: pr.created_on,
updated_at: pr.updated_on,
labels: vec![],
comment_count: pr.comment_count.unwrap_or(0),
additions: None,
deletions: None,
}
}
#[async_trait]
impl Provider for BitbucketProvider {
fn name(&self) -> &'static str {
"bitbucket"
}
fn display_name(&self) -> &'static str {
"Bitbucket"
}
async fn check_auth(&self) -> AuthStatus {
match Self::resolve_token(&self.config) {
Some(_) => {
tracing::debug!("Bitbucket check_auth: available");
AuthStatus::Available
}
None => {
let reason = concat!(
"Set BB_TOKEN env var or [bitbucket] token = \"...\" in config.toml. ",
"For Bitbucket Cloud (Basic auth), also set [bitbucket] username = \"your@atlassian.email\". ",
"For Data Center (Bearer auth), only BB_TOKEN or config token is required."
)
.to_string();
tracing::debug!("Bitbucket check_auth: missing");
AuthStatus::Missing { reason }
}
}
}
async fn list_prs(&self) -> Result<Vec<PullRequest>, ProviderError> {
let cache_path = self.effective_cache_path()?;
if let Some(entry) = cache::read_cache::<Vec<PullRequest>>(&cache_path) {
if entry.is_fresh() {
tracing::debug!("Bitbucket cache hit — returning cached PRs");
return Ok(entry.data);
}
tracing::debug!("Bitbucket cache miss or expired — fetching from API");
}
let token = Self::resolve_token(&self.config).ok_or_else(|| ProviderError::AuthMissing {
provider: "bitbucket".to_string(),
reason: "Set BB_TOKEN or [bitbucket] token = \"...\" in config.toml".to_string(),
})?;
let prs = match self.mode {
BitbucketMode::Cloud => self.fetch_cloud(&token).await?,
BitbucketMode::DataCenter => self.fetch_data_center(&token).await?,
};
if let Err(e) = cache::write_cache(&cache_path, &prs, 60) {
tracing::debug!(
"Failed to write Bitbucket cache to {:?}: {}",
cache_path,
e
);
}
Ok(prs)
}
async fn get_pr_details(&self, _pr_id: &PrIdentifier) -> Result<PullRequest, ProviderError> {
Err(ProviderError::NotImplemented {
provider: "bitbucket".to_string(),
})
}
async fn get_pr_diff(&self, _pr_id: &PrIdentifier) -> Result<String, ProviderError> {
Err(ProviderError::NotImplemented {
provider: "bitbucket".to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::BitbucketConfig;
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn provider_name() {
let provider = BitbucketProvider::new(BitbucketConfig::default());
assert_eq!(provider.name(), "bitbucket");
assert_eq!(provider.display_name(), "Bitbucket");
}
#[test]
fn provider_name_test_constructor() {
let provider = BitbucketProvider::new_with_base_url(
BitbucketConfig::default(),
"http://localhost:0".to_string(),
std::path::PathBuf::from("/tmp/prlens-test-bitbucket-cache.json"),
);
assert_eq!(provider.name(), "bitbucket");
assert_eq!(provider.display_name(), "Bitbucket");
}
#[test]
fn mode_detection_cloud() {
let mode = BitbucketMode::from_base_url("https://api.bitbucket.org/2.0");
assert!(matches!(mode, BitbucketMode::Cloud));
}
#[test]
fn mode_detection_data_center() {
let mode =
BitbucketMode::from_base_url("https://bitbucket.mycompany.com/rest/api/1.0");
assert!(matches!(mode, BitbucketMode::DataCenter));
}
#[test]
fn mode_detection_default_is_cloud() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("BB_SERVER_URL"); }
let provider = BitbucketProvider::new(BitbucketConfig::default());
assert!(matches!(provider.mode, BitbucketMode::Cloud));
}
#[test]
fn mode_detection_custom_url_is_data_center() {
let config = BitbucketConfig {
base_url: Some("https://bb.internal.example.com/rest/api/1.0".to_string()),
..BitbucketConfig::default()
};
let provider = BitbucketProvider::new(config);
assert!(matches!(provider.mode, BitbucketMode::DataCenter));
}
#[test]
fn resolve_token_bb_token_env_var() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("BB_TOKEN", "envtoken");
}
let config = BitbucketConfig::default();
let result = BitbucketProvider::resolve_token(&config);
unsafe {
std::env::remove_var("BB_TOKEN");
}
assert_eq!(result, Some("envtoken".to_string()));
}
#[test]
fn resolve_token_config_fallback_when_env_absent() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("BB_TOKEN");
}
let config = BitbucketConfig {
token: Some("conftoken".to_string()),
..BitbucketConfig::default()
};
let result = BitbucketProvider::resolve_token(&config);
assert_eq!(result, Some("conftoken".to_string()));
}
#[test]
fn resolve_token_none_when_both_absent() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("BB_TOKEN");
}
let config = BitbucketConfig::default(); let result = BitbucketProvider::resolve_token(&config);
assert_eq!(result, None);
}
#[test]
fn resolve_token_env_var_takes_priority_over_config() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("BB_TOKEN", "envwins");
}
let config = BitbucketConfig {
token: Some("configtoken".to_string()),
..BitbucketConfig::default()
};
let result = BitbucketProvider::resolve_token(&config);
unsafe {
std::env::remove_var("BB_TOKEN");
}
assert_eq!(result, Some("envwins".to_string()));
}
#[test]
fn resolve_token_empty_env_var_falls_back_to_config() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("BB_TOKEN", "");
}
let config = BitbucketConfig {
token: Some("configtoken".to_string()),
..BitbucketConfig::default()
};
let result = BitbucketProvider::resolve_token(&config);
unsafe {
std::env::remove_var("BB_TOKEN");
}
assert_eq!(result, Some("configtoken".to_string()));
}
#[tokio::test]
async fn check_auth_missing_when_no_token() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let provider = BitbucketProvider::new_with_base_url(
BitbucketConfig::default(), "http://localhost:0".to_string(),
std::path::PathBuf::from("/tmp/prlens-test-bitbucket-check-auth.json"),
);
let saved = std::env::var("BB_TOKEN").ok();
unsafe {
std::env::remove_var("BB_TOKEN");
}
let status = provider.check_auth().await;
if let Some(val) = saved {
unsafe {
std::env::set_var("BB_TOKEN", val);
}
}
match status {
AuthStatus::Missing { reason } => {
assert!(
reason.contains("BB_TOKEN"),
"Missing reason should mention BB_TOKEN, got: {}",
reason
);
assert!(
reason.contains("[bitbucket] token"),
"Missing reason should mention [bitbucket] token, got: {}",
reason
);
}
AuthStatus::Available => panic!("Expected Missing, got Available"),
}
}
#[tokio::test]
async fn check_auth_available_when_bb_token_set() {
let config = BitbucketConfig {
token: Some("testtoken123".to_string()),
..BitbucketConfig::default()
};
let provider = BitbucketProvider::new(config);
let status = provider.check_auth().await;
assert_eq!(status, AuthStatus::Available);
}
#[tokio::test]
async fn check_auth_available_when_config_token_set() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("BB_TOKEN");
}
let config = BitbucketConfig {
token: Some("myconfig_token".to_string()),
..BitbucketConfig::default()
};
let provider = BitbucketProvider::new(config);
let status = provider.check_auth().await;
assert_eq!(status, AuthStatus::Available);
}
#[test]
fn normalize_dc_pr_epoch_ms_timestamp() {
let dc_pr = DataCenterPr {
id: 1,
title: "Test PR".to_string(),
links: DcLinks {
self_links: vec![DcHref {
href: "https://bb.example.com/pr/1".to_string(),
}],
},
author: DcParticipant {
user: DcUser {
slug: "alice".to_string(),
display_name: "Alice Smith".to_string(),
},
},
from_ref: DcRef {
display_id: "feature/test".to_string(),
repository: DcRepository {
slug: "myrepo".to_string(),
project: DcProject {
key: "PROJ".to_string(),
},
},
},
to_ref: DcRef {
display_id: "main".to_string(),
repository: DcRepository {
slug: "myrepo".to_string(),
project: DcProject {
key: "PROJ".to_string(),
},
},
},
reviewers: vec![],
created_date: DateTime::from_timestamp(1731663000000 / 1000, 0).unwrap(),
updated_date: DateTime::from_timestamp(1731663000000 / 1000, 0).unwrap(),
properties: None,
};
let pr = normalize_dc_pr(dc_pr, "bitbucket");
assert_eq!(pr.created_at.to_rfc3339(), "2024-11-15T09:30:00+00:00");
assert!(!pr.draft, "Data Center PRs should never be draft");
assert_eq!(pr.provider, "bitbucket");
}
#[test]
fn map_reviewer_state_approved() {
assert!(matches!(
map_reviewer_state(true, "APPROVED"),
ReviewerState::Approved
));
}
#[test]
fn map_reviewer_state_needs_work() {
assert!(matches!(
map_reviewer_state(false, "NEEDS_WORK"),
ReviewerState::ChangesRequested
));
assert!(matches!(
map_reviewer_state(false, "needs_work"),
ReviewerState::ChangesRequested
));
}
#[test]
fn map_reviewer_state_pending() {
assert!(matches!(
map_reviewer_state(false, "UNAPPROVED"),
ReviewerState::Pending
));
}
#[test]
fn validate_base_url_rejects_http_non_localhost() {
let result = BitbucketProvider::validate_base_url_https("http://evil.com/api");
assert!(result.is_err());
if let Err(ProviderError::ApiError { message, .. }) = result {
assert!(message.contains("must use HTTPS"));
}
}
#[test]
fn validate_base_url_allows_https() {
assert!(
BitbucketProvider::validate_base_url_https("https://api.bitbucket.org").is_ok()
);
}
#[test]
fn validate_base_url_allows_http_localhost() {
assert!(
BitbucketProvider::validate_base_url_https("http://localhost:9090").is_ok()
);
assert!(
BitbucketProvider::validate_base_url_https("http://127.0.0.1:9090").is_ok()
);
}
}