1use crate::api;
2use crate::api::models::{
3 BuildState, BuildStatus, PullRequest, Repository, ReviewState, ReviewerState,
4};
5use crate::api::Client;
6use crate::commands::pr_list::{state_query, REVIEWER_FIELDS};
7use crate::credentials;
8use crate::error::{BbError, Result};
9use crate::output::{self, Format};
10use crate::repo::{self, RepoSlug};
11use crate::users::current_user;
12use futures::stream::{self, StreamExt};
13use serde::Serialize;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
16pub enum RoleArg {
17 Author,
19 Reviewer,
21 All,
22}
23
24#[derive(Debug)]
25pub struct MineArgs {
26 pub role: RoleArg,
27 pub state: String,
28 pub workspace: Option<String>,
29 pub repo_limit: usize,
30 pub build: bool,
31}
32
33#[derive(Debug, Serialize)]
37struct MineRow {
38 repo: String,
39 id: u64,
40 title: String,
41 url: String,
42 state: String,
44 draft: bool,
45 author: String,
46 my_role: String,
48 my_review_state: Option<ReviewState>,
50 reviewers: Vec<ReviewerState>,
51 updated_on: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 build_state: Option<BuildState>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 build: Option<Vec<BuildStatus>>,
56}
57
58#[derive(Debug, Serialize)]
61struct MineReport {
62 pull_requests: Vec<MineRow>,
63 partial: Vec<String>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72enum Origin {
73 Authored,
74 Reviewing,
75}
76
77impl Origin {
78 fn as_role(self) -> &'static str {
79 match self {
80 Origin::Authored => "author",
81 Origin::Reviewing => "reviewer",
82 }
83 }
84}
85
86fn to_row(repo: &str, pr: &PullRequest, my_uuid: &str) -> MineRow {
87 let reviewers = pr.reviewer_states();
88 let my_review_state = reviewers
89 .iter()
90 .find(|r| r.uuid.as_deref() == Some(my_uuid))
91 .map(|r| r.state);
92 let i_authored = pr.author.as_ref().and_then(|a| a.uuid.as_deref()) == Some(my_uuid);
93 let my_role = match (i_authored, my_review_state.is_some()) {
94 (true, true) => "both",
95 (true, false) => "author",
96 _ => "reviewer",
97 };
98 MineRow {
99 repo: repo.to_string(),
100 id: pr.id,
101 title: pr.title.clone().unwrap_or_default(),
102 url: pr.html_url().to_string(),
103 state: pr.state.clone().unwrap_or_else(|| "-".into()),
104 draft: pr.draft,
105 author: pr.author_name().to_string(),
106 my_role: my_role.to_string(),
107 my_review_state,
108 reviewers,
109 updated_on: pr.updated_on.clone(),
110 build_state: None,
111 build: None,
112 }
113}
114
115async fn authored(
130 client: &Client,
131 workspace: &str,
132 my_uuid: &str,
133 state: &str,
134) -> Result<Vec<(String, PullRequest)>> {
135 let prs: Vec<PullRequest> = client
136 .paginate(&format!(
137 "/workspaces/{}/pullrequests/{}?state={}&pagelen=50&fields={REVIEWER_FIELDS}",
138 urlencoding::encode(workspace),
139 urlencoding::encode(my_uuid),
140 urlencoding::encode(&state_query(state))
141 ))
142 .await?;
143 Ok(prs.into_iter().map(|pr| (repo_of(&pr), pr)).collect())
144}
145
146fn repo_of(pr: &PullRequest) -> String {
150 let url = pr.html_url();
151 let Some(rest) = url.split("bitbucket.org/").nth(1) else {
152 return "-".to_string();
153 };
154 let mut parts = rest.split('/');
155 match (parts.next(), parts.next()) {
156 (Some(ws), Some(repo)) if !ws.is_empty() && !repo.is_empty() => format!("{ws}/{repo}"),
157 _ => "-".to_string(),
158 }
159}
160
161const MAX_IN_FLIGHT: usize = 8;
164
165fn parse_workspace_list(raw: &str) -> Vec<String> {
169 let mut out = Vec::new();
170 for part in raw.split(',') {
171 let slug = part.trim();
172 if slug.is_empty() {
173 continue;
174 }
175 if !out.iter().any(|s: &String| s == slug) {
176 out.push(slug.to_string());
177 }
178 }
179 out
180}
181
182fn resolve_workspaces(explicit: Option<&str>) -> Result<Vec<String>> {
199 if let Some(raw) = explicit {
200 let slugs = parse_workspace_list(raw);
201 if !slugs.is_empty() {
202 return Ok(slugs);
203 }
204 }
205 if let Ok(raw) = std::env::var("BB_WORKSPACE") {
206 let slugs = parse_workspace_list(&raw);
207 if !slugs.is_empty() {
208 return Ok(slugs);
209 }
210 }
211 if let Ok(slug) = repo::resolve(None) {
212 return Ok(vec![slug.workspace]);
213 }
214 Err(BbError::Config(
215 "no workspace to scan — pass --workspace <slug>[,<slug>...], set BB_WORKSPACE, \
216 or run inside a bitbucket checkout"
217 .into(),
218 ))
219}
220
221async fn repositories(client: &Client, workspace: &str, limit: usize) -> Result<Vec<String>> {
232 if limit == 0 {
233 return Ok(Vec::new());
234 }
235 let pagelen = limit.min(100);
236 let page: api::Page<Repository> = client
240 .get_json(&format!(
241 "/repositories/{}?sort=-updated_on&pagelen={pagelen}",
242 urlencoding::encode(workspace)
243 ))
244 .await?;
245 Ok(page
246 .values
247 .into_iter()
248 .filter_map(|r| r.full_name)
249 .take(limit)
250 .collect())
251}
252
253async fn reviewing_in(
255 client: &Client,
256 repo: &str,
257 state: &str,
258 my_uuid: &str,
259) -> Result<Vec<(String, PullRequest)>> {
260 let slug = RepoSlug::parse(repo)?;
261 let prs: Vec<PullRequest> = client
262 .paginate(&api::repo_path(
263 &slug,
264 &format!(
265 "/pullrequests?state={}&pagelen=50&fields={REVIEWER_FIELDS}",
266 urlencoding::encode(&state_query(state))
267 ),
268 ))
269 .await?;
270 Ok(prs
271 .into_iter()
272 .filter(|pr| {
273 pr.reviewer_states()
274 .iter()
275 .any(|r| r.uuid.as_deref() == Some(my_uuid))
276 })
277 .map(|pr| (repo.to_string(), pr))
278 .collect())
279}
280
281pub async fn run(format: Format, args: MineArgs) -> Result<()> {
282 if args.state.eq_ignore_ascii_case("draft") {
288 return Err(BbError::Config(
289 "pr mine does not support --state draft — use `bb pr list --state draft` \
290 inside the repository, or `--role author` and check the `draft` field"
291 .into(),
292 ));
293 }
294
295 let workspaces = resolve_workspaces(args.workspace.as_deref())?;
296
297 let creds = credentials::load()?;
298 let client = Client::from_env(creds)?;
299
300 let me = current_user(&client).await?;
301 let my_uuid = me.uuid.ok_or_else(|| {
302 BbError::Config(
303 "your bitbucket account has no uuid — cannot identify your pull requests".into(),
304 )
305 })?;
306
307 let spinner = output::spinner("scanning your pull requests");
308 let mut found: Vec<(String, PullRequest, Origin)> = Vec::new();
309 let mut partial: Vec<String> = Vec::new();
310
311 for workspace in workspaces {
316 if args.role != RoleArg::Reviewer {
317 match authored(&client, &workspace, &my_uuid, &args.state).await {
318 Ok(prs) => found.extend(
319 prs.into_iter()
320 .map(|(repo, pr)| (repo, pr, Origin::Authored)),
321 ),
322 Err(crate::error::BbError::Api { status: 403, .. }) => {
323 partial.push(workspace.clone());
324 }
325 Err(e) => return Err(e),
326 }
327 }
328
329 if args.role != RoleArg::Author {
330 let repos = match repositories(&client, &workspace, args.repo_limit).await {
336 Ok(repos) => repos,
337 Err(crate::error::BbError::Api { status: 403, .. }) => {
338 if !partial.contains(&workspace) {
339 partial.push(workspace);
340 }
341 continue;
342 }
343 Err(e) => return Err(e),
344 };
345 let batches: Vec<Vec<(String, PullRequest)>> = stream::iter(repos.iter())
346 .map(|repo| reviewing_in(&client, repo, &args.state, &my_uuid))
347 .buffer_unordered(MAX_IN_FLIGHT)
348 .collect::<Vec<_>>()
349 .await
350 .into_iter()
351 .collect::<Result<Vec<_>>>()?;
352 for batch in batches {
353 found.extend(
354 batch
355 .into_iter()
356 .map(|(repo, pr)| (repo, pr, Origin::Reviewing)),
357 );
358 }
359 }
360 }
361 spinner.finish_and_clear();
362
363 let mut rows: Vec<MineRow> = Vec::new();
370 for (repo, pr, origin) in &found {
371 let this_role = origin.as_role();
372 match rows.iter_mut().find(|r| r.repo == *repo && r.id == pr.id) {
373 Some(existing) => {
374 if existing.my_role != this_role {
375 existing.my_role = "both".to_string();
376 }
377 }
378 None => rows.push(to_row(repo, pr, &my_uuid)),
379 }
380 }
381
382 if args.build {
383 attach_builds(&client, &mut rows).await?;
384 }
385
386 render(format, rows, partial, args.build)
387}
388
389async fn attach_builds(client: &Client, rows: &mut [MineRow]) -> Result<()> {
393 let mut repos: Vec<String> = rows.iter().map(|r| r.repo.clone()).collect();
394 repos.sort();
395 repos.dedup();
396 for repo in repos {
397 let Ok(slug) = RepoSlug::parse(&repo) else {
398 for row in rows.iter_mut().filter(|r| r.repo == repo) {
404 row.build_state = Some(BuildState::None);
405 row.build = Some(Vec::new());
406 }
407 continue;
408 };
409 let ids: Vec<u64> = rows
410 .iter()
411 .filter(|r| r.repo == repo)
412 .map(|r| r.id)
413 .collect();
414 let mut statuses = crate::commands::pr_build::statuses_for(client, &slug, &ids).await?;
415 for row in rows.iter_mut().filter(|r| r.repo == repo) {
416 let found = statuses.remove(&row.id).unwrap_or_default();
417 row.build_state = Some(BuildState::rollup(&found));
418 row.build = Some(found);
419 }
420 }
421 Ok(())
422}
423
424fn render(format: Format, rows: Vec<MineRow>, partial: Vec<String>, build: bool) -> Result<()> {
425 match format {
426 Format::Json => {
427 let report = MineReport {
428 pull_requests: rows,
429 partial,
430 };
431 output::print_json(&report)?;
432 }
433 Format::Human => {
434 if !partial.is_empty() {
435 output::warn(&format!(
436 "could not read {} — the scan is incomplete",
437 partial.join(", ")
438 ));
439 }
440 let mut headers: Vec<&str> = vec!["REPO", "ID", "TITLE", "STATE"];
441 if build {
442 headers.push("BUILD");
443 }
444 headers.extend(["ROLE", "MINE", "UPDATED"]);
445 output::print_table(
446 &headers,
447 rows.iter()
448 .map(|r| {
449 let mut cells = vec![
450 r.repo.clone(),
451 r.id.to_string(),
452 r.title.clone(),
453 r.state.clone(),
454 ];
455 if build {
456 let state = r.build_state.unwrap_or(BuildState::None);
457 cells
458 .push(output::colored_cell(state.label(), output::tone_for(state)));
459 }
460 cells.extend([
461 r.my_role.clone(),
462 r.my_review_state
463 .map(|s| s.as_str().to_string())
464 .unwrap_or_else(|| "-".into()),
465 r.updated_on
466 .as_deref()
467 .map(output::relative_time)
468 .unwrap_or_else(|| "-".into()),
469 ]);
470 cells
471 })
472 .collect(),
473 );
474 }
475 }
476 Ok(())
477}