diff --git a/Cargo.lock b/Cargo.lock index 51f40e0..fc3fa6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bb-cli" -version = "0.3.7" +version = "0.3.8" dependencies = [ "anyhow", "clap", @@ -98,6 +98,7 @@ dependencies = [ "crossterm", "dialoguer", "dirs", + "glob", "keyring", "open", "reqwest", @@ -146,9 +147,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.47" +version = "1.2.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" +checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" dependencies = [ "find-msvc-tools", "shlex", @@ -631,6 +632,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.12" @@ -688,12 +695,11 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -968,9 +974,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -1539,9 +1545,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ "web-time", "zeroize", @@ -2043,9 +2049,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" dependencies = [ "bitflags", "bytes", @@ -2073,9 +2079,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "pin-project-lite", "tracing-core", @@ -2083,9 +2089,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", ] @@ -2200,9 +2206,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -2213,9 +2219,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -2226,9 +2232,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2236,9 +2242,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", @@ -2249,18 +2255,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -2500,9 +2506,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -2555,18 +2561,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.28" +version = "0.8.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43fa6694ed34d6e57407afbccdeecfa268c470a7d2a5b0cf49ce9fcc345afb90" +checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.28" +version = "0.8.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" +checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 107192f..7cc0e50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bb-cli" -version = "0.3.7" +version = "0.3.8" edition = "2024" [dependencies] @@ -19,6 +19,7 @@ tokio = { version = "1.48.0", features = ["full"] } toml_edit = "0.23.7" similar = { version = "2.7", features = ["inline"] } dialoguer = "0.12.0" +glob = "0.3.3" [[bin]] name = "bb" diff --git a/README.md b/README.md index 13381ea..b899b3e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# bbcli v0.3.7 +# bbcli v0.3.8 Bitbucket CLI is a command line interface for interacting with Bitbucket. @@ -81,6 +81,84 @@ bb repo list bb repo list --limit 20 ``` +### Pull Requests + +List pull requests: + +```bash +bb pr list +``` + +View a pull request (auto-detected from branch or by ID): + +```bash +bb pr view +bb pr view 123 +``` + +**View Diff with Filtering:** + +You can filter the diff by file patterns or size. + +**Inferred Context (Current Branch):** + +```bash +# Filter by file extension +bb pr diff "*.rs" + +# Filter by directory +bb pr diff src/commands/ +``` + +**Manual Context (Explicit ID):** + +```bash +# Filter by specific file for PR #123 +bb pr diff 123 src/main.rs + +# Skip large files for PR #123 +bb pr diff 123 --max-diff-size 100 +``` + +**Review a Pull Request:** + +Start an interactive review or submit immediately with flags. + +**Inferred Context (Current Branch):** + +```bash +# Interactive mode +bb pr review + +# Approve immediately +bb pr review --approve +``` + +**Manual Context (Explicit ID):** + +```bash +# Interactive mode for PR #123 +bb pr review 123 + +# Request changes for PR #123 +bb pr review 123 --request-changes + +# Comment on PR #123 +bb pr review 123 --comment --body "Great work!" +``` + +**Override Repository:** + +You can run any command against a specific repository using `-R`: + +```bash +# List PRs in a different repo +bb pr list -R my-workspace/other-repo + +# View PR #123 in a different repo +bb pr view 123 -R my-workspace/other-repo +``` + ### Configuration View your current configuration (active profile and local overrides): diff --git a/src/commands/pr.rs b/src/commands/pr.rs index 5f53278..55c2a15 100644 --- a/src/commands/pr.rs +++ b/src/commands/pr.rs @@ -36,14 +36,18 @@ pub enum PrCommands { }, /// Show diff Diff { - /// PR ID (optional, infers from branch if missing) - id: Option, + /// PR ID (optional, infers from branch if missing) or file patterns + #[arg(trailing_var_arg = true)] + args: Vec, /// Display only names of changed files #[arg(long)] name_only: bool, /// Open the pull request diff in the browser #[arg(long, short = 'w')] web: bool, + /// Skip files larger than this number of lines + #[arg(long)] + max_diff_size: Option, }, /// Show comments Comments { @@ -156,7 +160,12 @@ pub async fn handle(ctx: &AppContext, args: PrArgs) -> Result<()> { pr_display::print_comments(&comments_list); } } - PrCommands::Diff { id, name_only, web } => { + PrCommands::Diff { + args, + name_only, + web, + max_diff_size, + } => { let workspace = ctx .workspace .as_ref() @@ -166,7 +175,8 @@ pub async fn handle(ctx: &AppContext, args: PrArgs) -> Result<()> { .as_ref() .ok_or_else(|| anyhow::anyhow!("No repository found"))?; - let pr_id = resolve_pr_id(id, &ctx.client, workspace, repo).await?; + let (id_opt, patterns) = parse_args_with_id(&args); + let pr_id = resolve_pr_id(id_opt, &ctx.client, workspace, repo).await?; // Handle --web flag (open in browser) if web { @@ -184,12 +194,9 @@ pub async fn handle(ctx: &AppContext, args: PrArgs) -> Result<()> { // Handle --name-only flag if name_only { - crate::display::diff::print_filenames_only(&diff); + crate::display::diff::print_filenames_only(&diff, patterns); } else { - // TODO: Add support for filtering (--exclude, --exclude-lockfiles, path patterns) - // TODO: Add support for collapsing large diffs (--collapse-large) - // TODO: Add --stat flag for git-style statistics - crate::display::diff::print_diff(&diff)?; + crate::display::diff::print_diff(&diff, patterns, max_diff_size)?; } } PrCommands::Comments { id } => { @@ -254,6 +261,27 @@ async fn resolve_pr_id( } } +/// Parse arguments to separate an optional ID from the rest of the arguments. +/// +/// # Arguments +/// +/// * `args` - Slice of string arguments +/// +/// # Returns +/// +/// A tuple containing: +/// * `Option` - The parsed ID, if the first argument was a valid number +/// * `&[String]` - The remaining arguments (all arguments if no ID was found, or the rest if an ID was found) +fn parse_args_with_id(args: &[String]) -> (Option, &[String]) { + if let Some(first) = args.first() + && let Ok(id) = first.parse::() + { + (Some(id), &args[1..]) + } else { + (None, args) + } +} + #[cfg(test)] mod tests { use super::*; @@ -284,4 +312,31 @@ mod tests { assert_eq!(ctx.workspace.as_deref(), Some("ws")); assert_eq!(ctx.repo.as_deref(), Some("repo")); } + + #[test] + fn test_parse_args_with_id() { + // Case 1: ID and patterns + let args = vec!["123".to_string(), "src/".to_string()]; + let (id, patterns) = parse_args_with_id(&args); + assert_eq!(id, Some(123)); + assert_eq!(patterns, &["src/".to_string()]); + + // Case 2: Only ID + let args = vec!["456".to_string()]; + let (id, patterns) = parse_args_with_id(&args); + assert_eq!(id, Some(456)); + assert!(patterns.is_empty()); + + // Case 3: Only patterns (no ID) + let args = vec!["src/".to_string(), "*.rs".to_string()]; + let (id, patterns) = parse_args_with_id(&args); + assert_eq!(id, None); + assert_eq!(patterns, &["src/".to_string(), "*.rs".to_string()]); + + // Case 4: Empty + let args: Vec = vec![]; + let (id, patterns) = parse_args_with_id(&args); + assert_eq!(id, None); + assert!(patterns.is_empty()); + } } diff --git a/src/commands/pr/review.rs b/src/commands/pr/review.rs index 32c40a2..ef4ce8d 100644 --- a/src/commands/pr/review.rs +++ b/src/commands/pr/review.rs @@ -5,10 +5,10 @@ use dialoguer::{Input, Select}; #[derive(Args, Debug)] pub struct ReviewArgs { - /// The ID of the pull request to review + /// The ID of the pull request to review (optional, infers from branch if missing) pub id: Option, - /// Approve the pull request + /// Approve the pull request immediately #[arg(short, long)] pub approve: bool, @@ -20,7 +20,7 @@ pub struct ReviewArgs { #[arg(short, long)] pub comment: bool, - /// The body of the review or comment + /// The body of the review or comment (required for --comment) #[arg(short, long)] pub body: Option, } diff --git a/src/display/diff.rs b/src/display/diff.rs index e3eabf5..e04ba35 100644 --- a/src/display/diff.rs +++ b/src/display/diff.rs @@ -1,11 +1,17 @@ use anyhow::Result; use crossterm::style::{Color, Stylize}; +use glob::Pattern; use crate::display::ui::{display_in_pager, should_use_pager}; /// Display a diff with color formatting and optional paging -pub fn print_diff(diff_text: &str) -> Result<()> { - let formatted = format_colored_diff(diff_text); +pub fn print_diff( + diff_text: &str, + patterns: &[String], + max_diff_size: Option, +) -> Result<()> { + let filtered_diff = filter_diff(diff_text, patterns, max_diff_size)?; + let formatted = format_colored_diff(&filtered_diff); if should_use_pager() { display_in_pager(&formatted)?; @@ -17,17 +23,99 @@ pub fn print_diff(diff_text: &str) -> Result<()> { } /// Display only the names of changed files from a diff -pub fn print_filenames_only(diff_text: &str) { +pub fn print_filenames_only(diff_text: &str, patterns: &[String]) { + let compiled_patterns = compile_patterns(patterns); + for line in diff_text.lines() { // Parse unified diff format: "diff --git a/path b/path" if line.starts_with("diff --git") && let Some(filename) = extract_filename_from_diff_line(line) + && is_match(&filename, &compiled_patterns) { println!("{}", filename); } } } +fn compile_patterns(patterns: &[String]) -> Vec { + patterns + .iter() + .filter_map(|p| Pattern::new(p).ok()) + .collect() +} + +fn is_match(filename: &str, patterns: &[Pattern]) -> bool { + if patterns.is_empty() { + return true; + } + patterns.iter().any(|p| p.matches(filename)) +} + +fn filter_diff( + diff_text: &str, + patterns: &[String], + max_diff_size: Option, +) -> Result { + if patterns.is_empty() && max_diff_size.is_none() { + return Ok(diff_text.to_string()); + } + + let compiled_patterns = compile_patterns(patterns); + let mut output = String::new(); + let mut current_file_diff = String::new(); + let mut current_filename: Option = None; + + // Helper to process the accumulated chunk + let mut process_chunk = |chunk: &str, filename: Option<&String>| { + if let Some(fname) = filename { + // Check pattern match + if !is_match(fname, &compiled_patterns) { + return; + } + + // Check size limit + if let Some(max_lines) = max_diff_size { + let line_count = chunk.lines().count(); + if line_count > max_lines { + output.push_str(&format!("diff --git a/{} b/{}\n", fname, fname)); + output.push_str(&format!( + "--- {} (skipped: diff too large, {} lines)\n", + fname, line_count + )); + output.push_str(&format!( + "+++ {} (skipped: diff too large, {} lines)\n", + fname, line_count + )); + return; + } + } + } + output.push_str(chunk); + }; + + for line in diff_text.lines() { + if line.starts_with("diff --git") { + // Process previous file + if !current_file_diff.is_empty() { + process_chunk(¤t_file_diff, current_filename.as_ref()); + current_file_diff.clear(); + } + + // Start new file + current_filename = extract_filename_from_diff_line(line); + } + current_file_diff.push_str(line); + current_file_diff.push('\n'); + } + + // Process last file + if !current_file_diff.is_empty() { + process_chunk(¤t_file_diff, current_filename.as_ref()); + } + + Ok(output) +} + /// Extract filename from a "diff --git a/path b/path" line fn extract_filename_from_diff_line(line: &str) -> Option { if let Some(rest) = line.strip_prefix("diff --git ") @@ -93,4 +181,21 @@ mod tests { let filename = extract_filename_from_diff_line(line); assert_eq!(filename, None); } + + #[test] + fn test_filter_diff_pattern() { + let diff = "diff --git a/file1.rs b/file1.rs\nindex 123..456 100644\n--- a/file1.rs\n+++ b/file1.rs\n@@ -1 +1 @@\n-old\n+new\ndiff --git a/file2.txt b/file2.txt\nindex 789..012 100644\n--- a/file2.txt\n+++ b/file2.txt\n@@ -1 +1 @@\n-foo\n+bar\n"; + let patterns = vec!["*.rs".to_string()]; + let filtered = filter_diff(diff, &patterns, None).unwrap(); + assert!(filtered.contains("file1.rs")); + assert!(!filtered.contains("file2.txt")); + } + + #[test] + fn test_filter_diff_size() { + let diff = "diff --git a/large.rs b/large.rs\nline1\nline2\nline3\nline4\nline5\n"; + let patterns = vec![]; + let filtered = filter_diff(diff, &patterns, Some(3)).unwrap(); + assert!(filtered.contains("skipped: diff too large")); + } }