From 1e3d52dbeef85bfa139497934219f0b39312736a Mon Sep 17 00:00:00 2001 From: Tom Maffia Date: Mon, 24 Nov 2025 23:25:37 -0500 Subject: [PATCH 1/3] feat: Implement `repo` commands and refactor `config` command for improved interactive setup and context-aware settings. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 34 +++++++- src/api/client.rs | 38 +++++++++ src/api/models.rs | 5 ++ src/cli.rs | 2 + src/commands/auth.rs | 15 +--- src/commands/config.rs | 174 +++++++++++++++++------------------------ src/commands/mod.rs | 1 + src/commands/repo.rs | 49 ++++++++++++ src/config/manager.rs | 38 +++------ src/config/mod.rs | 1 + src/config/setup.rs | 94 ++++++++++++++++++++++ src/display/mod.rs | 1 + src/display/pr.rs | 10 +++ src/display/repo.rs | 35 +++++++++ src/main.rs | 1 + 17 files changed, 362 insertions(+), 140 deletions(-) create mode 100644 src/commands/repo.rs create mode 100644 src/config/setup.rs create mode 100644 src/display/repo.rs diff --git a/Cargo.lock b/Cargo.lock index ec4f9e2..24435ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bb-cli" -version = "0.3.5" +version = "0.3.6" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 1b12070..25f10ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bb-cli" -version = "0.3.5" +version = "0.3.6" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 898b080..f9c06c2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# bbcli v0.3.5 +# bbcli v0.3.6 Bitbucket CLI is a command line interface for interacting with Bitbucket. @@ -64,6 +64,38 @@ To use this CLI, you need an API Token from your Atlassian account: bb config init ``` +## Global Flags + +- `--json`: Output results in JSON format (available for `list` commands). + +## Usage + +### Repositories + +List repositories in your workspace: + +```bash +bb repo list +``` + +### Configuration + +View your current configuration (active profile and local overrides): + +```bash +bb config list +``` + +Set a configuration value: + +```bash +# Set the active profile (user) +bb config set user + +# Set workspace for the default profile +bb config set profile.default.workspace +``` + ## Development For contributing to this repository, you can set up the pre-push hooks (recommended): diff --git a/src/api/client.rs b/src/api/client.rs index 2e55092..fc398f1 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -6,6 +6,7 @@ use serde::de::DeserializeOwned; /// /// Handles communication with the Bitbucket Cloud API v2.0. /// Supports authentication via Basic Auth (App Password). +#[derive(Clone)] pub struct BitbucketClient { client: Client, base_url: String, @@ -135,6 +136,43 @@ impl BitbucketClient { Ok(all_prs) } + /// List repositories in a workspace + /// + /// # Arguments + /// + /// * `workspace` - The workspace ID or slug + /// * `limit` - Optional maximum number of repositories to return + pub async fn list_repositories( + &self, + workspace: &str, + limit: Option, + ) -> Result> { + let mut all_repos = Vec::new(); + let mut path = format!("/repositories/{}", workspace); + + loop { + let response: crate::api::models::PaginatedResponse = + self.get(&path).await?; + + all_repos.extend(response.values); + + // Check if we've reached the limit + let limit_reached = limit.is_some_and(|max| all_repos.len() >= max as usize); + + if limit_reached { + all_repos.truncate(limit.unwrap() as usize); + break; + } + + match response.next { + Some(next_url) => path = next_url, + None => break, + } + } + + Ok(all_repos) + } + /// Get a single pull request by ID /// /// # Arguments diff --git a/src/api/models.rs b/src/api/models.rs index e6864c5..364f31b 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -50,6 +50,11 @@ pub struct Repository { pub name: String, pub full_name: String, pub uuid: String, + pub description: Option, + pub language: Option, + pub updated_on: Option, + pub website: Option, + pub is_private: Option, } #[derive(Debug, Deserialize, Serialize)] diff --git a/src/cli.rs b/src/cli.rs index fc09ac1..2d97173 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -40,4 +40,6 @@ pub enum Commands { Auth(commands::auth::AuthArgs), /// Configuration Config(commands::config::ConfigArgs), + /// Repository operations + Repo(commands::repo::RepoArgs), } diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 35f2e14..d1d90a1 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -31,9 +31,7 @@ async fn get_authenticated_user(profile: Option<&Profile>) -> Result { // Verify password exists in keyring let api_token = crate::utils::auth::get_credentials(username)?; - let base_url = profile - .and_then(|p| p.api_url.clone()) - .unwrap_or_else(|| crate::constants::DEFAULT_API_URL.to_string()); + let base_url = crate::constants::DEFAULT_API_URL.to_string(); // Verify credentials against API let client = @@ -45,10 +43,8 @@ async fn get_authenticated_user(profile: Option<&Profile>) -> Result { } /// Attempt to log in with provided credentials -async fn check_login(profile: Option<&Profile>, username: &str, api_token: &str) -> Result { - let base_url = profile - .and_then(|p| p.api_url.clone()) - .unwrap_or_else(|| crate::constants::DEFAULT_API_URL.to_string()); +async fn check_login(username: &str, api_token: &str) -> Result { + let base_url = crate::constants::DEFAULT_API_URL.to_string(); // Verify credentials work with API first let client = crate::api::client::BitbucketClient::new( @@ -106,10 +102,7 @@ pub async fn handle(_ctx: &AppContext, args: AuthArgs) -> Result<()> { ui::info(msg::VERIFYING_CREDENTIALS); - let config = crate::config::manager::ProfileConfig::load().ok(); - let profile = config.as_ref().and_then(|c| c.get_active_profile()); - - match check_login(profile, username, api_token).await { + match check_login(username, api_token).await { Ok(user) => { ui::success(msg::AUTH_SUCCESS); ui::info(&msg::CREDENTIALS_SAVED.replace("{}", username)); diff --git a/src/commands/config.rs b/src/commands/config.rs index 164ebee..d1bec70 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,6 +1,5 @@ use anyhow::Result; use clap::{Args, Subcommand}; -use std::io::{self, Write}; use crate::display::ui; @@ -24,79 +23,86 @@ pub enum ConfigCommands { use crate::context::AppContext; -pub async fn handle(_ctx: &AppContext, args: ConfigArgs) -> Result<()> { +pub async fn handle(ctx: &AppContext, args: ConfigArgs) -> Result<()> { match args.command { ConfigCommands::Init => { - ui::info("Initializing config..."); - // Interactive setup - let mut input = String::new(); - - print!("Initialize configuration in current directory? (y/n) [n]: "); - io::stdout().flush()?; - io::stdin().read_line(&mut input)?; - let local_init = input.trim().to_lowercase() == "y"; - - input.clear(); - print!("Workspace (e.g., myworkspace): "); - io::stdout().flush()?; - io::stdin().read_line(&mut input)?; - let workspace = input.trim().to_string(); - - input.clear(); - print!("Default repository (optional): "); - io::stdout().flush()?; - io::stdin().read_line(&mut input)?; - let repo = input.trim().to_string(); - - input.clear(); - print!("Default remote [origin]: "); - io::stdout().flush()?; - io::stdin().read_line(&mut input)?; - let input_remote = input.trim().to_string(); - let remote = if input_remote.is_empty() { - "origin".to_string() - } else { - input_remote - }; + crate::config::setup::interactive_init()?; + } + ConfigCommands::List => { + let config = crate::config::manager::ProfileConfig::load_global().unwrap_or_default(); + let repo_root = crate::git::get_repo_root().ok(); + let local_config = + crate::config::manager::ProfileConfig::load_local(repo_root.as_deref())?; - if local_init { - // Try to find git repo root, otherwise use current dir - let target_dir = crate::git::get_repo_root().unwrap_or_else(|_| { - std::env::current_dir().expect("Failed to get current directory") - }); - - crate::config::manager::init_local_config(&target_dir, &workspace, &repo, &remote)?; - ui::success(&format!( - "Local configuration initialized at {:?}", - target_dir - )); - } else { - crate::config::manager::set_config_value("profile.default.workspace", &workspace)?; - if !repo.is_empty() { - crate::config::manager::set_config_value("profile.default.repository", &repo)?; - } - crate::config::manager::set_config_value("profile.default.remote", &remote)?; + // Resolve active values + let active_profile = config.get_active_profile(); - input.clear(); - print!("Default user email (optional): "); - io::stdout().flush()?; - io::stdin().read_line(&mut input)?; - let user = input.trim().to_string(); + let mut config_values = Vec::new(); - if !user.is_empty() { - crate::config::manager::set_config_value("profile.default.user", &user)?; + // Helper to add values if present + let mut add_val = |key: &str, val: Option| { + if let Some(v) = val { + config_values.push((key.to_string(), v)); } + }; - ui::success("Configuration initialized"); + // 1. User (Global only) + add_val("user", active_profile.and_then(|p| p.user.clone())); + + // 2. Workspace (Local > Global) + let workspace = local_config + .as_ref() + .and_then(|c| c.project.as_ref()) + .and_then(|p| p.workspace.clone()) + .or_else(|| active_profile.and_then(|p| p.workspace.clone())); + add_val("workspace", workspace); + + // 3. Repository (Local only) + let repo = local_config + .as_ref() + .and_then(|c| c.project.as_ref()) + .and_then(|p| p.repository.clone()); + add_val("repository", repo); + + // 4. Remote (Local only) + let remote = local_config + .as_ref() + .and_then(|c| c.project.as_ref()) + .and_then(|p| p.remote.clone()); + add_val("remote", remote); + + if ctx.json { + let mut map = serde_json::Map::new(); + for (k, v) in config_values { + map.insert(k, serde_json::Value::String(v)); + } + ui::print_json(&map)?; + } else { + for (k, v) in config_values { + println!("{}={}", k, v); + } } } - ConfigCommands::List => { - let config = crate::config::manager::ProfileConfig::load()?; - println!("{:#?}", config); - } ConfigCommands::Set { key, value } => { - crate::config::manager::set_config_value(&key, &value)?; - ui::success(&format!("Set {} = {}", key, value)); + // Context-aware setting + // If key is "user", set global user. + // If key is "workspace", "repository", "remote", set it for the ACTIVE profile. + // Otherwise, set as provided (full key). + + let real_key = if key == "user" { + key + } else if ["workspace", "repository", "remote"].contains(&key.as_str()) { + let config = + crate::config::manager::ProfileConfig::load_global().unwrap_or_default(); + // If no active profile (user) is set, default to "default" + let profile_name = config.user.as_deref().unwrap_or("default"); + format!("profile.{}.{}", profile_name, key) + } else { + key + }; + + crate::config::manager::set_config_value(&real_key, &value)?; + ui::success(&format!("Set {} = {}", real_key, value)); } ConfigCommands::Get { key } => { let config = crate::config::manager::ProfileConfig::load()?; @@ -110,13 +116,7 @@ pub async fn handle(_ctx: &AppContext, args: ConfigArgs) -> Result<()> { match key { Some(key) => match key.as_str() { - "default_profile" => { - println!("{}", config.default_profile.as_deref().unwrap_or("Not set")) - } - "user" => println!( - "{}", - p.and_then(|prof| prof.user.as_deref()).unwrap_or("Not set") - ), + "user" => println!("{}", config.user.as_deref().unwrap_or("Not set")), "workspace" => { println!( "{}", @@ -124,47 +124,19 @@ pub async fn handle(_ctx: &AppContext, args: ConfigArgs) -> Result<()> { .unwrap_or("Not set") ) } - "api_url" => { - println!( - "{}", - p.and_then(|prof| prof.api_url.as_deref()) - .unwrap_or("Not set") - ) - } - "output_format" => { - println!( - "{}", - p.and_then(|prof| prof.output_format.as_deref()) - .unwrap_or("Not set") - ) - } _ => { ui::error(&format!("Unknown key: '{}'", key)); - ui::info( - "Valid keys: default_profile, workspace, user, api_url, output_format", - ); + ui::info("Valid keys: user, workspace"); } }, None => { println!("Current Profile Settings:"); - println!( - " Default Profile: {}", - config.default_profile.as_deref().unwrap_or("Not set") - ); + println!(" User: {}", config.user.as_deref().unwrap_or("Not set")); if let Some(profile) = p { - println!(" User: {}", profile.user.as_deref().unwrap_or("Not set")); println!( " Workspace: {}", profile.workspace.as_deref().unwrap_or("Not set") ); - println!( - " API URL: {}", - profile.api_url.as_deref().unwrap_or("Not set") - ); - println!( - " Output Format: {}", - profile.output_format.as_deref().unwrap_or("Not set") - ); } else { ui::warning("No active profile found."); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9af2d05..1be3baa 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ pub mod auth; pub mod config; pub mod pr; +pub mod repo; diff --git a/src/commands/repo.rs b/src/commands/repo.rs new file mode 100644 index 0000000..f1839a3 --- /dev/null +++ b/src/commands/repo.rs @@ -0,0 +1,49 @@ +use crate::context::AppContext; +use crate::display::ui; +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; + +#[derive(Args)] +pub struct RepoArgs { + #[command(subcommand)] + pub command: RepoCommands, +} + +#[derive(Subcommand)] +pub enum RepoCommands { + /// List repositories in the workspace + List { + /// Workspace to list repositories from (defaults to configured workspace) + #[arg(long, short)] + workspace: Option, + + /// Limit the number of repositories to return + #[arg(long, default_value = "50")] + limit: u32, + }, +} + +pub async fn handle(ctx: &AppContext, args: RepoArgs) -> Result<()> { + match args.command { + RepoCommands::List { workspace, limit } => { + let config = crate::config::manager::ProfileConfig::load_global()?; + let ws = workspace + .or_else(|| ctx.workspace.clone()) + .or_else(|| config.get_active_profile().and_then(|p| p.workspace.clone())) + .context("No workspace configured. Please set a default workspace with 'bb config set workspace ' or provide --workspace")?; + + let client = ctx.client.clone(); // Use client from context which is already initialized with auth + + ui::info(&format!("Fetching repositories for workspace '{}'...", ws)); + + let repos = client.list_repositories(&ws, Some(limit)).await?; + + if ctx.json { + ui::print_json(&repos)?; + } else { + crate::display::repo::print_repo_list(&repos); + } + } + } + Ok(()) +} diff --git a/src/config/manager.rs b/src/config/manager.rs index 5edc29a..f6aa6ce 100644 --- a/src/config/manager.rs +++ b/src/config/manager.rs @@ -1,29 +1,27 @@ use anyhow::{Context, Result}; use config::{Config, FileFormat}; use dirs; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize, Clone, Default)] +#[derive(Debug, Deserialize, Serialize, Clone, Default)] pub struct ProfileConfig { - pub default_profile: Option, + pub user: Option, #[serde(rename = "profile")] pub profiles: Option>, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] pub struct Profile { pub workspace: Option, pub user: Option, - pub api_url: Option, - pub output_format: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] pub struct LocalProjectConfig { pub project: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] pub struct ProjectContext { pub workspace: Option, pub repository: Option, @@ -72,7 +70,7 @@ impl ProfileConfig { } pub fn get_active_profile(&self) -> Option<&Profile> { - let profile_name = self.default_profile.as_deref().unwrap_or("default"); + let profile_name = self.user.as_deref().unwrap_or("default"); self.profiles.as_ref().and_then(|p| p.get(profile_name)) } @@ -85,7 +83,7 @@ impl ProfileConfig { profile_override: Option<&str>, ) -> Result { let profile_name = profile_override - .or(self.default_profile.as_deref()) + .or(self.user.as_deref()) .unwrap_or("default"); let profile = self.profiles.as_ref().and_then(|p| p.get(profile_name)); @@ -96,9 +94,7 @@ impl ProfileConfig { crate::utils::debug::log(&format!("Profile '{}' NOT found in config.", profile_name)); } - let base_url = profile - .and_then(|p| p.api_url.clone()) - .unwrap_or_else(|| crate::constants::DEFAULT_API_URL.to_string()); + let base_url = crate::constants::DEFAULT_API_URL.to_string(); let mut auth = None; if let Some(username) = profile.and_then(|p| p.user.as_ref()) { @@ -251,13 +247,11 @@ mod tests { Profile { workspace: Some("ws".to_string()), user: Some("default_user".to_string()), - api_url: None, - output_format: None, }, ); let config = ProfileConfig { - default_profile: None, + user: None, profiles: Some(profiles), }; @@ -275,13 +269,11 @@ mod tests { Profile { workspace: Some("custom_ws".to_string()), user: Some("custom_user".to_string()), - api_url: None, - output_format: None, }, ); let config = ProfileConfig { - default_profile: Some("custom".to_string()), + user: Some("custom".to_string()), profiles: Some(profiles), }; @@ -298,13 +290,11 @@ mod tests { Profile { workspace: Some("ws".to_string()), user: Some("test_user".to_string()), - api_url: None, - output_format: None, }, ); let config = ProfileConfig { - default_profile: None, + user: None, profiles: Some(profiles), }; @@ -320,13 +310,11 @@ mod tests { Profile { workspace: Some("ws".to_string()), user: None, - api_url: None, - output_format: None, }, ); let config = ProfileConfig { - default_profile: None, + user: None, profiles: Some(profiles), }; diff --git a/src/config/mod.rs b/src/config/mod.rs index ff8de9e..2e99231 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1 +1,2 @@ pub mod manager; +pub mod setup; diff --git a/src/config/setup.rs b/src/config/setup.rs new file mode 100644 index 0000000..84a728b --- /dev/null +++ b/src/config/setup.rs @@ -0,0 +1,94 @@ +use anyhow::Result; +use std::io::{self, Write}; + +use crate::display::ui; + +pub fn interactive_init() -> Result<()> { + ui::info("Initializing config..."); + // Interactive setup + let mut input = String::new(); + + print!("Initialize configuration in current directory? (y/n) [n]: "); + io::stdout().flush()?; + io::stdin().read_line(&mut input)?; + let local_init = input.trim().to_lowercase() == "y"; + + input.clear(); + print!("Workspace (e.g., myworkspace): "); + io::stdout().flush()?; + io::stdin().read_line(&mut input)?; + let workspace = input.trim().to_string(); + + input.clear(); + print!("Default repository (optional): "); + io::stdout().flush()?; + io::stdin().read_line(&mut input)?; + let repo = input.trim().to_string(); + + input.clear(); + print!("Default remote [origin]: "); + io::stdout().flush()?; + io::stdin().read_line(&mut input)?; + let input_remote = input.trim().to_string(); + let remote = if input_remote.is_empty() { + "origin".to_string() + } else { + input_remote + }; + + if local_init { + // Try to find git repo root, otherwise use current dir + let target_dir = crate::git::get_repo_root() + .unwrap_or_else(|_| std::env::current_dir().expect("Failed to get current directory")); + + crate::config::manager::init_local_config(&target_dir, &workspace, &repo, &remote)?; + ui::success(&format!( + "Local configuration initialized at {:?}", + target_dir + )); + } else { + input.clear(); + print!("Default user email (optional): "); + io::stdout().flush()?; + io::stdin().read_line(&mut input)?; + let user = input.trim().to_string(); + + let profile_name = if user.is_empty() { + "default".to_string() + } else { + user.clone() + }; + + // 1. Set the active user (global) + crate::config::manager::set_config_value("user", &profile_name)?; + + // 2. Set profile values + crate::config::manager::set_config_value( + &format!("profile.{}.workspace", profile_name), + &workspace, + )?; + + if !repo.is_empty() { + crate::config::manager::set_config_value( + &format!("profile.{}.repository", profile_name), + &repo, + )?; + } + + crate::config::manager::set_config_value( + &format!("profile.{}.remote", profile_name), + &remote, + )?; + + if !user.is_empty() { + crate::config::manager::set_config_value( + &format!("profile.{}.user", profile_name), + &user, + )?; + } + + ui::success("Configuration initialized"); + } + + Ok(()) +} diff --git a/src/display/mod.rs b/src/display/mod.rs index 06a1825..6220089 100644 --- a/src/display/mod.rs +++ b/src/display/mod.rs @@ -1,3 +1,4 @@ pub mod diff; pub mod pr; +pub mod repo; pub mod ui; diff --git a/src/display/pr.rs b/src/display/pr.rs index c067c47..f431768 100644 --- a/src/display/pr.rs +++ b/src/display/pr.rs @@ -143,6 +143,11 @@ mod tests { name: "repo".to_string(), full_name: "owner/repo".to_string(), uuid: "456".to_string(), + description: None, + language: None, + updated_on: None, + website: None, + is_private: None, }, commit: None, }, @@ -154,6 +159,11 @@ mod tests { name: "repo".to_string(), full_name: "owner/repo".to_string(), uuid: "456".to_string(), + description: None, + language: None, + updated_on: None, + website: None, + is_private: None, }, commit: None, }, diff --git a/src/display/repo.rs b/src/display/repo.rs new file mode 100644 index 0000000..9ceec9d --- /dev/null +++ b/src/display/repo.rs @@ -0,0 +1,35 @@ +use crate::api::models::Repository; +use crate::utils::formatting; +use comfy_table::{Attribute, Cell, Color}; + +pub fn print_repo_list(repos: &[Repository]) { + if repos.is_empty() { + crate::display::ui::info("No repositories found."); + return; + } + + let headers = vec!["Name", "Full Name", "Language", "Updated", "Private"]; + let rows: Vec> = repos + .iter() + .map(|r| { + vec![ + Cell::new(&r.name).add_attribute(Attribute::Bold), + Cell::new(&r.full_name), + Cell::new(r.language.as_deref().unwrap_or("-")), + Cell::new(r.updated_on.as_deref().unwrap_or("-")), + Cell::new(if r.is_private.unwrap_or(false) { + "Yes" + } else { + "No" + }) + .fg(if r.is_private.unwrap_or(false) { + Color::Yellow + } else { + Color::Green + }), + ] + }) + .collect(); + + formatting::print_table(headers, rows); +} diff --git a/src/main.rs b/src/main.rs index e16f6d0..b63b8cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ async fn main() { Commands::Pr(args) => commands::pr::handle(&ctx, args).await, Commands::Auth(args) => commands::auth::handle(&ctx, args).await, Commands::Config(args) => commands::config::handle(&ctx, args).await, + Commands::Repo(args) => commands::repo::handle(&ctx, args).await, }; if let Err(e) = result { From 18054ba9780ca4ce81735009e11b5970d9caad6f Mon Sep 17 00:00:00 2001 From: Tom Maffia Date: Mon, 24 Nov 2025 23:53:56 -0500 Subject: [PATCH 2/3] feat: Enhance `repo list` command with simplified workspace resolution, improved display, pager support, and updated default limit. --- README.md | 3 +++ src/api/client.rs | 10 +++++++--- src/commands/repo.rs | 6 ++---- src/display/repo.rs | 26 +++++++++++++++----------- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f9c06c2..229573f 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,9 @@ List repositories in your workspace: ```bash bb repo list + +# List with a custom limit (default is 100) +bb repo list --limit 20 ``` ### Configuration diff --git a/src/api/client.rs b/src/api/client.rs index fc398f1..39d1e75 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -108,9 +108,11 @@ impl BitbucketClient { limit: Option, ) -> Result> { let mut all_prs = Vec::new(); + // Use pagelen=100 (max) or limit if smaller to optimize API calls + let page_len = limit.map(|l| std::cmp::min(l, 100)).unwrap_or(100); let mut path = format!( - "/repositories/{}/{}/pullrequests?state={}", - workspace, repo, state + "/repositories/{}/{}/pullrequests?state={}&pagelen={}", + workspace, repo, state, page_len ); loop { @@ -148,7 +150,9 @@ impl BitbucketClient { limit: Option, ) -> Result> { let mut all_repos = Vec::new(); - let mut path = format!("/repositories/{}", workspace); + // Use pagelen=100 (max) or limit if smaller to optimize API calls + let page_len = limit.map(|l| std::cmp::min(l, 100)).unwrap_or(100); + let mut path = format!("/repositories/{}?pagelen={}", workspace, page_len); loop { let response: crate::api::models::PaginatedResponse = diff --git a/src/commands/repo.rs b/src/commands/repo.rs index f1839a3..c6de34e 100644 --- a/src/commands/repo.rs +++ b/src/commands/repo.rs @@ -17,8 +17,8 @@ pub enum RepoCommands { #[arg(long, short)] workspace: Option, - /// Limit the number of repositories to return - #[arg(long, default_value = "50")] + /// Limit the number of repositories to return (default: 100) + #[arg(long, default_value = "100")] limit: u32, }, } @@ -26,10 +26,8 @@ pub enum RepoCommands { pub async fn handle(ctx: &AppContext, args: RepoArgs) -> Result<()> { match args.command { RepoCommands::List { workspace, limit } => { - let config = crate::config::manager::ProfileConfig::load_global()?; let ws = workspace .or_else(|| ctx.workspace.clone()) - .or_else(|| config.get_active_profile().and_then(|p| p.workspace.clone())) .context("No workspace configured. Please set a default workspace with 'bb config set workspace ' or provide --workspace")?; let client = ctx.client.clone(); // Use client from context which is already initialized with auth diff --git a/src/display/repo.rs b/src/display/repo.rs index 9ceec9d..e459741 100644 --- a/src/display/repo.rs +++ b/src/display/repo.rs @@ -8,28 +8,32 @@ pub fn print_repo_list(repos: &[Repository]) { return; } - let headers = vec!["Name", "Full Name", "Language", "Updated", "Private"]; + let headers = vec!["Name", "Updated", "Visibility"]; let rows: Vec> = repos .iter() .map(|r| { + let is_private = r.is_private.unwrap_or(false); vec![ Cell::new(&r.name).add_attribute(Attribute::Bold), - Cell::new(&r.full_name), - Cell::new(r.language.as_deref().unwrap_or("-")), Cell::new(r.updated_on.as_deref().unwrap_or("-")), - Cell::new(if r.is_private.unwrap_or(false) { - "Yes" - } else { - "No" - }) - .fg(if r.is_private.unwrap_or(false) { + Cell::new(if is_private { "Private" } else { "Public" }).fg(if is_private { Color::Yellow } else { - Color::Green + Color::Cyan }), ] }) .collect(); - formatting::print_table(headers, rows); + let table = formatting::format_table(headers, rows); + + if crate::display::ui::should_use_pager() { + let content = format!("Found {} repositories:\n{}", repos.len(), table); + if let Err(e) = crate::display::ui::display_in_pager(&content) { + crate::display::ui::error(&format!("Failed to display in pager: {}", e)); + } + } else { + crate::display::ui::info(&format!("Found {} repositories:", repos.len())); + println!("{}", table); + } } From 3f83c5ee06871d2e427129cacf44f1dbdf33c567 Mon Sep 17 00:00:00 2001 From: Tom Maffia Date: Tue, 25 Nov 2025 00:03:54 -0500 Subject: [PATCH 3/3] feat: fix dry issue with init prompt code --- src/config/setup.rs | 39 ++++++++++++++------------------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/src/config/setup.rs b/src/config/setup.rs index 84a728b..285ad2b 100644 --- a/src/config/setup.rs +++ b/src/config/setup.rs @@ -6,30 +6,15 @@ use crate::display::ui; pub fn interactive_init() -> Result<()> { ui::info("Initializing config..."); // Interactive setup - let mut input = String::new(); - print!("Initialize configuration in current directory? (y/n) [n]: "); - io::stdout().flush()?; - io::stdin().read_line(&mut input)?; - let local_init = input.trim().to_lowercase() == "y"; + let input = prompt("Initialize configuration in current directory? (y/n) [n]: ")?; + let local_init = input.to_lowercase() == "y"; - input.clear(); - print!("Workspace (e.g., myworkspace): "); - io::stdout().flush()?; - io::stdin().read_line(&mut input)?; - let workspace = input.trim().to_string(); + let workspace = prompt("Workspace (e.g., myworkspace): ")?; - input.clear(); - print!("Default repository (optional): "); - io::stdout().flush()?; - io::stdin().read_line(&mut input)?; - let repo = input.trim().to_string(); + let repo = prompt("Default repository (optional): ")?; - input.clear(); - print!("Default remote [origin]: "); - io::stdout().flush()?; - io::stdin().read_line(&mut input)?; - let input_remote = input.trim().to_string(); + let input_remote = prompt("Default remote [origin]: ")?; let remote = if input_remote.is_empty() { "origin".to_string() } else { @@ -47,11 +32,7 @@ pub fn interactive_init() -> Result<()> { target_dir )); } else { - input.clear(); - print!("Default user email (optional): "); - io::stdout().flush()?; - io::stdin().read_line(&mut input)?; - let user = input.trim().to_string(); + let user = prompt("Default user email (optional): ")?; let profile_name = if user.is_empty() { "default".to_string() @@ -92,3 +73,11 @@ pub fn interactive_init() -> Result<()> { Ok(()) } + +fn prompt(message: &str) -> Result { + print!("{}", message); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim().to_string()) +}