Skip to content

Commit 1b69791

Browse files
committed
feat(bitbucket): add workspace list and repo list CLI commands
Add `workspace list` and `repo list` subcommands with: - --all flag for auto-pagination (pagelen=100, max 100 pages safety bound) - --format table|json|csv output options with RFC 4180 CSV escaping - --limit with silent clamping to Bitbucket API max of 100 - --next-page for manual pagination (conflicts_with --all) - Shared OutputFormat enum and csv_escape helper in bitbucket module
1 parent 31ce7e9 commit 1b69791

5 files changed

Lines changed: 546 additions & 0 deletions

File tree

crates/mcptools/src/atlassian/bitbucket/mod.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,47 @@
11
pub mod pr;
2+
pub mod repo;
3+
pub mod workspace;
24

35
use crate::prelude::{println, *};
46

7+
/// Output format for list commands
8+
#[derive(Debug, Clone, Copy, Default, clap::ValueEnum, serde::Deserialize)]
9+
pub enum OutputFormat {
10+
/// Pretty table (default)
11+
#[default]
12+
Table,
13+
/// JSON
14+
Json,
15+
/// CSV
16+
Csv,
17+
}
18+
19+
/// Maximum pages to fetch during auto-pagination to prevent runaway requests
20+
pub const MAX_AUTO_PAGES: usize = 100;
21+
22+
/// Escape a field value for RFC 4180 CSV output
23+
pub fn csv_escape(s: &str) -> String {
24+
if s.contains(',') || s.contains('"') || s.contains('\n') {
25+
format!("\"{}\"", s.replace('"', "\"\""))
26+
} else {
27+
s.to_string()
28+
}
29+
}
30+
531
/// Bitbucket commands
632
#[derive(Debug, clap::Subcommand)]
733
pub enum Commands {
834
/// Pull request operations
935
#[clap(subcommand)]
1036
Pr(pr::Commands),
37+
38+
/// Workspace operations
39+
#[clap(subcommand)]
40+
Workspace(workspace::Commands),
41+
42+
/// Repository operations
43+
#[clap(subcommand)]
44+
Repo(repo::Commands),
1145
}
1246

1347
/// Run Bitbucket commands
@@ -18,10 +52,15 @@ pub async fn run(cmd: Commands, global: crate::Global) -> Result<()> {
1852

1953
match cmd {
2054
Commands::Pr(pr_cmd) => pr::run(pr_cmd, global).await,
55+
Commands::Workspace(workspace_cmd) => workspace::run(workspace_cmd, global).await,
56+
Commands::Repo(repo_cmd) => repo::run(repo_cmd, global).await,
2157
}
2258
}
2359

2460
// Re-export public data functions for external use (e.g., MCP)
2561
pub use pr::create::{create_pr_data, CreatePRParams};
2662
pub use pr::list::{list_pr_data, ListPRParams};
2763
pub use pr::read::{read_pr_data, ReadPRParams};
64+
65+
pub use repo::list::{list_repo_data, ListRepoParams};
66+
pub use workspace::list::{list_workspace_data, ListWorkspaceParams};
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
use crate::atlassian::bitbucket::{csv_escape, OutputFormat, MAX_AUTO_PAGES};
2+
use crate::atlassian::{create_bitbucket_client, BitbucketConfig};
3+
use crate::prelude::{eprintln, println, *};
4+
use color_eyre::owo_colors::OwoColorize;
5+
use indicatif::{ProgressBar, ProgressStyle};
6+
use mcptools_core::atlassian::bitbucket::{
7+
transform_repo_list_response, BitbucketRepoListResponse, RepoListOutput,
8+
};
9+
use serde::Deserialize;
10+
11+
/// Options for listing repositories in a workspace
12+
#[derive(Debug, clap::Args, Deserialize, Clone)]
13+
pub struct ListOptions {
14+
/// Workspace slug (e.g., "myworkspace")
15+
#[arg(long, short = 'w')]
16+
pub workspace: String,
17+
18+
/// Maximum number of results to return per page
19+
#[arg(short, long, default_value = "10")]
20+
pub limit: usize,
21+
22+
/// Fetch all pages automatically (uses pagelen=100)
23+
#[arg(long, conflicts_with = "next_page")]
24+
pub all: bool,
25+
26+
/// Pagination URL for fetching the next page
27+
#[arg(long)]
28+
pub next_page: Option<String>,
29+
30+
/// Bitbucket API base URL (overrides BITBUCKET_BASE_URL env var)
31+
#[arg(long)]
32+
pub base_url: Option<String>,
33+
34+
/// Output format
35+
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
36+
pub format: OutputFormat,
37+
}
38+
39+
/// Parameters for fetching repository list from Bitbucket API
40+
#[derive(Debug, Clone)]
41+
pub struct ListRepoParams {
42+
/// Workspace slug
43+
pub workspace: String,
44+
/// Maximum results per page
45+
pub limit: usize,
46+
/// Pagination URL for next page
47+
pub next_page: Option<String>,
48+
/// Override for Bitbucket API base URL
49+
pub base_url_override: Option<String>,
50+
/// Override for app password
51+
pub app_password_override: Option<String>,
52+
}
53+
54+
/// Fetch repository list from Bitbucket API
55+
pub async fn list_repo_data(
56+
params: ListRepoParams,
57+
spinner: Option<&ProgressBar>,
58+
) -> Result<RepoListOutput> {
59+
let ListRepoParams {
60+
workspace,
61+
limit,
62+
next_page,
63+
base_url_override,
64+
app_password_override,
65+
} = params;
66+
67+
let config =
68+
BitbucketConfig::from_env()?.with_overrides(base_url_override, app_password_override);
69+
let client = create_bitbucket_client(&config)?;
70+
let base_url = config.base_url.trim_end_matches('/');
71+
72+
// Bitbucket API enforces a max pagelen of 100
73+
let pagelen = limit.min(100);
74+
75+
let url = match next_page {
76+
Some(page_url) => page_url,
77+
None => format!(
78+
"{}/repositories/{}?pagelen={}",
79+
base_url, workspace, pagelen
80+
),
81+
};
82+
83+
if let Some(s) = spinner {
84+
s.set_message(format!("Fetching repositories from {}...", workspace));
85+
}
86+
let response = client
87+
.get(&url)
88+
.send()
89+
.await
90+
.map_err(|e| eyre!("Failed to send request to Bitbucket: {}", e))?;
91+
92+
if !response.status().is_success() {
93+
let status = response.status();
94+
let body = response.text().await.unwrap_or_default();
95+
return Err(eyre!(
96+
"Failed to fetch Bitbucket repository list [{}]: {}",
97+
status,
98+
body
99+
));
100+
}
101+
102+
let repo_list: BitbucketRepoListResponse = response
103+
.json()
104+
.await
105+
.map_err(|e| eyre!("Failed to parse Bitbucket repository list response: {}", e))?;
106+
107+
Ok(transform_repo_list_response(repo_list))
108+
}
109+
110+
pub async fn handler(options: ListOptions, global: crate::Global) -> Result<()> {
111+
let spinner = ProgressBar::new_spinner();
112+
spinner.set_style(
113+
ProgressStyle::default_spinner()
114+
.template("{spinner:.cyan} {msg}")
115+
.unwrap(),
116+
);
117+
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
118+
119+
let data = if options.all {
120+
let mut all_repos = Vec::new();
121+
let mut next_page = None;
122+
let mut page = 1;
123+
124+
loop {
125+
if page > 1 {
126+
spinner.set_message(format!(
127+
"Fetching repositories (page {}, {} found)...",
128+
page,
129+
all_repos.len()
130+
));
131+
}
132+
133+
let params = ListRepoParams {
134+
workspace: options.workspace.clone(),
135+
limit: 100,
136+
next_page,
137+
base_url_override: options.base_url.clone(),
138+
app_password_override: global.bitbucket_app_password.clone(),
139+
};
140+
141+
let page_data = list_repo_data(params, Some(&spinner)).await?;
142+
all_repos.extend(page_data.repositories);
143+
144+
match page_data.next_page {
145+
Some(url) if page < MAX_AUTO_PAGES => {
146+
next_page = Some(url);
147+
page += 1;
148+
}
149+
Some(_) => {
150+
eprintln!(
151+
"Warning: reached maximum page limit ({}), stopping",
152+
MAX_AUTO_PAGES
153+
);
154+
break;
155+
}
156+
None => break,
157+
}
158+
}
159+
160+
RepoListOutput {
161+
total_count: Some(all_repos.len() as u32),
162+
repositories: all_repos,
163+
next_page: None,
164+
}
165+
} else {
166+
let params = ListRepoParams {
167+
workspace: options.workspace.clone(),
168+
limit: options.limit,
169+
next_page: options.next_page,
170+
base_url_override: options.base_url,
171+
app_password_override: global.bitbucket_app_password,
172+
};
173+
list_repo_data(params, Some(&spinner)).await?
174+
};
175+
176+
spinner.finish_and_clear();
177+
178+
match options.format {
179+
OutputFormat::Json => {
180+
let json_output = serde_json::to_string_pretty(&data)
181+
.map_err(|e| eyre!("Failed to serialize output: {}", e))?;
182+
println!("{}", json_output);
183+
}
184+
OutputFormat::Csv => {
185+
println!("name,full_name,ssh_url,https_url");
186+
for repo in &data.repositories {
187+
println!(
188+
"{},{},{},{}",
189+
csv_escape(&repo.name),
190+
csv_escape(&repo.full_name),
191+
csv_escape(repo.ssh_url.as_deref().unwrap_or("")),
192+
csv_escape(repo.https_url.as_deref().unwrap_or(""))
193+
);
194+
}
195+
}
196+
OutputFormat::Table => {
197+
let count = data.repositories.len();
198+
let total_info = data
199+
.total_count
200+
.map(|t| format!(" (of {} total)", t))
201+
.unwrap_or_default();
202+
println!(
203+
"\nFound {} repository(ies){}:\n",
204+
count.to_string().bold(),
205+
total_info
206+
);
207+
208+
if data.repositories.is_empty() {
209+
println!("No repositories found.");
210+
return Ok(());
211+
}
212+
213+
let mut table = crate::prelude::new_table();
214+
table.add_row(prettytable::row![
215+
"Name".bold().cyan(),
216+
"SSH URL".bold().cyan(),
217+
"HTTPS URL".bold().cyan()
218+
]);
219+
220+
for repo in &data.repositories {
221+
table.add_row(prettytable::row![
222+
repo.name.bright_yellow(),
223+
repo.ssh_url.as_deref().unwrap_or("-").bright_white(),
224+
repo.https_url.as_deref().unwrap_or("-").bright_white()
225+
]);
226+
}
227+
228+
table.printstd();
229+
230+
if let Some(next_url) = &data.next_page {
231+
eprintln!();
232+
eprintln!(
233+
"{}",
234+
"More results available. To fetch the next page, run:".cyan()
235+
);
236+
eprintln!(
237+
" mcptools atlassian bitbucket repo list -w {} --limit {} --next-page '{}'",
238+
options.workspace, options.limit, next_url
239+
);
240+
}
241+
}
242+
}
243+
244+
Ok(())
245+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
pub mod list;
2+
3+
use crate::prelude::{println, *};
4+
5+
/// Repository commands
6+
#[derive(Debug, clap::Subcommand)]
7+
pub enum Commands {
8+
/// List repositories in a workspace
9+
#[clap(name = "list")]
10+
List(list::ListOptions),
11+
}
12+
13+
/// Run repo commands
14+
pub async fn run(cmd: Commands, global: crate::Global) -> Result<()> {
15+
if global.verbose {
16+
println!("Running Bitbucket Repo command...");
17+
}
18+
19+
match cmd {
20+
Commands::List(options) => list::handler(options, global).await,
21+
}
22+
}

0 commit comments

Comments
 (0)