diff --git a/Cargo.toml b/Cargo.toml index d8d8555..abb82b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,3 +40,4 @@ uuid = { version = "1", features = ["v4"] } assert_cmd = "2" predicates = "3" tempfile = "3" +mockito = "1" diff --git a/src/bitbucket/mod.rs b/src/bitbucket/mod.rs index 9d3805a..1466d6f 100644 --- a/src/bitbucket/mod.rs +++ b/src/bitbucket/mod.rs @@ -39,7 +39,6 @@ pub struct MergeResult { /// A single pipeline step/result #[derive(Debug, Clone)] -#[allow(dead_code)] pub struct PipelineStatus { pub name: String, pub state: String, @@ -47,6 +46,17 @@ pub struct PipelineStatus { pub url: Option, } +/// 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, + /// Convenience boolean flag from the Bitbucket API. + pub approved: bool, + /// Role: "REVIEWER" or "PARTICIPANT". + pub role: Option, +} + /// Parsed Bitbucket remote info #[derive(Debug, Clone)] pub struct BitbucketRemote { @@ -64,6 +74,30 @@ struct ApiPr { title: Option, state: Option, links: Option, + #[serde(default)] + source: Option, + #[serde(default)] + participants: Option>, +} + +#[derive(Deserialize)] +struct ApiPrEndpoint { + branch: Option, +} + +#[derive(Deserialize)] +struct ApiBranch { + name: Option, +} + +#[derive(Deserialize)] +struct ApiParticipant { + #[serde(default)] + state: Option, + #[serde(default)] + approved: Option, + #[serde(default)] + role: Option, } #[derive(Deserialize)] @@ -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 { @@ -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, })) } @@ -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 ) } @@ -376,7 +418,6 @@ impl BitbucketClient { } /// Get pipeline status for a branch. - #[allow(dead_code)] pub async fn get_pipelines(&self, branch: &str) -> Result> { let url = format!( "{}/pipelines/?sort=-created_on&pagelen=5&target.ref_name={}", @@ -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> { + 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> { + 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> { + 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")); + } } diff --git a/src/cli/commands/ci.rs b/src/cli/commands/ci.rs index 459ed09..f20cb3c 100644 --- a/src/cli/commands/ci.rs +++ b/src/cli/commands/ci.rs @@ -2,6 +2,7 @@ use std::path::Path; use anyhow::{Context, Result}; +use crate::bitbucket; use crate::config::ParsecConfig; use crate::errors::ErrorCode; use crate::git; @@ -9,20 +10,38 @@ use crate::github; use crate::output::{self, Mode}; use crate::worktree::WorktreeManager; +/// Forge backend selected for `parsec ci` based on the origin remote URL. +enum Forge { + GitHub(github::GitHubClient), + Bitbucket(bitbucket::BitbucketClient), +} + pub async fn ci(repo: &Path, tickets: &[&str], watch: bool, all: bool, mode: Mode) -> Result<()> { let config = ParsecConfig::load()?; let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?; let remote_url = git::run_output(repo, &["remote", "get-url", "origin"])?; - let gh = github::GitHubClient::new(&remote_url, &config)? - .ok_or_else(|| anyhow::anyhow!("no GitHub token found. Set PARSEC_GITHUB_TOKEN."))?; + + // Dispatch on remote type — GitHub takes priority when both tokens exist. + let forge = if let Some(gh) = github::GitHubClient::new(&remote_url, &config)? { + Forge::GitHub(gh) + } else if let Some(bb) = bitbucket::BitbucketClient::new(&remote_url)? { + Forge::Bitbucket(bb) + } else { + bail_code!( + ErrorCode::E001, + "no forge token found. Set PARSEC_GITHUB_TOKEN or PARSEC_BITBUCKET_TOKEN." + ); + }; + let oplog = crate::oplog::OpLog::load(&repo_root)?; let manager = WorktreeManager::new(repo, &config)?; - // Collect (ticket_id, pr_number) pairs to check + // Collect (ticket_id, pr_number) pairs to check. Bitbucket "PR id" and + // GitHub "PR number" share the same numeric encoding in the oplog (last + // path segment of the URL), so the resolution logic is forge-agnostic. let mut targets: Vec<(String, u64)> = Vec::new(); if all { - // All shipped entries with PR numbers from oplog let entries: Vec<_> = oplog .get_entries(None) .into_iter() @@ -41,10 +60,8 @@ pub async fn ci(repo: &Path, tickets: &[&str], watch: bool, all: bool, mode: Mod } targets = entries; } else if !tickets.is_empty() { - // Multiple tickets specified for t in tickets { let ticket_id = t.to_string(); - // First check if there's a shipped PR in the oplog let shipped_pr = oplog .get_entries(Some(&ticket_id)) .into_iter() @@ -58,11 +75,14 @@ pub async fn ci(repo: &Path, tickets: &[&str], watch: bool, all: bool, mode: Mod if let Some(pr_number) = shipped_pr { targets.push((ticket_id, pr_number)); } else { - // Not shipped yet — try to find an open PR by branch name let ws = manager.get(&ticket_id).with_context(|| { format!("ticket {ticket_id} not found in active workspaces or oplog") })?; - match gh.find_pr_by_branch(&ws.branch).await? { + let found = match &forge { + Forge::GitHub(gh) => gh.find_pr_by_branch(&ws.branch).await?, + Forge::Bitbucket(bb) => bb.find_pr_by_branch(&ws.branch).await?, + }; + match found { Some(pr_number) => targets.push((ticket_id, pr_number)), None => { bail_code!( @@ -85,7 +105,6 @@ pub async fn ci(repo: &Path, tickets: &[&str], watch: bool, all: bool, mode: Mod })?; let ticket_id = found.ticket; - // First check if there's a shipped PR in the oplog let shipped_pr = oplog .get_entries(Some(&ticket_id)) .into_iter() @@ -99,11 +118,14 @@ pub async fn ci(repo: &Path, tickets: &[&str], watch: bool, all: bool, mode: Mod if let Some(pr_number) = shipped_pr { targets.push((ticket_id, pr_number)); } else { - // Not shipped yet — try to find an open PR by branch name let ws = manager.get(&ticket_id).with_context(|| { format!("ticket {ticket_id} not found in active workspaces or oplog") })?; - match gh.find_pr_by_branch(&ws.branch).await? { + let pr_lookup = match &forge { + Forge::GitHub(gh) => gh.find_pr_by_branch(&ws.branch).await?, + Forge::Bitbucket(bb) => bb.find_pr_by_branch(&ws.branch).await?, + }; + match pr_lookup { Some(pr_number) => targets.push((ticket_id, pr_number)), None => { anyhow::bail!( @@ -118,11 +140,13 @@ pub async fn ci(repo: &Path, tickets: &[&str], watch: bool, all: bool, mode: Mod let mut statuses: Vec<(String, crate::github::CiStatus)> = Vec::new(); for (ticket_id, pr_number) in &targets { - let ci = gh.get_check_runs(*pr_number).await?; + let ci = match &forge { + Forge::GitHub(gh) => gh.get_check_runs(*pr_number).await?, + Forge::Bitbucket(bb) => fetch_bitbucket_ci(bb, *pr_number).await?, + }; statuses.push((ticket_id.clone(), ci)); } - // In watch + human mode, clear screen before redraw if watch && mode == Mode::Human { print!("\x1B[2J\x1B[H"); } @@ -130,8 +154,6 @@ pub async fn ci(repo: &Path, tickets: &[&str], watch: bool, all: bool, mode: Mod output::print_ci_status(&statuses, mode); if !watch || mode != Mode::Human { - // JSON/quiet mode prints once even with --watch - // Determine exit code based on overall status let has_failure = statuses.iter().any(|(_t, ci)| ci.overall == "failing"); if has_failure { bail_code!( @@ -146,7 +168,6 @@ pub async fn ci(repo: &Path, tickets: &[&str], watch: bool, all: bool, mode: Mod return Ok(()); } - // Check if all checks are completed let all_completed = statuses .iter() .all(|(_t, ci)| ci.checks.iter().all(|c| c.status == "completed")); @@ -169,3 +190,61 @@ pub async fn ci(repo: &Path, tickets: &[&str], watch: bool, all: bool, mode: Mod tokio::time::sleep(std::time::Duration::from_secs(5)).await; } } + +/// Fetch the latest pipeline for the PR's source branch and shape it into the +/// same `CiStatus` struct GitHub emits, so the renderer stays forge-agnostic. +async fn fetch_bitbucket_ci( + bb: &bitbucket::BitbucketClient, + pr_id: u64, +) -> Result { + let branch = bb.get_pr_source_branch(pr_id).await?.unwrap_or_default(); + + // No branch resolvable → return an empty CiStatus rather than erroring; + // matches the behaviour of GitHub's "no checks" path. + if branch.is_empty() { + return Ok(crate::github::CiStatus { + pr_number: pr_id, + head_sha: String::new(), + overall: "no checks".to_string(), + checks: Vec::new(), + }); + } + + let pipeline = bb.get_latest_pipeline_for_branch(&branch).await?; + let overall = bitbucket::pipeline_status_to_ci_string(pipeline.as_ref()); + + // Project a single CheckRun representing the pipeline so that --watch's + // "all completed" check works the same way it does for GitHub. Pipelines + // in pending/in_progress map to status "in_progress"; everything else to + // "completed". + let checks: Vec = match pipeline { + Some(p) => { + let upper = p.state.to_ascii_uppercase(); + let status = match upper.as_str() { + "PENDING" | "IN_PROGRESS" | "HALTED" => "in_progress", + _ => "completed", + }; + let conclusion = match overall.as_str() { + "passing" => Some("success".to_string()), + "failing" => Some("failure".to_string()), + _ => None, + }; + vec![crate::github::CheckRun { + name: p.name, + status: status.to_string(), + conclusion, + started_at: None, + completed_at: None, + html_url: p.url, + }] + } + None => Vec::new(), + }; + + Ok(crate::github::CiStatus { + pr_number: pr_id, + head_sha: String::new(), + overall, + checks, + }) +} diff --git a/src/cli/commands/pr.rs b/src/cli/commands/pr.rs index 436fe03..6907aaa 100644 --- a/src/cli/commands/pr.rs +++ b/src/cli/commands/pr.rs @@ -168,7 +168,23 @@ pub async fn pr_status(repo: &Path, ticket: Option<&str>, mode: Mode) -> Result< } else if let Some(bb) = bitbucket::BitbucketClient::new(&remote_url)? { for (ticket_id, pr_id, _url) in &all_entries { let bb_status = bb.get_pr_status(*pr_id).await?; - // Map to github::PrStatus for output compatibility + + // Resolve CI from Bitbucket Pipelines for the PR's source branch. + // Any failure (no token scope, pipelines disabled, network) falls + // back to "unknown" rather than failing the whole pr-status call. + let ci_status = match bb.get_pr_source_branch(*pr_id).await { + Ok(Some(branch)) => match bb.get_latest_pipeline_for_branch(&branch).await { + Ok(pipeline) => bitbucket::pipeline_status_to_ci_string(pipeline.as_ref()), + Err(_) => "unknown".to_string(), + }, + _ => "unknown".to_string(), + }; + + let review_status = match bb.get_pr_participants(*pr_id).await { + Ok(participants) => bitbucket::participants_to_review_status(&participants), + Err(_) => "unknown".to_string(), + }; + statuses.push(( ticket_id.clone(), github::PrStatus { @@ -176,8 +192,8 @@ pub async fn pr_status(repo: &Path, ticket: Option<&str>, mode: Mode) -> Result< title: bb_status.title, state: bb_status.state.to_lowercase(), mergeable: None, - ci_status: "unknown".to_string(), - review_status: "unknown".to_string(), + ci_status, + review_status, url: bb_status.url, }, )); diff --git a/src/env.rs b/src/env.rs index b5b8605..de824fa 100644 --- a/src/env.rs +++ b/src/env.rs @@ -77,6 +77,9 @@ pub fn gitlab_token() -> Option { pub const PARSEC_BITBUCKET_TOKEN: &str = "PARSEC_BITBUCKET_TOKEN"; pub const BITBUCKET_TOKEN: &str = "BITBUCKET_TOKEN"; +/// Override Bitbucket Cloud API base URL. Useful for tests (mock servers) and +/// future Bitbucket Server / Data Center support. +pub const PARSEC_BITBUCKET_API_BASE: &str = "PARSEC_BITBUCKET_API_BASE"; /// Resolve Bitbucket token. Priority: PARSEC_BITBUCKET_TOKEN > BITBUCKET_TOKEN pub fn bitbucket_token() -> Option { @@ -90,6 +93,14 @@ pub fn bitbucket_token() -> Option { None } +/// Bitbucket API base URL override (no trailing slash). Returns None when unset. +pub fn bitbucket_api_base() -> Option { + std::env::var(PARSEC_BITBUCKET_API_BASE) + .ok() + .filter(|v| !v.is_empty()) + .map(|v| v.trim_end_matches('/').to_string()) +} + // --------------------------------------------------------------------------- // Offline mode // --------------------------------------------------------------------------- diff --git a/tests/bitbucket_integration_tests.rs b/tests/bitbucket_integration_tests.rs new file mode 100644 index 0000000..833fb24 --- /dev/null +++ b/tests/bitbucket_integration_tests.rs @@ -0,0 +1,458 @@ +//! End-to-end tests that exercise the Bitbucket Cloud code path of `parsec ci` +//! and `parsec pr-status` against a mocked Bitbucket API server. +//! +//! These tests verify (a) the dispatch logic actually picks the Bitbucket path +//! instead of GitHub when the origin remote is Bitbucket, and (b) the response +//! mapping (ci_status, review_status) reflects the live API payload. + +use assert_cmd::Command; +use mockito::{Matcher, Server, ServerGuard}; +use std::process::Command as StdCommand; +use tempfile::TempDir; + +const WORKSPACE: &str = "fakews"; +const REPO_SLUG: &str = "fakerepo"; + +/// Initialize a git repo whose `origin` points at a Bitbucket Cloud URL. +/// No actual remote backs the URL — these tests only exercise API calls, +/// never `git fetch` / `git push`. +fn setup_bitbucket_repo() -> TempDir { + let dir = TempDir::new().unwrap(); + let p = dir.path(); + + StdCommand::new("git") + .args(["init"]) + .current_dir(p) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(p) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(p) + .output() + .unwrap(); + StdCommand::new("git") + .args(["checkout", "-b", "main"]) + .current_dir(p) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(p) + .output() + .unwrap(); + StdCommand::new("git") + .args([ + "remote", + "add", + "origin", + &format!("git@bitbucket.org:{}/{}.git", WORKSPACE, REPO_SLUG), + ]) + .current_dir(p) + .output() + .unwrap(); + + dir +} + +/// Drop a fake oplog Ship entry so `parsec pr-status` / `parsec ci` resolve +/// the PR number from the log without needing a live workspace. +fn write_oplog_ship_entry(repo: &std::path::Path, ticket: &str, pr_number: u64) { + let parsec_dir = repo.join(".parsec"); + std::fs::create_dir_all(&parsec_dir).unwrap(); + let body = serde_json::json!({ + "entries": [{ + "id": 1, + "op": "ship", + "ticket": ticket, + "detail": format!( + "Shipped branch 'feature/{0}' -> https://bitbucket.org/{1}/{2}/pull-requests/{3}", + ticket, WORKSPACE, REPO_SLUG, pr_number + ), + "timestamp": "2024-01-01T00:00:00Z", + "undo_info": null + }] + }); + std::fs::write( + parsec_dir.join("oplog.json"), + serde_json::to_string_pretty(&body).unwrap(), + ) + .unwrap(); +} + +fn parsec(server: &ServerGuard) -> Command { + let mut cmd = Command::cargo_bin("parsec").unwrap(); + // Isolate from any user-level config (e.g. existing default_base) so the + // subprocess sees only the env we provide. + cmd.env("PARSEC_CONFIG_DIR", "/tmp/parsec-test-nonexistent") + .env("PARSEC_BITBUCKET_TOKEN", "fake-token-for-test") + .env("PARSEC_BITBUCKET_API_BASE", server.url()) + // Defensive: don't let an inherited GitHub token cause the dispatcher + // to pick the GitHub forge for our bitbucket.org-style remote. + .env_remove("PARSEC_GITHUB_TOKEN") + .env_remove("GITHUB_TOKEN") + .env_remove("GH_TOKEN"); + cmd +} + +/// Build the API path prefix used in mock URLs. +fn pr_path(pr_id: u64) -> String { + format!( + "/repositories/{}/{}/pullrequests/{}", + WORKSPACE, REPO_SLUG, pr_id + ) +} + +fn pipelines_path() -> String { + format!("/repositories/{}/{}/pipelines/", WORKSPACE, REPO_SLUG) +} + +// --------------------------------------------------------------------------- +// pr-status +// --------------------------------------------------------------------------- + +#[test] +fn pr_status_bitbucket_maps_ci_and_review_from_api() { + let repo = setup_bitbucket_repo(); + let repo_path = repo.path().to_str().unwrap(); + + let mut server = Server::new(); + + // PR JSON is reused for get_pr_status, get_pr_source_branch, and + // get_pr_participants — they all hit the same endpoint. Two reviewers: + // one approved, one no-action → review_status == "approved". + let pr_body = serde_json::json!({ + "id": 42, + "title": "Add Bitbucket pipelines support", + "state": "OPEN", + "links": { "html": { "href": "https://bitbucket.org/fakews/fakerepo/pull-requests/42" } }, + "source": { "branch": { "name": "feature/BB-1" } }, + "participants": [ + { "state": "approved", "approved": true, "role": "REVIEWER" }, + { "state": null, "approved": false, "role": "REVIEWER" } + ] + }); + let pr_mock = server + .mock("GET", pr_path(42).as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(pr_body.to_string()) + .expect_at_least(2) // status + source-branch + participants + .create(); + + // Pipeline for the source branch: COMPLETED + SUCCESSFUL → ci_status "passing". + let pipelines_body = serde_json::json!({ + "values": [{ + "uuid": "{abc-123}", + "state": { "name": "COMPLETED", "result": { "name": "SUCCESSFUL" } }, + "target": { "ref_name": "feature/BB-1" } + }] + }); + let pipeline_mock = server + .mock("GET", pipelines_path().as_str()) + .match_query(Matcher::AllOf(vec![Matcher::UrlEncoded( + "target.ref_name".into(), + "feature/BB-1".into(), + )])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(pipelines_body.to_string()) + .create(); + + write_oplog_ship_entry(repo.path(), "BB-1", 42); + + let output = parsec(&server) + .args(["--json", "pr-status", "BB-1", "--repo", repo_path]) + .output() + .unwrap(); + + let stdout = String::from_utf8(output.stdout.clone()).unwrap(); + let stderr = String::from_utf8(output.stderr.clone()).unwrap(); + assert!( + output.status.success(), + "pr-status should succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}", + ); + + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("pr-status --json must produce valid JSON"); + let arr = parsed.as_array().expect("output should be a JSON array"); + assert_eq!(arr.len(), 1); + let entry = &arr[0]; + assert_eq!(entry["ticket"], "BB-1"); + assert_eq!(entry["pr_number"], 42); + assert_eq!(entry["state"], "open"); + assert_eq!( + entry["ci_status"], "passing", + "ci_status should come from the Bitbucket Pipelines mock" + ); + assert_eq!( + entry["review_status"], "approved", + "review_status should reflect the participants payload" + ); + + pr_mock.assert(); + pipeline_mock.assert(); +} + +#[test] +fn pr_status_bitbucket_no_pipeline_yet_is_no_checks() { + let repo = setup_bitbucket_repo(); + let repo_path = repo.path().to_str().unwrap(); + + let mut server = Server::new(); + + let pr_body = serde_json::json!({ + "id": 7, + "title": "Edge case PR", + "state": "OPEN", + "links": { "html": { "href": "https://bitbucket.org/fakews/fakerepo/pull-requests/7" } }, + "source": { "branch": { "name": "feature/BB-7" } }, + "participants": [] + }); + let _pr_mock = server + .mock("GET", pr_path(7).as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(pr_body.to_string()) + .expect_at_least(2) + .create(); + + // No pipeline runs yet for this branch. + let _pipeline_mock = server + .mock("GET", pipelines_path().as_str()) + .match_query(Matcher::UrlEncoded( + "target.ref_name".into(), + "feature/BB-7".into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"values":[]}"#) + .create(); + + write_oplog_ship_entry(repo.path(), "BB-7", 7); + + let output = parsec(&server) + .args(["--json", "pr-status", "BB-7", "--repo", repo_path]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let entry = &parsed.as_array().unwrap()[0]; + assert_eq!( + entry["ci_status"], "no checks", + "no pipeline runs → ci_status \"no checks\"" + ); + assert_eq!( + entry["review_status"], "no reviews", + "no participants → review_status \"no reviews\"" + ); +} + +#[test] +fn pr_status_bitbucket_changes_requested_review() { + let repo = setup_bitbucket_repo(); + let repo_path = repo.path().to_str().unwrap(); + + let mut server = Server::new(); + + let pr_body = serde_json::json!({ + "id": 9, + "title": "Needs work", + "state": "OPEN", + "links": { "html": { "href": "https://bitbucket.org/fakews/fakerepo/pull-requests/9" } }, + "source": { "branch": { "name": "feature/BB-9" } }, + "participants": [ + { "state": "approved", "approved": true, "role": "REVIEWER" }, + { "state": "changes_requested", "approved": false, "role": "REVIEWER" } + ] + }); + let _pr_mock = server + .mock("GET", pr_path(9).as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(pr_body.to_string()) + .expect_at_least(2) + .create(); + + let pipelines_body = serde_json::json!({ + "values": [{ + "uuid": "{xyz-9}", + "state": { "name": "COMPLETED", "result": { "name": "FAILED" } }, + "target": { "ref_name": "feature/BB-9" } + }] + }); + let _pipeline_mock = server + .mock("GET", pipelines_path().as_str()) + .match_query(Matcher::UrlEncoded( + "target.ref_name".into(), + "feature/BB-9".into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(pipelines_body.to_string()) + .create(); + + write_oplog_ship_entry(repo.path(), "BB-9", 9); + + let output = parsec(&server) + .args(["--json", "pr-status", "BB-9", "--repo", repo_path]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let entry = &parsed.as_array().unwrap()[0]; + assert_eq!(entry["ci_status"], "failing"); + assert_eq!(entry["review_status"], "changes_requested"); +} + +// --------------------------------------------------------------------------- +// ci +// --------------------------------------------------------------------------- + +#[test] +fn ci_bitbucket_uses_pipelines_endpoint() { + let repo = setup_bitbucket_repo(); + let repo_path = repo.path().to_str().unwrap(); + + let mut server = Server::new(); + + // PR endpoint must respond so `fetch_bitbucket_ci` can resolve the source branch. + let pr_body = serde_json::json!({ + "id": 100, + "title": "CI test", + "state": "OPEN", + "links": { "html": { "href": "https://bitbucket.org/fakews/fakerepo/pull-requests/100" } }, + "source": { "branch": { "name": "feature/CI-1" } } + }); + let _pr_mock = server + .mock("GET", pr_path(100).as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(pr_body.to_string()) + .create(); + + // Pipeline run that's still in progress. + let pipelines_body = serde_json::json!({ + "values": [{ + "uuid": "{ci-1}", + "state": { "name": "IN_PROGRESS", "result": null }, + "target": { "ref_name": "feature/CI-1" } + }] + }); + let pipeline_mock = server + .mock("GET", pipelines_path().as_str()) + .match_query(Matcher::UrlEncoded( + "target.ref_name".into(), + "feature/CI-1".into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(pipelines_body.to_string()) + .expect_at_least(1) + .create(); + + // Crucial: assert that the GitHub commit-status / check-runs endpoints are + // never hit. Mockito returns 501 for unmatched paths by default; that + // would blow up the request. We use a catch-all for /repos/* to detect + // accidental GitHub dispatch and fail loudly. + let github_mock = server + .mock("GET", Matcher::Regex("^/repos/.*".into())) + .with_status(500) + .with_body("github endpoint should not be hit for a Bitbucket remote") + .expect(0) + .create(); + + write_oplog_ship_entry(repo.path(), "CI-1", 100); + + let output = parsec(&server) + .args(["--json", "ci", "CI-1", "--repo", repo_path]) + .output() + .unwrap(); + + let stdout = String::from_utf8(output.stdout.clone()).unwrap(); + let stderr = String::from_utf8(output.stderr.clone()).unwrap(); + assert!( + output.status.success(), + "ci should succeed for an in-progress pipeline.\nstdout:\n{stdout}\nstderr:\n{stderr}", + ); + + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let entry = &parsed.as_array().unwrap()[0]; + assert_eq!(entry["ticket"], "CI-1"); + assert_eq!(entry["pr_number"], 100); + assert_eq!( + entry["overall"], "pending", + "in-progress pipeline → overall \"pending\"" + ); + + pipeline_mock.assert(); + github_mock.assert(); +} + +#[test] +fn ci_bitbucket_failing_pipeline_exits_nonzero() { + let repo = setup_bitbucket_repo(); + let repo_path = repo.path().to_str().unwrap(); + + let mut server = Server::new(); + + let pr_body = serde_json::json!({ + "id": 200, + "title": "Broken build", + "state": "OPEN", + "links": { "html": { "href": "https://bitbucket.org/fakews/fakerepo/pull-requests/200" } }, + "source": { "branch": { "name": "feature/CI-2" } } + }); + let _pr_mock = server + .mock("GET", pr_path(200).as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(pr_body.to_string()) + .create(); + + let pipelines_body = serde_json::json!({ + "values": [{ + "uuid": "{ci-2}", + "state": { "name": "COMPLETED", "result": { "name": "FAILED" } }, + "target": { "ref_name": "feature/CI-2" } + }] + }); + let _pipeline_mock = server + .mock("GET", pipelines_path().as_str()) + .match_query(Matcher::UrlEncoded( + "target.ref_name".into(), + "feature/CI-2".into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(pipelines_body.to_string()) + .create(); + + write_oplog_ship_entry(repo.path(), "CI-2", 200); + + let output = parsec(&server) + .args(["--json", "ci", "CI-2", "--repo", repo_path]) + .output() + .unwrap(); + + // Failing CI is a hard error (E002) — exit code is non-zero, but the JSON + // status line is printed to stdout before the error JSON is appended. + assert!( + !output.status.success(), + "failing pipeline should exit non-zero" + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + // First line: the CI status array. Second line: the JSON error envelope. + let first_line = stdout.lines().next().expect("expected at least one line"); + let parsed: serde_json::Value = serde_json::from_str(first_line).unwrap(); + let entry = &parsed.as_array().unwrap()[0]; + assert_eq!(entry["overall"], "failing"); +}