From 0cf0efdf7eec4f9af2337de45fd4dfdcb0bd7b88 Mon Sep 17 00:00:00 2001 From: Tom Maffia Date: Fri, 28 Nov 2025 09:39:29 -0500 Subject: [PATCH] feat: add pr review command --- Cargo.lock | 40 ++++++++++++- Cargo.toml | 3 +- README.md | 2 +- src/api/client.rs | 118 ++++++++++++++++++++++++++++---------- src/commands/pr.rs | 7 +++ src/commands/pr/review.rs | 108 ++++++++++++++++++++++++++++++++++ 6 files changed, 246 insertions(+), 32 deletions(-) create mode 100644 src/commands/pr/review.rs diff --git a/Cargo.lock b/Cargo.lock index 24435ab..51f40e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,13 +89,14 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bb-cli" -version = "0.3.6" +version = "0.3.7" dependencies = [ "anyhow", "clap", "comfy-table", "config", "crossterm", + "dialoguer", "dirs", "keyring", "open", @@ -242,6 +243,19 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "console" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-random" version = "0.1.18" @@ -379,6 +393,18 @@ dependencies = [ "syn", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -439,6 +465,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1676,6 +1708,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 25f10ca..107192f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bb-cli" -version = "0.3.6" +version = "0.3.7" edition = "2024" [dependencies] @@ -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" diff --git a/README.md b/README.md index 229573f..13381ea 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# bbcli v0.3.6 +# bbcli v0.3.7 Bitbucket CLI is a command line interface for interacting with Bitbucket. diff --git a/src/api/client.rs b/src/api/client.rs index 39d1e75..04286bb 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -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(&self, path: &str) -> Result { - 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 { + let response = request.send().await.context("Failed to send request")?; crate::utils::debug::log(&format!("Response status: {}", response.status())); @@ -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(&self, path: &str) -> Result { + let request = self.build_request(Method::GET, path); + let response = self.send_request(request).await?; + let data = response .json::() .await @@ -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) @@ -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 { + 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::() + .await + .context("Failed to parse JSON response")?; + Ok(comment) + } + /// Get the currently authenticated user pub async fn get_current_user(&self) -> Result { self.get("/user").await diff --git a/src/commands/pr.rs b/src/commands/pr.rs index bd6f7e4..5f53278 100644 --- a/src/commands/pr.rs +++ b/src/commands/pr.rs @@ -1,6 +1,8 @@ use anyhow::Result; use clap::{Args, Subcommand}; +pub mod review; + use crate::display::{pr as pr_display, ui}; #[derive(Args)] @@ -48,6 +50,8 @@ pub enum PrCommands { /// PR ID (optional, infers from branch if missing) id: Option, }, + /// Review a pull request + Review(review::ReviewArgs), } use crate::api::client::BitbucketClient; @@ -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(()) } diff --git a/src/commands/pr/review.rs b/src/commands/pr/review.rs new file mode 100644 index 0000000..32c40a2 --- /dev/null +++ b/src/commands/pr/review.rs @@ -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, + + /// 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, +} + +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(()) +}