use anyhow::Result;
use base64::Engine;
use reqwest::Client;
use serde::Deserialize;
use super::{is_bot_comment, InlineComment, PrMeta, ReviewPost};
use crate::clip;
use crate::config::{require, Config};
fn auth_header(cfg: &Config) -> Result<String> {
require(&cfg.bitbucket_email, "BB_EMAIL")?;
require(&cfg.bitbucket_token, "BB_API_TOKEN")?;
let raw = format!("{}:{}", cfg.bitbucket_email, cfg.bitbucket_token);
Ok(format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(raw)
))
}
fn pr_base(cfg: &Config, repo: &str, pr: u64) -> String {
format!(
"{}/repositories/{repo}/pullrequests/{pr}",
cfg.bitbucket_api_base
)
}
pub async fn get_diff(client: &Client, cfg: &Config, repo: &str, pr: u64) -> Result<String> {
let res = client
.get(format!("{}/diff", pr_base(cfg, repo, pr)))
.header(reqwest::header::AUTHORIZATION, auth_header(cfg)?)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
anyhow::bail!(
"Bitbucket getDiff {status}: {}",
clip(&res.text().await.unwrap_or_default(), 300)
);
}
Ok(res.text().await?)
}
pub async fn get_meta(client: &Client, cfg: &Config, repo: &str, pr: u64) -> Result<PrMeta> {
#[derive(Deserialize)]
struct Branch {
name: Option<String>,
}
#[derive(Deserialize)]
struct Destination {
branch: Option<Branch>,
}
#[derive(Deserialize)]
struct Commit {
hash: Option<String>,
}
#[derive(Deserialize)]
struct Source {
commit: Option<Commit>,
}
#[derive(Deserialize)]
struct Pr {
title: Option<String>,
description: Option<String>,
destination: Option<Destination>,
source: Option<Source>,
}
let res = client
.get(pr_base(cfg, repo, pr))
.header(reqwest::header::AUTHORIZATION, auth_header(cfg)?)
.header("Accept", "application/json")
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
anyhow::bail!(
"Bitbucket getMeta {status}: {}",
clip(&res.text().await.unwrap_or_default(), 300)
);
}
let pr_data: Pr = res.json().await?;
let source_sha = pr_data
.source
.and_then(|s| s.commit)
.and_then(|c| c.hash)
.filter(|h| !h.is_empty());
let ci_status = match source_sha.as_ref().filter(|_| cfg.ci_status) {
Some(sha) => get_build_status(client, cfg, repo, sha)
.await
.unwrap_or_else(|e| {
tracing::warn!("could not fetch build statuses for {repo}@{sha}: {e:#}");
None
}),
None => None,
};
Ok(PrMeta {
repo: repo.to_string(),
pr,
title: pr_data.title,
base_branch: pr_data
.destination
.and_then(|d| d.branch)
.and_then(|b| b.name),
head_sha: None, body: pr_data.description,
ci_status,
})
}
pub async fn get_build_status(
client: &Client,
cfg: &Config,
repo: &str,
sha: &str,
) -> Result<Option<String>> {
#[derive(serde::Deserialize)]
struct Status {
key: Option<String>,
name: Option<String>,
state: Option<String>,
}
#[derive(serde::Deserialize)]
struct Page {
values: Option<Vec<Status>>,
}
let url = format!(
"{}/repositories/{repo}/commit/{sha}/statuses?pagelen=50",
cfg.bitbucket_api_base
);
let res = client
.get(url)
.header(reqwest::header::AUTHORIZATION, auth_header(cfg)?)
.header("Accept", "application/json")
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
anyhow::bail!(
"Bitbucket getStatuses {status}: {}",
clip(&res.text().await.unwrap_or_default(), 200)
);
}
let values = res.json::<Page>().await?.values.unwrap_or_default();
if values.is_empty() {
return Ok(None);
}
let rendered = values
.iter()
.map(|s| {
let name = s
.name
.as_deref()
.or(s.key.as_deref())
.unwrap_or("(unnamed build)");
format!("- {name}: {}", s.state.as_deref().unwrap_or("unknown"))
})
.collect::<Vec<_>>()
.join("\n");
Ok(Some(rendered))
}
pub async fn post_comment(
client: &Client,
cfg: &Config,
repo: &str,
pr: u64,
body: &str,
) -> Result<Option<String>> {
#[derive(Deserialize)]
struct Html {
href: Option<String>,
}
#[derive(Deserialize)]
struct Links {
html: Option<Html>,
}
#[derive(Deserialize)]
struct Created {
links: Option<Links>,
}
let marked = format!("{body}\n\n_{}_", cfg.comment_marker);
let res = client
.post(format!("{}/comments", pr_base(cfg, repo, pr)))
.header(reqwest::header::AUTHORIZATION, auth_header(cfg)?)
.json(&serde_json::json!({ "content": { "raw": marked } }))
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
anyhow::bail!(
"Bitbucket postComment {status}: {}",
clip(&res.text().await.unwrap_or_default(), 300)
);
}
let c: Created = res.json().await?;
Ok(c.links.and_then(|l| l.html).and_then(|h| h.href))
}
pub async fn update_pr_description(
client: &Client,
cfg: &Config,
meta: &PrMeta,
description: &str,
) -> Result<()> {
let title = meta.title.clone().unwrap_or_default();
let res = client
.put(pr_base(cfg, &meta.repo, meta.pr))
.header(reqwest::header::AUTHORIZATION, auth_header(cfg)?)
.json(&serde_json::json!({ "title": title, "description": description }))
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
anyhow::bail!(
"Bitbucket updatePrDescription {status}: {}",
clip(&res.text().await.unwrap_or_default(), 300)
);
}
Ok(())
}
pub async fn get_file_contents(
client: &Client,
cfg: &Config,
repo: &str,
r#ref: &str,
path: &str,
) -> Result<Option<String>> {
let res = client
.get(format!(
"{}/repositories/{repo}/src/{}/{}",
cfg.bitbucket_api_base,
super::github::enc_path(r#ref),
super::github::enc_path(path)
))
.header(reqwest::header::AUTHORIZATION, auth_header(cfg)?)
.send()
.await?;
if res.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !res.status().is_success() {
let status = res.status();
anyhow::bail!(
"Bitbucket getFileContents {status}: {}",
clip(&res.text().await.unwrap_or_default(), 300)
);
}
Ok(Some(res.text().await?))
}
pub fn clone_url(cfg: &Config, repo: &str) -> Result<String> {
require(&cfg.bitbucket_email, "BB_EMAIL")?;
require(&cfg.bitbucket_token, "BB_API_TOKEN")?;
Ok(format!(
"https://x-bitbucket-api-token-auth:{}@bitbucket.org/{repo}.git",
cfg.bitbucket_token
))
}
async fn find_bot_comments(
client: &Client,
cfg: &Config,
repo: &str,
pr: u64,
) -> Result<(Option<u64>, Vec<u64>)> {
#[derive(Deserialize)]
struct Content {
raw: Option<String>,
}
#[derive(Deserialize)]
struct Inline {}
#[derive(Deserialize)]
struct Comment {
id: u64,
content: Option<Content>,
inline: Option<Inline>,
deleted: Option<bool>,
}
#[derive(Deserialize)]
struct Page {
values: Vec<Comment>,
next: Option<String>,
}
let mut url = format!("{}/comments?pagelen=100", pr_base(cfg, repo, pr));
let mut summary: Option<u64> = None;
let mut inline: Vec<u64> = Vec::new();
loop {
let res = client
.get(&url)
.header(reqwest::header::AUTHORIZATION, auth_header(cfg)?)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
anyhow::bail!(
"Bitbucket listComments {status}: {}",
clip(&res.text().await.unwrap_or_default(), 300)
);
}
let page: Page = res.json().await?;
for c in page.values {
if c.deleted == Some(true) {
continue;
}
let is_ours = c
.content
.as_ref()
.and_then(|x| x.raw.as_deref())
.is_some_and(|b| is_bot_comment(cfg, b));
if !is_ours {
continue;
}
if c.inline.is_some() {
inline.push(c.id);
} else if summary.is_none() {
summary = Some(c.id);
}
}
match page.next {
Some(n) => url = n,
None => return Ok((summary, inline)),
}
}
}
async fn upsert_summary(
client: &Client,
cfg: &Config,
repo: &str,
pr: u64,
body: &str,
existing: Option<u64>,
) -> Result<Option<String>> {
#[derive(Deserialize)]
struct Html {
href: Option<String>,
}
#[derive(Deserialize)]
struct Links {
html: Option<Html>,
}
#[derive(Deserialize)]
struct Created {
links: Option<Links>,
}
let marked = format!("{body}\n\n_{}_", cfg.comment_marker);
let (req, action) = match existing {
Some(id) => (
client.put(format!("{}/comments/{id}", pr_base(cfg, repo, pr))),
"updateComment",
),
None => (
client.post(format!("{}/comments", pr_base(cfg, repo, pr))),
"postComment",
),
};
let res = req
.header(reqwest::header::AUTHORIZATION, auth_header(cfg)?)
.json(&serde_json::json!({ "content": { "raw": marked } }))
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
anyhow::bail!(
"Bitbucket {action} {status}: {}",
clip(&res.text().await.unwrap_or_default(), 300)
);
}
let c: Created = res.json().await?;
Ok(c.links.and_then(|l| l.html).and_then(|h| h.href))
}
async fn post_inline(
client: &Client,
cfg: &Config,
repo: &str,
pr: u64,
c: &InlineComment,
) -> Result<()> {
let body = format!("{}\n\n_{}_", c.body, cfg.comment_marker);
let res = client
.post(format!("{}/comments", pr_base(cfg, repo, pr)))
.header(reqwest::header::AUTHORIZATION, auth_header(cfg)?)
.json(&serde_json::json!({ "content": { "raw": body }, "inline": { "path": c.path, "to": c.line } }))
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
tracing::warn!(
"Bitbucket inline comment failed ({status}) on {}:{}: {}",
c.path,
c.line,
clip(&res.text().await.unwrap_or_default(), 200)
);
}
Ok(())
}
pub async fn post_review(
client: &Client,
cfg: &Config,
meta: &PrMeta,
review: &ReviewPost,
) -> Result<Option<String>> {
let (summary_id, inline_ids) = find_bot_comments(client, cfg, &meta.repo, meta.pr).await?;
if !review.inline.is_empty() || !inline_ids.is_empty() {
for id in inline_ids {
let url = format!("{}/comments/{id}", pr_base(cfg, &meta.repo, meta.pr));
let _ = client
.delete(url)
.header(reqwest::header::AUTHORIZATION, auth_header(cfg)?)
.send()
.await;
}
for c in &review.inline {
post_inline(client, cfg, &meta.repo, meta.pr, c).await?;
}
}
upsert_summary(
client,
cfg,
&meta.repo,
meta.pr,
&review.summary,
summary_id,
)
.await
}