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
40 changes: 39 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "bb-cli"
version = "0.3.6"
version = "0.3.7"
edition = "2024"

[dependencies]
Expand All @@ -18,6 +18,7 @@ serde_json = "1.0.140"
tokio = { version = "1.48.0", features = ["full"] }
toml_edit = "0.23.7"
similar = { version = "2.7", features = ["inline"] }
dialoguer = "0.12.0"

[[bin]]
name = "bb"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# bbcli v0.3.6
# bbcli v0.3.7

Bitbucket CLI is a command line interface for interacting with Bitbucket.

Expand Down
118 changes: 89 additions & 29 deletions src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,9 @@ impl BitbucketClient {
request
}

/// Perform a GET request to the Bitbucket API
///
/// # Arguments
///
/// * `path` - The API path (relative to base URL) or full URL
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let response = self
.build_request(Method::GET, path)
.send()
.await
.context("Failed to send request")?;
/// Send a request and handle common error checking
async fn send_request(&self, request: RequestBuilder) -> Result<reqwest::Response> {
let response = request.send().await.context("Failed to send request")?;

crate::utils::debug::log(&format!("Response status: {}", response.status()));

Expand All @@ -85,6 +77,18 @@ impl BitbucketClient {
));
}

Ok(response)
}

/// Perform a GET request to the Bitbucket API
///
/// # Arguments
///
/// * `path` - The API path (relative to base URL) or full URL
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let request = self.build_request(Method::GET, path);
let response = self.send_request(request).await?;

let data = response
.json::<T>()
.await
Expand Down Expand Up @@ -211,24 +215,8 @@ impl BitbucketClient {
"/repositories/{}/{}/pullrequests/{}/diff",
workspace, repo, id
);
let response = self
.build_request(Method::GET, &path)
.send()
.await
.context("Failed to send request")?;

if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Could not read error body".to_string());
return Err(anyhow::anyhow!(
"API request failed ({}) : {}",
status,
error_text
));
}
let request = self.build_request(Method::GET, &path);
let response = self.send_request(request).await?;

let text = response.text().await.context("Failed to get diff text")?;
Ok(text)
Expand Down Expand Up @@ -318,6 +306,78 @@ impl BitbucketClient {
Ok(response.values.into_iter().next())
}

/// Approve a pull request
///
/// # Arguments
///
/// * `workspace` - The workspace ID or slug
/// * `repo` - The repository slug
/// * `id` - The pull request ID
pub async fn approve_pr(&self, workspace: &str, repo: &str, id: u32) -> Result<()> {
let path = format!(
"/repositories/{}/{}/pullrequests/{}/approve",
workspace, repo, id
);
let request = self.build_request(Method::POST, &path);
self.send_request(request).await?;

Ok(())
}

/// Request changes on a pull request
///
/// # Arguments
///
/// * `workspace` - The workspace ID or slug
/// * `repo` - The repository slug
/// * `id` - The pull request ID
pub async fn request_changes(&self, workspace: &str, repo: &str, id: u32) -> Result<()> {
let path = format!(
"/repositories/{}/{}/pullrequests/{}/request-changes",
workspace, repo, id
);
let request = self.build_request(Method::POST, &path);
self.send_request(request).await?;

Ok(())
}

/// Post a comment on a pull request
///
/// # Arguments
///
/// * `workspace` - The workspace ID or slug
/// * `repo` - The repository slug
/// * `id` - The pull request ID
/// * `content` - The comment content
pub async fn post_pr_comment(
&self,
workspace: &str,
repo: &str,
id: u32,
content: &str,
) -> Result<crate::api::models::Comment> {
let path = format!(
"/repositories/{}/{}/pullrequests/{}/comments",
workspace, repo, id
);

let body = serde_json::json!({
"content": {
"raw": content
}
});

let request = self.build_request(Method::POST, &path).json(&body);
let response = self.send_request(request).await?;

let comment = response
.json::<crate::api::models::Comment>()
.await
.context("Failed to parse JSON response")?;
Ok(comment)
}

/// Get the currently authenticated user
pub async fn get_current_user(&self) -> Result<crate::api::models::User> {
self.get("/user").await
Expand Down
7 changes: 7 additions & 0 deletions src/commands/pr.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use anyhow::Result;
use clap::{Args, Subcommand};

pub mod review;

use crate::display::{pr as pr_display, ui};

#[derive(Args)]
Expand Down Expand Up @@ -48,6 +50,8 @@ pub enum PrCommands {
/// PR ID (optional, infers from branch if missing)
id: Option<u32>,
},
/// Review a pull request
Review(review::ReviewArgs),
}

use crate::api::client::BitbucketClient;
Expand Down Expand Up @@ -216,6 +220,9 @@ pub async fn handle(ctx: &AppContext, args: PrArgs) -> Result<()> {
pr_display::print_comments(&comments);
}
}
PrCommands::Review(args) => {
review::pr_review(ctx, &args).await?;
}
}
Ok(())
}
Expand Down
108 changes: 108 additions & 0 deletions src/commands/pr/review.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use crate::context::AppContext;
use anyhow::{Context, Result};
use clap::Args;
use dialoguer::{Input, Select};

#[derive(Args, Debug)]
pub struct ReviewArgs {
/// The ID of the pull request to review
pub id: Option<u32>,

/// Approve the pull request
#[arg(short, long)]
pub approve: bool,

/// Request changes on the pull request
#[arg(short, long)]
pub request_changes: bool,

/// Comment on the pull request
#[arg(short, long)]
pub comment: bool,

/// The body of the review or comment
#[arg(short, long)]
pub body: Option<String>,
}

pub async fn pr_review(ctx: &AppContext, args: &ReviewArgs) -> Result<()> {
let workspace = ctx
.workspace
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No workspace found"))?;
let repo = ctx
.repo
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No repository found"))?;

// Determine PR ID
let pr_id = match args.id {
Some(id) => id,
None => {
// Try to deduce from current branch
let branch = crate::git::get_current_branch()?;
let pr = ctx
.client
.find_pull_request_by_branch(workspace, repo, &branch)
.await?
.context("No open pull request found for current branch")?;
pr.id
}
};

// Check if flags are provided
if args.approve || args.request_changes || args.comment {
if args.approve {
ctx.client.approve_pr(workspace, repo, pr_id).await?;
println!("Approved pull request #{}", pr_id);
}

if args.request_changes {
ctx.client.request_changes(workspace, repo, pr_id).await?;
println!("Requested changes on pull request #{}", pr_id);
}

if args.comment {
let body = args
.body
.clone()
.context("Comment body is required when using --comment")?;
ctx.client
.post_pr_comment(workspace, repo, pr_id, &body)
.await?;
println!("Commented on pull request #{}", pr_id);
}
} else {
// Interactive mode
let selections = &["Approve", "Request Changes", "Comment"];
let selection = Select::new()
.with_prompt("Select review action")
.default(0)
.items(&selections[..])
.interact()?;

match selection {
0 => {
// Approve
ctx.client.approve_pr(workspace, repo, pr_id).await?;
println!("Approved pull request #{}", pr_id);
}
1 => {
// Request Changes
ctx.client.request_changes(workspace, repo, pr_id).await?;
println!("Requested changes on pull request #{}", pr_id);
}
2 => {
// Comment
let body: String = Input::new().with_prompt("Comment body").interact_text()?;
ctx.client
.post_pr_comment(workspace, repo, pr_id, &body)
.await?;
println!("Commented on pull request #{}", pr_id);
}
_ => unreachable!(),
}
}

Ok(())
}