Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ uuid = { version = "1", features = ["v4"] }
assert_cmd = "2"
predicates = "3"
tempfile = "3"
mockito = "1"
324 changes: 320 additions & 4 deletions src/bitbucket/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,24 @@ pub struct MergeResult {

/// A single pipeline step/result
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct PipelineStatus {
pub name: String,
pub state: String,
pub result: Option<String>,
pub url: Option<String>,
}

/// A PR participant (reviewer/commenter) used for review_status mapping.
#[derive(Debug, Clone)]
pub struct Participant {
/// Bitbucket review state: "approved", "changes_requested", or None.
pub state: Option<String>,
/// Convenience boolean flag from the Bitbucket API.
pub approved: bool,
/// Role: "REVIEWER" or "PARTICIPANT".
pub role: Option<String>,
}

/// Parsed Bitbucket remote info
#[derive(Debug, Clone)]
pub struct BitbucketRemote {
Expand All @@ -64,6 +74,30 @@ struct ApiPr {
title: Option<String>,
state: Option<String>,
links: Option<ApiLinks>,
#[serde(default)]
source: Option<ApiPrEndpoint>,
#[serde(default)]
participants: Option<Vec<ApiParticipant>>,
}

#[derive(Deserialize)]
struct ApiPrEndpoint {
branch: Option<ApiBranch>,
}

#[derive(Deserialize)]
struct ApiBranch {
name: Option<String>,
}

#[derive(Deserialize)]
struct ApiParticipant {
#[serde(default)]
state: Option<String>,
#[serde(default)]
approved: Option<bool>,
#[serde(default)]
role: Option<String>,
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -157,11 +191,15 @@ pub fn is_bitbucket_remote(url: &str) -> bool {
// BitbucketClient
// ---------------------------------------------------------------------------

/// Default Bitbucket Cloud API base URL (without trailing slash).
const DEFAULT_API_BASE: &str = "https://api.bitbucket.org/2.0";

/// Authenticated Bitbucket Cloud API client.
pub struct BitbucketClient {
client: Client,
remote: BitbucketRemote,
token: String,
api_base: String,
}

impl BitbucketClient {
Expand Down Expand Up @@ -191,10 +229,14 @@ impl BitbucketClient {
.build()
.context("failed to build HTTP client")?;

let api_base =
crate::env::bitbucket_api_base().unwrap_or_else(|| DEFAULT_API_BASE.to_string());

Ok(Some(Self {
client,
remote,
token,
api_base,
}))
}

Expand All @@ -206,8 +248,8 @@ impl BitbucketClient {
/// Repo API path prefix.
fn repo_url(&self) -> String {
format!(
"https://api.bitbucket.org/2.0/repositories/{}/{}",
self.remote.workspace, self.remote.repo_slug
"{}/repositories/{}/{}",
self.api_base, self.remote.workspace, self.remote.repo_slug
)
}

Expand Down Expand Up @@ -376,7 +418,6 @@ impl BitbucketClient {
}

/// Get pipeline status for a branch.
#[allow(dead_code)]
pub async fn get_pipelines(&self, branch: &str) -> Result<Vec<PipelineStatus>> {
let url = format!(
"{}/pipelines/?sort=-created_on&pagelen=5&target.ref_name={}",
Expand Down Expand Up @@ -433,4 +474,279 @@ impl BitbucketClient {

Ok(pipelines)
}

/// Get the latest pipeline (most recently created) for a branch, if any.
pub async fn get_latest_pipeline_for_branch(
&self,
branch: &str,
) -> Result<Option<PipelineStatus>> {
Ok(self.get_pipelines(branch).await?.into_iter().next())
}

/// Fetch the source branch name for a PR.
pub async fn get_pr_source_branch(&self, pr_id: u64) -> Result<Option<String>> {
let url = format!("{}/pullrequests/{}", self.repo_url(), pr_id);
let response = self
.auth_get(&url)
.send()
.await
.context("Failed to fetch Bitbucket PR")?;

if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!("Bitbucket API returned {}: {}", status, body);
}

let pr: ApiPr = response.json().await?;
Ok(pr
.source
.and_then(|s| s.branch)
.and_then(|b| b.name)
.filter(|n| !n.is_empty()))
}

/// Fetch participants for a PR. Returns an empty vec when the PR has no participants
/// or the API call fails (callers may interpret this as "unknown / pending").
pub async fn get_pr_participants(&self, pr_id: u64) -> Result<Vec<Participant>> {
let url = format!("{}/pullrequests/{}", self.repo_url(), pr_id);
let response = self
.auth_get(&url)
.send()
.await
.context("Failed to fetch Bitbucket PR")?;

if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
bail!("Bitbucket API returned {}: {}", status, body);
}

let pr: ApiPr = response.json().await?;
Ok(pr
.participants
.unwrap_or_default()
.into_iter()
.map(|p| Participant {
state: p.state,
approved: p.approved.unwrap_or(false),
role: p.role,
})
.collect())
}
}

// ---------------------------------------------------------------------------
// Pure mapping functions (forge-agnostic vocabulary)
// ---------------------------------------------------------------------------

/// Map a Bitbucket pipeline (state + optional result) to the same `ci_status`
/// vocabulary that the GitHub path emits: `passing` | `failing` | `pending`
/// | `no checks` | `unknown`.
///
/// Bitbucket pipeline `state.name` values: `PENDING`, `IN_PROGRESS`,
/// `COMPLETED`, `HALTED`, `STOPPED`. When `state.name == "COMPLETED"`,
/// `state.result.name` is one of `SUCCESSFUL`, `FAILED`, `ERROR`,
/// `STOPPED`, `EXPIRED`.
pub fn pipeline_to_ci_status(state: &str, result: Option<&str>) -> String {
match state.to_ascii_uppercase().as_str() {
"COMPLETED" => match result.map(|r| r.to_ascii_uppercase()).as_deref() {
Some("SUCCESSFUL") => "passing".to_string(),
Some("FAILED") | Some("ERROR") | Some("STOPPED") | Some("EXPIRED") => {
"failing".to_string()
}
_ => "unknown".to_string(),
},
"PENDING" | "IN_PROGRESS" | "HALTED" => "pending".to_string(),
_ => "unknown".to_string(),
}
}

/// Convenience wrapper: map an optional `PipelineStatus` to a `ci_status` string.
/// `None` → `"no checks"` (consistent with GitHub's empty-checks rendering).
pub fn pipeline_status_to_ci_string(p: Option<&PipelineStatus>) -> String {
match p {
Some(p) => pipeline_to_ci_status(&p.state, p.result.as_deref()),
None => "no checks".to_string(),
}
}

/// Map Bitbucket PR participants to the same `review_status` vocabulary the
/// GitHub path emits: `approved` | `changes_requested` | `pending` | `no reviews`.
///
/// - Any participant with `state == "changes_requested"` → `changes_requested`.
/// - Else any participant with `approved == true` (or `state == "approved"`) → `approved`.
/// - Else if there are any reviewer-role participants → `pending`.
/// - Else → `no reviews`.
pub fn participants_to_review_status(participants: &[Participant]) -> String {
if participants
.iter()
.any(|p| p.state.as_deref() == Some("changes_requested"))
{
return "changes_requested".to_string();
}
if participants
.iter()
.any(|p| p.approved || p.state.as_deref() == Some("approved"))
{
return "approved".to_string();
}
if participants
.iter()
.any(|p| p.role.as_deref() == Some("REVIEWER"))
{
return "pending".to_string();
}
"no reviews".to_string()
}

#[cfg(test)]
mod tests {
use super::*;

// -- pipeline_to_ci_status -------------------------------------------------

#[test]
fn pipeline_completed_successful_is_passing() {
assert_eq!(
pipeline_to_ci_status("COMPLETED", Some("SUCCESSFUL")),
"passing"
);
}

#[test]
fn pipeline_completed_failed_is_failing() {
assert_eq!(
pipeline_to_ci_status("COMPLETED", Some("FAILED")),
"failing"
);
}

#[test]
fn pipeline_completed_error_is_failing() {
assert_eq!(pipeline_to_ci_status("COMPLETED", Some("ERROR")), "failing");
}

#[test]
fn pipeline_completed_expired_is_failing() {
assert_eq!(
pipeline_to_ci_status("COMPLETED", Some("EXPIRED")),
"failing"
);
}

#[test]
fn pipeline_in_progress_is_pending() {
assert_eq!(pipeline_to_ci_status("IN_PROGRESS", None), "pending");
}

#[test]
fn pipeline_pending_is_pending() {
assert_eq!(pipeline_to_ci_status("PENDING", None), "pending");
}

#[test]
fn pipeline_halted_is_pending() {
assert_eq!(pipeline_to_ci_status("HALTED", None), "pending");
}

#[test]
fn pipeline_unknown_state_is_unknown() {
assert_eq!(pipeline_to_ci_status("WAT", None), "unknown");
}

#[test]
fn pipeline_state_is_case_insensitive() {
assert_eq!(
pipeline_to_ci_status("completed", Some("successful")),
"passing"
);
}

#[test]
fn pipeline_status_to_ci_string_none_is_no_checks() {
assert_eq!(pipeline_status_to_ci_string(None), "no checks");
}

#[test]
fn pipeline_status_to_ci_string_passes_through() {
let p = PipelineStatus {
name: "x".into(),
state: "COMPLETED".into(),
result: Some("SUCCESSFUL".into()),
url: None,
};
assert_eq!(pipeline_status_to_ci_string(Some(&p)), "passing");
}

// -- participants_to_review_status -----------------------------------------

fn p(state: Option<&str>, approved: bool, role: Option<&str>) -> Participant {
Participant {
state: state.map(|s| s.to_string()),
approved,
role: role.map(|s| s.to_string()),
}
}

#[test]
fn empty_participants_is_no_reviews() {
assert_eq!(participants_to_review_status(&[]), "no reviews");
}

#[test]
fn changes_requested_dominates() {
let parts = vec![
p(Some("approved"), true, Some("REVIEWER")),
p(Some("changes_requested"), false, Some("REVIEWER")),
];
assert_eq!(participants_to_review_status(&parts), "changes_requested");
}

#[test]
fn approved_state() {
let parts = vec![p(Some("approved"), true, Some("REVIEWER"))];
assert_eq!(participants_to_review_status(&parts), "approved");
}

#[test]
fn approved_via_boolean_only() {
let parts = vec![p(None, true, Some("REVIEWER"))];
assert_eq!(participants_to_review_status(&parts), "approved");
}

#[test]
fn reviewer_no_action_is_pending() {
let parts = vec![p(None, false, Some("REVIEWER"))];
assert_eq!(participants_to_review_status(&parts), "pending");
}

#[test]
fn only_non_reviewer_participants_is_no_reviews() {
// PR author / commenter shows up as PARTICIPANT with no review state.
let parts = vec![p(None, false, Some("PARTICIPANT"))];
assert_eq!(participants_to_review_status(&parts), "no reviews");
}

// -- remote URL parsing (existing behavior, sanity-check) ------------------

#[test]
fn parse_https_remote() {
let r = parse_bitbucket_remote("https://bitbucket.org/myws/myrepo.git").unwrap();
assert_eq!(r.workspace, "myws");
assert_eq!(r.repo_slug, "myrepo");
}

#[test]
fn parse_ssh_remote() {
let r = parse_bitbucket_remote("git@bitbucket.org:myws/myrepo.git").unwrap();
assert_eq!(r.workspace, "myws");
assert_eq!(r.repo_slug, "myrepo");
}

#[test]
fn is_bitbucket_remote_detects_url() {
assert!(is_bitbucket_remote("git@bitbucket.org:foo/bar.git"));
assert!(!is_bitbucket_remote("git@github.com:foo/bar.git"));
}
}
Loading
Loading