use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{Result, TuicrError};
use crate::forge::remote_comments::{RemoteReviewSummary, RemoteReviewThread};
use crate::forge::submit::{GhSide, SubmitEvent};
use crate::forge::traits::{
CreateReviewRequest, ForgeBackend, ForgeFileLinesRequest, ForgeRepository,
GhCreateReviewResponse, PagedPullRequests, PullRequestCommit, PullRequestDetails,
PullRequestListQuery, PullRequestListScope, PullRequestReviewMetadata, PullRequestTarget,
};
use crate::model::DiffLine;
use crate::process::{CommandOutputError, CommandOutputErrorKind, run_command_output};
use crate::vcs::slice_context_lines;
use super::models::{
BbComment, BbCommit, BbPaged, BbPullRequest, BbUser, group_into_review_threads,
review_summaries,
};
const BITBUCKET_CLOUD_HOST: &str = "bitbucket.org";
const MAX_PAGE_LEN: usize = 100;
const MAX_PR_LIST_PAGE_LEN: usize = 50;
const MAX_PAGES: usize = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BktCommandError {
MissingBkt,
Failed { status: Option<i32>, stderr: String },
}
pub type BktCommandResult<T> = std::result::Result<T, BktCommandError>;
pub trait BktCommandRunner {
fn run(&self, args: &[String]) -> BktCommandResult<String>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemBktRunner;
impl BktCommandRunner for SystemBktRunner {
fn run(&self, args: &[String]) -> BktCommandResult<String> {
run_command_output("bkt", None, args.iter().map(|arg| OsStr::new(arg.as_str())))
.map_err(BktCommandError::from)
}
}
impl From<CommandOutputError> for BktCommandError {
fn from(error: CommandOutputError) -> Self {
match error.kind {
CommandOutputErrorKind::NotFound => Self::MissingBkt,
CommandOutputErrorKind::SpawnFailed | CommandOutputErrorKind::Unsuccessful => {
Self::Failed {
status: error.status,
stderr: error.stderr,
}
}
}
}
}
fn read_blob_with_repo(repo_root: &Path, sha: &str, path: &Path) -> Option<String> {
let spec = format!("{}:{}", sha, path.to_string_lossy());
let exists = run_command_output(
"git",
Some(repo_root),
["cat-file", "-e", spec.as_str()]
.iter()
.map(|s| OsStr::new(*s)),
);
if exists.is_err() {
return None;
}
run_command_output(
"git",
Some(repo_root),
["show", spec.as_str()].iter().map(|s| OsStr::new(*s)),
)
.ok()
}
fn local_range_diff(repo_root: &Path, start_sha: &str, end_sha: &str) -> Option<String> {
for sha in [start_sha, end_sha] {
let exists = run_command_output(
"git",
Some(repo_root),
["cat-file", "-e", sha].iter().map(|s| OsStr::new(*s)),
);
if exists.is_err() {
return None;
}
}
let range = format!("{start_sha}..{end_sha}");
run_command_output(
"git",
Some(repo_root),
["diff", range.as_str()].iter().map(|s| OsStr::new(*s)),
)
.ok()
}
#[derive(Debug, Clone)]
pub struct BitbucketBktBackend<R = SystemBktRunner> {
default_repository: Option<ForgeRepository>,
runner: R,
local_checkout: Option<PathBuf>,
}
impl BitbucketBktBackend<SystemBktRunner> {
pub fn new(default_repository: Option<ForgeRepository>) -> Self {
Self {
default_repository,
runner: SystemBktRunner,
local_checkout: None,
}
}
pub fn with_local_checkout(mut self, checkout: Option<PathBuf>) -> Self {
self.local_checkout = checkout;
self
}
}
impl<R> BitbucketBktBackend<R>
where
R: BktCommandRunner,
{
pub fn with_runner(default_repository: Option<ForgeRepository>, runner: R) -> Self {
Self {
default_repository,
runner,
local_checkout: None,
}
}
fn resolve_repository(&self, target: &PullRequestTarget) -> Result<ForgeRepository> {
target
.repository
.clone()
.or_else(|| self.default_repository.clone())
.ok_or_else(|| {
TuicrError::Forge(format!(
"Bitbucket pull request target `{}` does not include a repository",
target.original
))
})
}
fn run_bkt(&self, args: Vec<String>) -> Result<String> {
self.runner.run(&args).map_err(map_bkt_error)
}
fn repo_args(repo: &ForgeRepository) -> Vec<String> {
vec![
"--workspace".to_string(),
repo.owner.clone(),
"--repo".to_string(),
repo.name.clone(),
]
}
fn repo_path(repo: &ForgeRepository) -> String {
format!("/2.0/repositories/{}/{}", repo.owner, repo.name)
}
fn run_api(&self, path: String, params: &[(&str, String)]) -> Result<String> {
let mut args = vec!["api".to_string(), path];
for (key, value) in params {
args.push("-P".to_string());
args.push(format!("{key}={value}"));
}
self.run_bkt(args)
}
fn viewer_uuid(&self) -> Result<Option<String>> {
let output = self.run_api("/2.0/user".to_string(), &[])?;
let user: BbUser = serde_json::from_str(&output)?;
Ok(if user.uuid.is_empty() {
None
} else {
Some(user.uuid)
})
}
fn promote_sha(&self, repo: &ForgeRepository, sha: &str) -> String {
if sha.len() >= 40 || sha.is_empty() {
return sha.to_string();
}
let path = format!("{}/commit/{}", Self::repo_path(repo), sha);
let Ok(output) = self.run_api(path, &[]) else {
return sha.to_string();
};
match serde_json::from_str::<BbCommit>(&output) {
Ok(commit) if commit.hash.starts_with(sha) => commit.hash,
_ => sha.to_string(),
}
}
fn collect_pages<T: serde::de::DeserializeOwned>(&self, path: String) -> Result<Vec<T>> {
let mut all: Vec<T> = Vec::new();
let mut next = Some(path);
let mut fetched = 0;
while let Some(target) = next.take() {
let params: &[(&str, String)] = if fetched == 0 {
&[("pagelen", MAX_PAGE_LEN.to_string())]
} else {
&[]
};
let output = self.run_api(target, params)?;
let parsed: BbPaged<T> = serde_json::from_str(&output)?;
all.extend(parsed.values);
fetched += 1;
if fetched >= MAX_PAGES {
break;
}
next = parsed.next;
}
Ok(all)
}
fn list_comments(&self, pr: &PullRequestDetails) -> Result<Vec<BbComment>> {
let path = format!(
"{}/pullrequests/{}/comments",
Self::repo_path(&pr.repository),
pr.number
);
self.collect_pages(path)
}
fn fetch_file_via_api(&self, request: &ForgeFileLinesRequest) -> Result<String> {
let path_str = request.path.to_string_lossy().replace('\\', "/");
let path = format!(
"{}/src/{}/{}",
Self::repo_path(&request.repository),
request.sha(),
path_str,
);
self.run_api(path, &[])
}
fn post_comment(
&self,
pr: &PullRequestDetails,
body: &serde_json::Value,
) -> Result<Option<u64>> {
let path = format!(
"{}/pullrequests/{}/comments",
Self::repo_path(&pr.repository),
pr.number
);
let args = vec![
"api".to_string(),
path,
"--method".to_string(),
"POST".to_string(),
"--input".to_string(),
serde_json::to_string(body)?,
];
let output = self.runner.run(&args).map_err(map_create_comment_error)?;
Ok(serde_json::from_str::<BbComment>(&output)
.ok()
.map(|comment| comment.id))
}
fn inline_anchor(comment: &crate::forge::submit::InlineComment) -> serde_json::Value {
let path = comment.path.to_string_lossy().replace('\\', "/");
let mut inline = serde_json::json!({ "path": path });
let (line_key, start_key) = match comment.side {
GhSide::Right => ("to", "start_to"),
GhSide::Left => ("from", "start_from"),
};
inline[line_key] = serde_json::Value::from(comment.line);
if let Some(start_line) = comment.start_line
&& start_line != comment.line
{
inline[start_key] = serde_json::Value::from(start_line);
}
inline
}
}
impl<R> ForgeBackend for BitbucketBktBackend<R>
where
R: BktCommandRunner,
{
fn list_pull_requests(&self, query: PullRequestListQuery) -> Result<PagedPullRequests> {
let page_size = query.page_size.clamp(1, MAX_PR_LIST_PAGE_LEN);
let page = query.already_loaded / page_size + 1;
let mut params = vec![
("pagelen", page_size.to_string()),
("page", page.to_string()),
];
match query.scope {
PullRequestListScope::Open => params.push(("state", "OPEN".to_string())),
PullRequestListScope::ReviewRequested => {
let uuid = self.viewer_uuid()?.ok_or_else(|| {
TuicrError::Forge(
"Could not determine the authenticated Bitbucket user.\n\
Run `bkt auth status`."
.to_string(),
)
})?;
params.push(("q", format!("state=\"OPEN\" AND reviewers.uuid=\"{uuid}\"")));
}
}
let path = format!("{}/pullrequests", Self::repo_path(&query.repository));
let output = self.run_api(path, ¶ms)?;
let parsed: BbPaged<BbPullRequest> = serde_json::from_str(&output)?;
let has_more = parsed.has_more();
let pull_requests = parsed
.values
.into_iter()
.map(|pr| pr.into_summary(&query.repository))
.collect::<Vec<_>>();
let total_loaded = query.already_loaded + pull_requests.len();
Ok(PagedPullRequests {
pull_requests,
has_more,
total_loaded,
})
}
fn get_pull_request(&self, target: PullRequestTarget) -> Result<PullRequestDetails> {
let repository = self.resolve_repository(&target)?;
let path = format!(
"{}/pullrequests/{}",
Self::repo_path(&repository),
target.number
);
let output = self.run_api(path, &[])?;
let pr: BbPullRequest = serde_json::from_str(&output)?;
let mut details = pr.into_details(&repository)?;
details.head_sha = self.promote_sha(&repository, &details.head_sha);
details.base_sha = self.promote_sha(&repository, &details.base_sha);
Ok(details)
}
fn get_pull_request_diff(&self, pr: &PullRequestDetails) -> Result<String> {
let mut args = vec!["pr".to_string(), "diff".to_string(), pr.number.to_string()];
args.extend(Self::repo_args(&pr.repository));
self.run_bkt(args)
}
fn local_checkout_path(&self) -> Option<PathBuf> {
self.local_checkout.clone()
}
fn list_pull_request_commits(&self, pr: &PullRequestDetails) -> Result<Vec<PullRequestCommit>> {
let path = format!(
"{}/pullrequests/{}/commits",
Self::repo_path(&pr.repository),
pr.number
);
let rows: Vec<BbCommit> = self.collect_pages(path)?;
Ok(rows
.into_iter()
.map(BbCommit::into_pull_request_commit)
.rev()
.collect())
}
fn list_pull_request_review_metadata(
&self,
pr: &PullRequestDetails,
) -> Result<PullRequestReviewMetadata> {
let path = format!(
"{}/pullrequests/{}",
Self::repo_path(&pr.repository),
pr.number
);
let output = self.run_api(path, &[])?;
let payload: BbPullRequest = serde_json::from_str(&output)?;
Ok(PullRequestReviewMetadata {
viewer_login: self.viewer_uuid().unwrap_or_default(),
reviews: payload.review_records(),
})
}
fn get_pull_request_commit_range_diff(
&self,
pr: &PullRequestDetails,
start_sha: &str,
end_sha: &str,
) -> Result<String> {
if let Some(root) = self.local_checkout.as_deref()
&& let Some(diff) = local_range_diff(root, start_sha, end_sha)
{
return Ok(diff);
}
let path = format!(
"{}/diff/{}..{}",
Self::repo_path(&pr.repository),
end_sha,
start_sha
);
self.run_api(path, &[])
}
fn list_review_threads(&self, pr: &PullRequestDetails) -> Result<Vec<RemoteReviewThread>> {
Ok(group_into_review_threads(self.list_comments(pr)?))
}
fn list_review_summaries(&self, pr: &PullRequestDetails) -> Result<Vec<RemoteReviewSummary>> {
Ok(review_summaries(&self.list_comments(pr)?))
}
fn fetch_file_lines(&self, request: ForgeFileLinesRequest) -> Result<Vec<DiffLine>> {
if request.start_line == 0 || request.start_line > request.end_line {
return Ok(Vec::new());
}
let content = match self
.local_checkout
.as_deref()
.and_then(|root| read_blob_with_repo(root, request.sha(), request.path.as_path()))
{
Some(content) => content,
None => self.fetch_file_via_api(&request)?,
};
Ok(slice_context_lines(
&content,
request.start_line,
request.end_line,
))
}
fn file_line_count(&self, request: ForgeFileLinesRequest) -> Result<u32> {
let content = match self
.local_checkout
.as_deref()
.and_then(|root| read_blob_with_repo(root, request.sha(), request.path.as_path()))
{
Some(content) => content,
None => self.fetch_file_via_api(&request)?,
};
Ok(content.lines().count() as u32)
}
fn create_review(
&self,
pr: &PullRequestDetails,
request: CreateReviewRequest<'_>,
) -> Result<GhCreateReviewResponse> {
match request.event {
SubmitEvent::Comment | SubmitEvent::Approve => {}
SubmitEvent::RequestChanges => {
return Err(TuicrError::UnsupportedOperation(
"Requesting changes is not supported for Bitbucket yet. \
Use `:submit` to post comments, then request changes in Bitbucket."
.to_string(),
));
}
SubmitEvent::Draft => {
return Err(TuicrError::UnsupportedOperation(
"Draft (pending) reviews are not supported for Bitbucket yet. \
Use `:submit` to publish comments directly."
.to_string(),
));
}
}
let mut first_comment_id: Option<u64> = None;
if !request.body.is_empty() {
let body = serde_json::json!({ "content": { "raw": request.body } });
let id = self.post_comment(pr, &body)?;
first_comment_id = first_comment_id.or(id);
}
for comment in request.comments {
let body = serde_json::json!({
"content": { "raw": comment.body },
"inline": Self::inline_anchor(comment),
});
let id = self.post_comment(pr, &body)?;
first_comment_id = first_comment_id.or(id);
}
let approved = request.event == SubmitEvent::Approve;
if approved {
let mut args = vec![
"pr".to_string(),
"approve".to_string(),
pr.number.to_string(),
];
args.extend(Self::repo_args(&pr.repository));
self.runner.run(&args).map_err(map_approve_error)?;
}
Ok(GhCreateReviewResponse {
id: first_comment_id.unwrap_or(0),
html_url: pr.url.clone(),
state: if approved { "APPROVED" } else { "COMMENTED" }.to_string(),
})
}
}
pub fn parse_pull_request_target_bitbucket(input: &str) -> Result<PullRequestTarget> {
let trimmed = input.trim();
if trimmed.is_empty() {
return malformed_target(input);
}
if let Some(target) = parse_numeric_target(trimmed) {
return Ok(target);
}
if let Some(target) = parse_bitbucket_url_target(trimmed) {
return Ok(target);
}
if let Some(target) = parse_bitbucket_repo_hash_target(trimmed) {
return Ok(target);
}
malformed_target(input)
}
pub fn parse_bitbucket_remote_url(remote_url: &str) -> Option<ForgeRepository> {
let trimmed = trim_url_suffix(remote_url.trim());
if trimmed.is_empty() {
return None;
}
if let Some((host, path)) = parse_scp_like_remote(trimmed) {
let resolved = resolve_ssh_hostname(host);
if !is_bitbucket_cloud_host(&resolved) {
return None;
}
return bitbucket_repository_from_path(BITBUCKET_CLOUD_HOST, path);
}
let without_scheme = strip_scheme(trimmed)?;
let without_user = without_scheme
.rsplit_once('@')
.map(|(_, rest)| rest)
.unwrap_or(without_scheme);
let (host, path) = without_user.split_once('/')?;
if !is_bitbucket_cloud_host(host) {
return None;
}
bitbucket_repository_from_path(BITBUCKET_CLOUD_HOST, path)
}
fn is_bitbucket_cloud_host(host: &str) -> bool {
host.eq_ignore_ascii_case(BITBUCKET_CLOUD_HOST)
|| host.eq_ignore_ascii_case("altssh.bitbucket.org")
|| host.eq_ignore_ascii_case("api.bitbucket.org")
}
fn bitbucket_repository_from_path(host: &str, path: &str) -> Option<ForgeRepository> {
let mut parts = path.split('/').filter(|part| !part.is_empty());
let workspace = parts.next()?;
let repo = parts.next()?;
Some(ForgeRepository::bitbucket(
host,
workspace,
strip_git_suffix(trim_url_suffix(repo)),
))
}
fn parse_bitbucket_url_target(target: &str) -> Option<PullRequestTarget> {
let without_scheme = strip_scheme(target)?;
let trimmed = trim_url_suffix(without_scheme);
let parts: Vec<&str> = trimmed.split('/').filter(|p| !p.is_empty()).collect();
if parts.len() < 5 {
return None;
}
if !is_bitbucket_cloud_host(parts[0]) || parts[3] != "pull-requests" {
return None;
}
let number = parts[4].parse::<u64>().ok()?;
if number == 0 {
return None;
}
Some(PullRequestTarget::with_repository(
ForgeRepository::bitbucket(BITBUCKET_CLOUD_HOST, parts[1], strip_git_suffix(parts[2])),
number,
target,
))
}
fn parse_bitbucket_repo_hash_target(target: &str) -> Option<PullRequestTarget> {
let (repo_part, number_part) = target.split_once('#')?;
let number = number_part.parse::<u64>().ok()?;
if number == 0 {
return None;
}
let parts: Vec<&str> = repo_part.split('/').filter(|p| !p.is_empty()).collect();
let (workspace, repo) = match parts.as_slice() {
[host, workspace, repo] if is_bitbucket_cloud_host(host) => (*workspace, *repo),
_ => return None,
};
Some(PullRequestTarget::with_repository(
ForgeRepository::bitbucket(BITBUCKET_CLOUD_HOST, workspace, strip_git_suffix(repo)),
number,
target,
))
}
fn parse_numeric_target(target: &str) -> Option<PullRequestTarget> {
if !target.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
let number = target.parse::<u64>().ok()?;
if number == 0 {
return None;
}
Some(PullRequestTarget::number(number, target))
}
fn strip_scheme(value: &str) -> Option<&str> {
value
.strip_prefix("https://")
.or_else(|| value.strip_prefix("http://"))
.or_else(|| value.strip_prefix("ssh://"))
}
fn trim_url_suffix(value: &str) -> &str {
value
.split(['?', '#'])
.next()
.unwrap_or(value)
.trim_end_matches('/')
}
fn strip_git_suffix(value: &str) -> &str {
value.strip_suffix(".git").unwrap_or(value)
}
fn parse_scp_like_remote(remote_url: &str) -> Option<(&str, &str)> {
if remote_url.contains("://") {
return None;
}
let (host_part, path) = remote_url.split_once(':')?;
if host_part.contains('/') || path.is_empty() {
return None;
}
let host = host_part
.rsplit_once('@')
.map(|(_, host)| host)
.unwrap_or(host_part);
Some((host, path))
}
fn resolve_ssh_hostname(alias: &str) -> String {
let Ok(home) = std::env::var("HOME") else {
return alias.to_string();
};
let path = PathBuf::from(home).join(".ssh/config");
let Ok(content) = fs::read_to_string(path) else {
return alias.to_string();
};
resolve_ssh_hostname_from_config(alias, &content)
}
fn resolve_ssh_hostname_from_config(alias: &str, config: &str) -> String {
let mut in_block = false;
for raw in config.lines() {
let line = raw.split_once('#').map_or(raw, |(before, _)| before).trim();
if line.is_empty() {
continue;
}
let (key, value) = line
.split_once(|c: char| c.is_whitespace() || c == '=')
.unwrap_or((line, ""));
let value = value
.trim_start_matches(|c: char| c.is_whitespace() || c == '=')
.trim();
if key.eq_ignore_ascii_case("Host") {
in_block = value.split_whitespace().any(|pat| pat == alias);
} else if key.eq_ignore_ascii_case("Match") {
in_block = false;
} else if in_block && key.eq_ignore_ascii_case("HostName") {
return value.to_string();
}
}
alias.to_string()
}
fn map_bkt_error(error: BktCommandError) -> TuicrError {
match error {
BktCommandError::MissingBkt => TuicrError::Forge(
"Bitbucket integration requires `bkt`.\n\
Install it with `brew install avivsinai/tap/bitbucket-cli`, \
then run `bkt auth login https://bitbucket.org --kind cloud --web-token`."
.to_string(),
),
BktCommandError::Failed { stderr, .. } if looks_like_auth_failure(&stderr) => {
TuicrError::Forge(
"Bitbucket authentication failed.\n\
Run `bkt auth status`, then `bkt auth login https://bitbucket.org --kind cloud`."
.to_string(),
)
}
BktCommandError::Failed { stderr, status } => TuicrError::Forge(format!(
"Bitbucket command failed: {}",
detail(stderr, status)
)),
}
}
fn map_create_comment_error(error: BktCommandError) -> TuicrError {
match &error {
BktCommandError::Failed { stderr, .. } if looks_like_permission_failure(stderr) => {
TuicrError::Forge(
"Bitbucket rejected the comment: your token lacks pull request write access.\n\
Re-run `bkt auth login` with the `Pull requests: Write` scope."
.to_string(),
)
}
_ => map_bkt_error(error),
}
}
fn map_approve_error(error: BktCommandError) -> TuicrError {
match &error {
BktCommandError::Failed { stderr, .. } if looks_like_permission_failure(stderr) => {
TuicrError::Forge(
"Comments were posted, but Bitbucket rejected the approval: \
you may not be a reviewer on this pull request."
.to_string(),
)
}
_ => map_bkt_error(error),
}
}
fn detail(stderr: String, status: Option<i32>) -> String {
if stderr.is_empty() {
status
.map(|code| format!("bkt exited with status {code}"))
.unwrap_or_else(|| "bkt command failed".to_string())
} else {
stderr
}
}
fn looks_like_auth_failure(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
lower.contains("bkt auth login")
|| lower.contains("not logged in")
|| lower.contains("no credentials")
|| lower.contains("authentication failed")
|| lower.contains("requires authentication")
|| lower.contains("401 unauthorized")
}
fn looks_like_permission_failure(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
lower.contains("403 forbidden")
|| lower.contains("http 403")
|| lower.contains("status: 403")
|| lower.contains("not allowed")
|| lower.contains("forbidden")
}
fn malformed_target<T>(input: &str) -> Result<T> {
Err(TuicrError::Forge(format!(
"Malformed Bitbucket pull request target: `{input}`"
)))
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use super::*;
use crate::forge::submit::InlineComment;
use crate::model::FileStatus;
struct RecordingRunner {
calls: RefCell<Vec<Vec<String>>>,
responses: RefCell<Vec<String>>,
}
impl RecordingRunner {
fn with_responses(responses: Vec<&str>) -> Self {
Self {
calls: RefCell::new(Vec::new()),
responses: RefCell::new(responses.into_iter().map(String::from).collect()),
}
}
}
impl BktCommandRunner for RecordingRunner {
fn run(&self, args: &[String]) -> BktCommandResult<String> {
self.calls.borrow_mut().push(args.to_vec());
Ok(self
.responses
.borrow_mut()
.drain(..1)
.next()
.unwrap_or_default())
}
}
struct FailingRunner {
stderr: String,
}
impl BktCommandRunner for FailingRunner {
fn run(&self, _args: &[String]) -> BktCommandResult<String> {
Err(BktCommandError::Failed {
status: Some(1),
stderr: self.stderr.clone(),
})
}
}
fn repo() -> ForgeRepository {
ForgeRepository::bitbucket("bitbucket.org", "example-workspace", "repo")
}
fn backend(responses: Vec<&str>) -> BitbucketBktBackend<RecordingRunner> {
BitbucketBktBackend::with_runner(Some(repo()), RecordingRunner::with_responses(responses))
}
fn details() -> PullRequestDetails {
PullRequestDetails {
repository: repo(),
number: 830,
title: "A change".to_string(),
url: "https://bitbucket.org/example-workspace/repo/pull-requests/830".to_string(),
state: "OPEN".to_string(),
is_draft: false,
author: Some("Alice".to_string()),
head_ref_name: "feature".to_string(),
base_ref_name: "main".to_string(),
head_sha: "a".repeat(40),
base_sha: "b".repeat(40),
body: String::new(),
updated_at: None,
closed: false,
merged_at: None,
diff_start_sha: None,
}
}
const PR_JSON: &str = r#"{
"id": 830, "title": "A change", "state": "OPEN", "draft": false,
"source": { "branch": { "name": "feature" }, "commit": { "hash": "7d9bf1fa670a" } },
"destination": { "branch": { "name": "main" }, "commit": { "hash": "b7e0a737bb8c" } },
"links": { "html": { "href": "https://bitbucket.org/example-workspace/repo/pull-requests/830" } }
}"#;
fn inline_comment(line: u32, side: GhSide, start_line: Option<u32>) -> InlineComment {
InlineComment {
path: PathBuf::from("src/lib.rs"),
line,
side,
counterpart_line: None,
start_line,
start_side: start_line.map(|_| side),
old_path: None,
body: "a note".to_string(),
comment_id: "local-1".to_string(),
}
}
#[test]
fn should_always_pass_workspace_and_repo_to_first_class_commands() {
let backend = backend(vec!["diff --git a/x b/x\n"]);
backend.get_pull_request_diff(&details()).unwrap();
assert_eq!(
backend.runner.calls.borrow()[0],
vec![
"pr",
"diff",
"830",
"--workspace",
"example-workspace",
"--repo",
"repo"
]
);
}
#[test]
fn should_request_the_page_containing_the_next_unseen_row() {
let backend = backend(vec![r#"{"values":[]}"#]);
let query = PullRequestListQuery {
repository: repo(),
already_loaded: 60,
page_size: 30,
scope: PullRequestListScope::Open,
};
backend.list_pull_requests(query).unwrap();
assert_eq!(
backend.runner.calls.borrow()[0],
vec![
"api",
"/2.0/repositories/example-workspace/repo/pullrequests",
"-P",
"pagelen=30",
"-P",
"page=3",
"-P",
"state=OPEN",
]
);
}
#[test]
fn should_report_more_pages_when_next_link_present() {
let backend = backend(vec![
r#"{"values":[],"next":"https://api.bitbucket.org/2.0/x?page=2"}"#,
]);
let paged = backend
.list_pull_requests(PullRequestListQuery::first_page(repo(), 30))
.unwrap();
assert!(paged.has_more);
}
#[test]
fn should_filter_by_reviewer_uuid_for_review_requested_scope() {
let backend = backend(vec![
r#"{"uuid":"{viewer-uuid}","username":"","display_name":"Example User"}"#,
r#"{"values":[]}"#,
]);
backend
.list_pull_requests(PullRequestListQuery::first_page_with_scope(
repo(),
30,
PullRequestListScope::ReviewRequested,
))
.unwrap();
let calls = backend.runner.calls.borrow();
assert_eq!(calls[0], vec!["api", "/2.0/user"]);
assert!(
calls[1].contains(&"q=state=\"OPEN\" AND reviewers.uuid=\"{viewer-uuid}\"".to_string()),
"expected state to be folded into the q filter, got {:?}",
calls[1]
);
assert!(
!calls[1].iter().any(|arg| arg == "state=OPEN"),
"a standalone state param is ignored by Cloud and must not be sent: {:?}",
calls[1]
);
}
#[test]
fn should_use_the_standalone_state_param_for_the_open_scope() {
let backend = backend(vec![r#"{"values":[]}"#]);
backend
.list_pull_requests(PullRequestListQuery::first_page(repo(), 30))
.unwrap();
let calls = backend.runner.calls.borrow();
assert_eq!(calls.len(), 1);
assert!(calls[0].contains(&"state=OPEN".to_string()));
}
#[test]
fn should_fail_clearly_when_viewer_identity_is_unavailable() {
let backend = backend(vec![r#"{"username":"","display_name":""}"#]);
let err = backend
.list_pull_requests(PullRequestListQuery::first_page_with_scope(
repo(),
30,
PullRequestListScope::ReviewRequested,
))
.unwrap_err();
assert!(
err.to_string().contains("bkt auth status"),
"unexpected error: {err}"
);
}
#[test]
fn should_promote_abbreviated_shas_to_full_length() {
let full_head = "7d9bf1fa670a02d075ee60b7e2034bced095e096";
let full_base = "b7e0a737bb8c1111111111111111111111111111";
let backend = backend(vec![
PR_JSON,
&format!(r#"{{"hash":"{full_head}","message":"x"}}"#),
&format!(r#"{{"hash":"{full_base}","message":"y"}}"#),
]);
let details = backend
.get_pull_request(PullRequestTarget::with_repository(repo(), 830, "830"))
.unwrap();
assert_eq!(details.head_sha, full_head);
assert_eq!(details.base_sha, full_base);
let calls = backend.runner.calls.borrow();
assert_eq!(
calls[1][1],
format!("/2.0/repositories/example-workspace/repo/commit/7d9bf1fa670a")
);
}
#[test]
fn should_keep_short_sha_when_promotion_returns_a_different_commit() {
let backend = backend(vec![
PR_JSON,
r#"{"hash":"ffffffffffffffffffffffffffffffffffffffff","message":"x"}"#,
r#"{"hash":"ffffffffffffffffffffffffffffffffffffffff","message":"y"}"#,
]);
let details = backend
.get_pull_request(PullRequestTarget::with_repository(repo(), 830, "830"))
.unwrap();
assert_eq!(details.head_sha, "7d9bf1fa670a");
}
#[test]
fn should_not_promote_a_sha_that_is_already_full_length() {
let json = r#"{
"id": 1, "state": "OPEN",
"source": { "branch": { "name": "f" },
"commit": { "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } },
"destination": { "branch": { "name": "main" } },
"links": { "html": { "href": "u" } }
}"#;
let backend = backend(vec![json]);
backend
.get_pull_request(PullRequestTarget::with_repository(repo(), 1, "1"))
.unwrap();
assert_eq!(backend.runner.calls.borrow().len(), 1);
}
#[test]
fn should_return_commits_oldest_first() {
let backend = backend(vec![
r#"{"values":[
{"hash":"cccccccccccccccccccccccccccccccccccccccc","message":"third"},
{"hash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","message":"second"},
{"hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","message":"first"}
]}"#,
]);
let commits = backend.list_pull_request_commits(&details()).unwrap();
let summaries: Vec<&str> = commits.iter().map(|c| c.summary.as_str()).collect();
assert_eq!(summaries, vec!["first", "second", "third"]);
}
#[test]
fn should_stop_paginating_when_no_next_link() {
let backend = backend(vec![r#"{"values":[{"hash":"a","message":"m"}]}"#]);
backend.list_pull_request_commits(&details()).unwrap();
assert_eq!(backend.runner.calls.borrow().len(), 1);
}
#[test]
fn should_never_send_an_explicit_page_param_when_paginating() {
let next = "https://api.bitbucket.org/2.0/repositories/example-workspace/repo\
/pullrequests/830/commits?pagelen=100&page=2";
let backend = backend(vec![
&format!(r#"{{"values":[{{"hash":"a","message":"m"}}],"next":"{next}"}}"#),
r#"{"values":[{"hash":"b","message":"m2"}]}"#,
]);
let commits = backend.list_pull_request_commits(&details()).unwrap();
let calls = backend.runner.calls.borrow();
assert_eq!(calls.len(), 2);
assert!(
!calls.iter().flatten().any(|arg| arg.starts_with("page=")),
"must not construct page numbers: {calls:?}"
);
assert!(calls[0].contains(&"pagelen=100".to_string()));
assert_eq!(calls[1], vec!["api", next]);
assert_eq!(commits.len(), 2);
}
#[test]
fn should_stop_after_the_page_ceiling_even_if_next_keeps_coming() {
struct AlwaysMore;
impl BktCommandRunner for AlwaysMore {
fn run(&self, _args: &[String]) -> BktCommandResult<String> {
Ok(r#"{"values":[{"hash":"a","message":"m"}],
"next":"https://api.bitbucket.org/2.0/next"}"#
.to_string())
}
}
let backend = BitbucketBktBackend::with_runner(Some(repo()), AlwaysMore);
let commits = backend.list_pull_request_commits(&details()).unwrap();
assert_eq!(commits.len(), MAX_PAGES);
}
#[test]
fn should_cap_the_pull_request_list_page_size() {
let backend = backend(vec![r#"{"values":[]}"#]);
let query = PullRequestListQuery {
repository: repo(),
already_loaded: 0,
page_size: 500,
scope: PullRequestListScope::Open,
};
backend.list_pull_requests(query).unwrap();
assert!(
backend.runner.calls.borrow()[0].contains(&"pagelen=50".to_string()),
"expected the page size to be clamped: {:?}",
backend.runner.calls.borrow()[0]
);
}
#[test]
fn should_reverse_the_diff_spec_for_a_commit_range() {
let backend = backend(vec!["diff --git a/x b/x\n"]);
backend
.get_pull_request_commit_range_diff(&details(), "oldsha", "newsha")
.unwrap();
assert_eq!(
backend.runner.calls.borrow()[0][1],
"/2.0/repositories/example-workspace/repo/diff/newsha..oldsha"
);
}
#[test]
fn should_split_comments_into_threads_and_summaries() {
let payload = r#"{"values":[
{"id":1,"content":{"raw":"LGTM"},"user":{"display_name":"A"},"parent":null},
{"id":2,"content":{"raw":"nit"},"user":{"display_name":"B"},"parent":null,
"inline":{"path":"src/lib.rs","to":42}}
]}"#;
let threads = backend(vec![payload])
.list_review_threads(&details())
.unwrap();
let summaries = backend(vec![payload])
.list_review_summaries(&details())
.unwrap();
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].line, Some(42));
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].body, "LGTM");
}
#[test]
fn should_fetch_file_lines_from_the_src_endpoint() {
let backend = backend(vec!["one\ntwo\nthree\nfour\n"]);
let request = ForgeFileLinesRequest {
repository: repo(),
base_sha: "b".repeat(40),
head_sha: "a".repeat(40),
path: PathBuf::from("src/lib.rs"),
status: FileStatus::Modified,
side: crate::forge::traits::ForgeFileSide::Head,
start_line: 2,
end_line: 3,
};
let lines = backend.fetch_file_lines(request).unwrap();
assert_eq!(
backend.runner.calls.borrow()[0][1],
format!(
"/2.0/repositories/example-workspace/repo/src/{}/src/lib.rs",
"a".repeat(40)
)
);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].content, "two");
assert_eq!(lines[1].content, "three");
}
#[test]
fn should_post_body_then_inline_comments_on_submit() {
let backend = backend(vec![r#"{"id":111}"#, r#"{"id":222}"#]);
let comments = vec![inline_comment(42, GhSide::Right, None)];
let request = CreateReviewRequest {
event: SubmitEvent::Comment,
commit_id: &"a".repeat(40),
body: "overall looks fine",
comments: &comments,
};
let response = backend.create_review(&details(), request).unwrap();
let calls = backend.runner.calls.borrow();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0][2..4], ["--method".to_string(), "POST".to_string()]);
let general: serde_json::Value = serde_json::from_str(&calls[0][5]).unwrap();
assert_eq!(general["content"]["raw"], "overall looks fine");
assert!(general.get("inline").is_none());
let inline: serde_json::Value = serde_json::from_str(&calls[1][5]).unwrap();
assert_eq!(inline["inline"]["path"], "src/lib.rs");
assert_eq!(inline["inline"]["to"], 42);
assert_eq!(response.id, 111);
assert_eq!(response.state, "COMMENTED");
}
#[test]
fn should_skip_the_general_comment_when_body_is_empty() {
let backend = backend(vec![r#"{"id":222}"#]);
let comments = vec![inline_comment(1, GhSide::Right, None)];
let request = CreateReviewRequest {
event: SubmitEvent::Comment,
commit_id: &"a".repeat(40),
body: "",
comments: &comments,
};
backend.create_review(&details(), request).unwrap();
assert_eq!(backend.runner.calls.borrow().len(), 1);
}
#[test]
fn should_anchor_left_side_comments_with_from() {
let backend = backend(vec![r#"{"id":1}"#]);
let comments = vec![inline_comment(17, GhSide::Left, None)];
let request = CreateReviewRequest {
event: SubmitEvent::Comment,
commit_id: &"a".repeat(40),
body: "",
comments: &comments,
};
backend.create_review(&details(), request).unwrap();
let calls = backend.runner.calls.borrow();
let posted: serde_json::Value = serde_json::from_str(&calls[0][5]).unwrap();
assert_eq!(posted["inline"]["from"], 17);
assert!(posted["inline"].get("to").is_none());
}
#[test]
fn should_send_a_range_anchor_for_multi_line_comments() {
let backend = backend(vec![r#"{"id":1}"#]);
let comments = vec![inline_comment(14, GhSide::Right, Some(10))];
let request = CreateReviewRequest {
event: SubmitEvent::Comment,
commit_id: &"a".repeat(40),
body: "",
comments: &comments,
};
backend.create_review(&details(), request).unwrap();
let calls = backend.runner.calls.borrow();
let posted: serde_json::Value = serde_json::from_str(&calls[0][5]).unwrap();
assert_eq!(posted["inline"]["to"], 14);
assert_eq!(posted["inline"]["start_to"], 10);
}
#[test]
fn should_omit_range_anchor_when_start_equals_end() {
let backend = backend(vec![r#"{"id":1}"#]);
let comments = vec![inline_comment(9, GhSide::Right, Some(9))];
let request = CreateReviewRequest {
event: SubmitEvent::Comment,
commit_id: &"a".repeat(40),
body: "",
comments: &comments,
};
backend.create_review(&details(), request).unwrap();
let calls = backend.runner.calls.borrow();
let posted: serde_json::Value = serde_json::from_str(&calls[0][5]).unwrap();
assert!(posted["inline"].get("start_to").is_none());
}
#[test]
fn should_approve_after_posting_comments() {
let backend = backend(vec![r#"{"id":1}"#, "{}"]);
let comments = vec![inline_comment(3, GhSide::Right, None)];
let request = CreateReviewRequest {
event: SubmitEvent::Approve,
commit_id: &"a".repeat(40),
body: "",
comments: &comments,
};
let response = backend.create_review(&details(), request).unwrap();
let calls = backend.runner.calls.borrow();
assert_eq!(calls[0][0], "api");
assert_eq!(
calls[1],
vec![
"pr",
"approve",
"830",
"--workspace",
"example-workspace",
"--repo",
"repo"
]
);
assert_eq!(response.state, "APPROVED");
}
#[test]
fn should_synthesize_a_response_when_the_comment_id_is_unparseable() {
let backend = backend(vec!["not json"]);
let request = CreateReviewRequest {
event: SubmitEvent::Comment,
commit_id: &"a".repeat(40),
body: "hello",
comments: &[],
};
let response = backend.create_review(&details(), request).unwrap();
assert_eq!(response.id, 0);
assert_eq!(
response.html_url,
"https://bitbucket.org/example-workspace/repo/pull-requests/830"
);
}
#[test]
fn should_reject_request_changes_and_draft_before_posting_anything() {
for event in [SubmitEvent::RequestChanges, SubmitEvent::Draft] {
let backend = backend(vec![]);
let request = CreateReviewRequest {
event,
commit_id: &"a".repeat(40),
body: "text",
comments: &[],
};
let err = backend.create_review(&details(), request).unwrap_err();
assert!(
err.to_string().contains("not supported for Bitbucket yet"),
"unexpected error for {event:?}: {err}"
);
assert!(backend.runner.calls.borrow().is_empty());
}
}
#[test]
fn should_explain_how_to_authenticate_on_auth_failure() {
let backend = BitbucketBktBackend::with_runner(
Some(repo()),
FailingRunner {
stderr: "401 Unauthorized".to_string(),
},
);
let err = backend.get_pull_request_diff(&details()).unwrap_err();
assert!(
err.to_string().contains("bkt auth login"),
"unexpected error: {err}"
);
}
#[test]
fn should_explain_missing_scope_when_a_comment_is_forbidden() {
let backend = BitbucketBktBackend::with_runner(
Some(repo()),
FailingRunner {
stderr: "403 Forbidden".to_string(),
},
);
let request = CreateReviewRequest {
event: SubmitEvent::Comment,
commit_id: &"a".repeat(40),
body: "text",
comments: &[],
};
let err = backend.create_review(&details(), request).unwrap_err();
assert!(
err.to_string().contains("Pull requests: Write"),
"unexpected error: {err}"
);
}
#[test]
fn should_say_comments_survived_when_only_the_approval_fails() {
struct ApproveFails;
impl BktCommandRunner for ApproveFails {
fn run(&self, args: &[String]) -> BktCommandResult<String> {
if args[0] == "pr" && args[1] == "approve" {
return Err(BktCommandError::Failed {
status: Some(1),
stderr: "403 Forbidden".to_string(),
});
}
Ok(r#"{"id":1}"#.to_string())
}
}
let backend = BitbucketBktBackend::with_runner(Some(repo()), ApproveFails);
let request = CreateReviewRequest {
event: SubmitEvent::Approve,
commit_id: &"a".repeat(40),
body: "text",
comments: &[],
};
let err = backend.create_review(&details(), request).unwrap_err();
assert!(
err.to_string().contains("Comments were posted"),
"unexpected error: {err}"
);
}
#[test]
fn should_require_a_repository_for_a_bare_numeric_target() {
let backend =
BitbucketBktBackend::with_runner(None, RecordingRunner::with_responses(vec![]));
let err = backend
.get_pull_request(PullRequestTarget::number(5, "5"))
.unwrap_err();
assert!(
err.to_string().contains("does not include a repository"),
"unexpected error: {err}"
);
}
#[test]
fn should_parse_bitbucket_cloud_remote_urls() {
let expected = ForgeRepository::bitbucket("bitbucket.org", "example-workspace", "repo");
for url in [
"https://bitbucket.org/example-workspace/repo.git",
"https://bitbucket.org/example-workspace/repo",
"https://someuser@bitbucket.org/example-workspace/repo.git",
"git@bitbucket.org:example-workspace/repo.git",
"ssh://git@bitbucket.org/example-workspace/repo.git",
"git@altssh.bitbucket.org:example-workspace/repo.git",
] {
assert_eq!(
parse_bitbucket_remote_url(url).as_ref(),
Some(&expected),
"failed for {url}"
);
}
}
#[test]
fn should_not_claim_non_bitbucket_remotes() {
for url in [
"https://github.com/owner/repo.git",
"git@gitlab.com:owner/repo.git",
"https://gitlab.example.com/owner/repo.git",
] {
assert!(
parse_bitbucket_remote_url(url).is_none(),
"wrongly claimed {url}"
);
}
}
#[test]
fn should_not_claim_bitbucket_data_center_remotes() {
for url in [
"https://bitbucket.example.com/scm/proj/repo.git",
"git@bitbucket.mycompany.io:proj/repo.git",
] {
assert!(
parse_bitbucket_remote_url(url).is_none(),
"wrongly claimed DC remote {url}"
);
}
}
#[test]
fn should_parse_a_bitbucket_pull_request_url() {
let target = parse_pull_request_target_bitbucket(
"https://bitbucket.org/example-workspace/repo/pull-requests/830",
)
.unwrap();
assert_eq!(target.number, 830);
assert_eq!(
target.repository,
Some(ForgeRepository::bitbucket(
"bitbucket.org",
"example-workspace",
"repo"
))
);
}
#[test]
fn should_parse_a_pull_request_url_with_trailing_segments() {
let target = parse_pull_request_target_bitbucket(
"https://bitbucket.org/example-workspace/repo/pull-requests/830/some-branch-name/diff",
)
.unwrap();
assert_eq!(target.number, 830);
}
#[test]
fn should_parse_a_bare_number() {
let target = parse_pull_request_target_bitbucket("42").unwrap();
assert_eq!(target.number, 42);
assert!(target.repository.is_none());
}
#[test]
fn should_parse_a_host_qualified_repo_hash_target() {
let target =
parse_pull_request_target_bitbucket("bitbucket.org/example-workspace/repo#7").unwrap();
assert_eq!(target.number, 7);
assert_eq!(
target.repository.map(|r| r.slug()),
Some("example-workspace/repo".to_string())
);
}
#[test]
#[ignore = "hits the live Bitbucket API; needs TUICR_BB_* env vars"]
fn bitbucket_live_read_path() {
let (Ok(workspace), Ok(name), Ok(number)) = (
std::env::var("TUICR_BB_WORKSPACE"),
std::env::var("TUICR_BB_REPO"),
std::env::var("TUICR_BB_PR"),
) else {
panic!("set TUICR_BB_WORKSPACE, TUICR_BB_REPO and TUICR_BB_PR");
};
let number: u64 = number.parse().expect("TUICR_BB_PR must be a number");
let repository = ForgeRepository::bitbucket(BITBUCKET_CLOUD_HOST, workspace, name);
let backend = BitbucketBktBackend::new(Some(repository.clone()));
let details = backend
.get_pull_request(PullRequestTarget::with_repository(
repository.clone(),
number,
number.to_string(),
))
.expect("get_pull_request");
println!(
"PR #{}: {} [{}] {} -> {}",
details.number,
details.title,
details.state,
details.head_ref_name,
details.base_ref_name
);
assert_eq!(details.head_sha.len(), 40, "head_sha was not promoted");
assert_eq!(details.number, number);
let patch = backend
.get_pull_request_diff(&details)
.expect("get_pull_request_diff");
assert!(
patch.contains("diff --git "),
"patch is missing git headers, the shared parser needs them"
);
let commits = backend
.list_pull_request_commits(&details)
.expect("list_pull_request_commits");
println!("{} commits", commits.len());
for commit in &commits {
assert_eq!(commit.oid.len(), 40, "short oid in commit list");
}
let threads = backend
.list_review_threads(&details)
.expect("list_review_threads");
for thread in &threads {
println!(
"thread {} {}:{:?} resolved={} outdated={} comments={}",
thread.id,
thread.path,
thread.line,
thread.is_resolved,
thread.is_outdated,
thread.comments.len()
);
assert!(thread.line.is_some(), "inline thread without an anchor");
}
let summaries = backend
.list_review_summaries(&details)
.expect("list_review_summaries");
println!("{} general comments", summaries.len());
let metadata = backend
.list_pull_request_review_metadata(&details)
.expect("list_pull_request_review_metadata");
println!(
"viewer={:?} reviews={}",
metadata.viewer_login,
metadata.reviews.len()
);
for scope in [
PullRequestListScope::Open,
PullRequestListScope::ReviewRequested,
] {
let listed = backend
.list_pull_requests(PullRequestListQuery::first_page_with_scope(
repository.clone(),
30,
scope,
))
.unwrap_or_else(|err| panic!("list_pull_requests({scope:?}): {err}"));
println!(
"{:?}: {} rows, has_more={}",
scope,
listed.pull_requests.len(),
listed.has_more
);
for row in listed.pull_requests.iter().take(3) {
println!(" #{} {} [{}]", row.number, row.title, row.state);
}
}
}
#[test]
fn should_not_claim_ambiguous_or_foreign_targets() {
for target in [
"owner/repo#5",
"https://github.com/owner/repo/pull/5",
"https://gitlab.com/owner/repo/-/merge_requests/5",
"",
"not-a-target",
"0",
] {
assert!(
parse_pull_request_target_bitbucket(target).is_err(),
"wrongly claimed {target:?}"
);
}
}
}