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::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 comment_count: Option<u64>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 build_state: Option<BuildState>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 build: Option<Vec<BuildStatus>>,
65}
66
67#[derive(Debug, Serialize)]
70struct MineReport {
71 pull_requests: Vec<MineRow>,
72 partial: Vec<String>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum Origin {
82 Authored,
83 Reviewing,
84}
85
86impl Origin {
87 fn as_role(self) -> &'static str {
88 match self {
89 Origin::Authored => "author",
90 Origin::Reviewing => "reviewer",
91 }
92 }
93}
94
95fn browse_url(repo: &str, pr: &PullRequest) -> String {
102 let from_api = pr.html_url();
103 if from_api != "-" {
104 return from_api.to_string();
105 }
106 format!("https://bitbucket.org/{repo}/pull-requests/{}", pr.id)
107}
108
109fn to_row(repo: &str, pr: &PullRequest, my_uuid: &str) -> MineRow {
110 let reviewers = pr.reviewer_states();
111 let my_review_state = reviewers
112 .iter()
113 .find(|r| r.uuid.as_deref() == Some(my_uuid))
114 .map(|r| r.state);
115 let i_authored = pr.author.as_ref().and_then(|a| a.uuid.as_deref()) == Some(my_uuid);
116 let my_role = match (i_authored, my_review_state.is_some()) {
117 (true, true) => "both",
118 (true, false) => "author",
119 _ => "reviewer",
120 };
121 MineRow {
122 repo: repo.to_string(),
123 id: pr.id,
124 title: pr.title.clone().unwrap_or_default(),
125 url: browse_url(repo, pr),
126 state: pr.state.clone().unwrap_or_else(|| "-".into()),
127 draft: pr.draft,
128 author: pr.author_name().to_string(),
129 my_role: my_role.to_string(),
130 my_review_state,
131 reviewers,
132 updated_on: pr.updated_on.clone(),
133 comment_count: pr.comment_count,
134 build_state: None,
135 build: None,
136 }
137}
138
139async fn authored(
154 client: &Client,
155 workspace: &str,
156 my_uuid: &str,
157 state: &str,
158) -> Result<Vec<(String, PullRequest)>> {
159 let prs: Vec<PullRequest> = client
160 .paginate(&format!(
161 "/workspaces/{}/pullrequests/{}?state={}&pagelen=50&fields={REVIEWER_FIELDS}",
162 urlencoding::encode(workspace),
163 urlencoding::encode(my_uuid),
164 urlencoding::encode(&state_query(state))
165 ))
166 .await?;
167 Ok(prs.into_iter().map(|pr| (repo_of(&pr), pr)).collect())
168}
169
170fn repo_of(pr: &PullRequest) -> String {
174 let url = pr.html_url();
175 let Some(rest) = url.split("bitbucket.org/").nth(1) else {
176 return "-".to_string();
177 };
178 let mut parts = rest.split('/');
179 match (parts.next(), parts.next()) {
180 (Some(ws), Some(repo)) if !ws.is_empty() && !repo.is_empty() => format!("{ws}/{repo}"),
181 _ => "-".to_string(),
182 }
183}
184
185const MAX_IN_FLIGHT: usize = 8;
188
189async fn repositories(client: &Client, workspace: &str, limit: usize) -> Result<Vec<String>> {
200 if limit == 0 {
201 return Ok(Vec::new());
202 }
203 let pagelen = limit.min(100);
204 let page: api::Page<Repository> = client
208 .get_json(&format!(
209 "/repositories/{}?sort=-updated_on&pagelen={pagelen}",
210 urlencoding::encode(workspace)
211 ))
212 .await?;
213 Ok(page
214 .values
215 .into_iter()
216 .filter_map(|r| r.full_name)
217 .take(limit)
218 .collect())
219}
220
221async fn reviewing_in(
223 client: &Client,
224 repo: &str,
225 state: &str,
226 my_uuid: &str,
227) -> Result<Vec<(String, PullRequest)>> {
228 let slug = RepoSlug::parse(repo)?;
229 let prs: Vec<PullRequest> = client
230 .paginate(&api::repo_path(
231 &slug,
232 &format!(
233 "/pullrequests?state={}&pagelen=50&fields={REVIEWER_FIELDS}",
234 urlencoding::encode(&state_query(state))
235 ),
236 ))
237 .await?;
238 Ok(prs
239 .into_iter()
240 .filter(|pr| {
241 pr.reviewer_states()
242 .iter()
243 .any(|r| r.uuid.as_deref() == Some(my_uuid))
244 })
245 .map(|pr| (repo.to_string(), pr))
246 .collect())
247}
248
249pub async fn run(format: Format, args: MineArgs) -> Result<()> {
250 if args.state.eq_ignore_ascii_case("draft") {
256 return Err(BbError::Config(
257 "pr mine does not support --state draft — use `bb pr list --state draft` \
258 inside the repository, or `--role author` and check the `draft` field"
259 .into(),
260 ));
261 }
262
263 let workspaces = crate::workspace::resolve_list(args.workspace.as_deref())?;
264
265 let creds = credentials::load()?;
266 let client = Client::from_env(creds)?;
267
268 let me = current_user(&client).await?;
269 let my_uuid = me.uuid.ok_or_else(|| {
270 BbError::Config(
271 "your bitbucket account has no uuid — cannot identify your pull requests".into(),
272 )
273 })?;
274
275 let spinner = output::spinner("scanning your pull requests");
276 let mut found: Vec<(String, PullRequest, Origin)> = Vec::new();
277 let mut partial: Vec<String> = Vec::new();
278
279 for workspace in workspaces {
284 if args.role != RoleArg::Reviewer {
285 match authored(&client, &workspace, &my_uuid, &args.state).await {
286 Ok(prs) => found.extend(
287 prs.into_iter()
288 .map(|(repo, pr)| (repo, pr, Origin::Authored)),
289 ),
290 Err(crate::error::BbError::Api { status: 403, .. }) => {
291 partial.push(workspace.clone());
292 }
293 Err(e) => return Err(e),
294 }
295 }
296
297 if args.role != RoleArg::Author {
298 let repos = match repositories(&client, &workspace, args.repo_limit).await {
304 Ok(repos) => repos,
305 Err(crate::error::BbError::Api { status: 403, .. }) => {
306 if !partial.contains(&workspace) {
307 partial.push(workspace);
308 }
309 continue;
310 }
311 Err(e) => return Err(e),
312 };
313 let batches: Vec<Vec<(String, PullRequest)>> = stream::iter(repos.iter())
314 .map(|repo| reviewing_in(&client, repo, &args.state, &my_uuid))
315 .buffer_unordered(MAX_IN_FLIGHT)
316 .collect::<Vec<_>>()
317 .await
318 .into_iter()
319 .collect::<Result<Vec<_>>>()?;
320 for batch in batches {
321 found.extend(
322 batch
323 .into_iter()
324 .map(|(repo, pr)| (repo, pr, Origin::Reviewing)),
325 );
326 }
327 }
328 }
329 spinner.finish_and_clear();
330
331 let mut rows: Vec<MineRow> = Vec::new();
338 for (repo, pr, origin) in &found {
339 let this_role = origin.as_role();
340 match rows.iter_mut().find(|r| r.repo == *repo && r.id == pr.id) {
341 Some(existing) => {
342 if existing.my_role != this_role {
343 existing.my_role = "both".to_string();
344 }
345 }
346 None => rows.push(to_row(repo, pr, &my_uuid)),
347 }
348 }
349
350 if args.build {
351 attach_builds(&client, &mut rows).await?;
352 }
353
354 render(format, rows, partial, args.build)
355}
356
357async fn attach_builds(client: &Client, rows: &mut [MineRow]) -> Result<()> {
361 let mut repos: Vec<String> = rows.iter().map(|r| r.repo.clone()).collect();
362 repos.sort();
363 repos.dedup();
364 for repo in repos {
365 let Ok(slug) = RepoSlug::parse(&repo) else {
366 for row in rows.iter_mut().filter(|r| r.repo == repo) {
372 row.build_state = Some(BuildState::None);
373 row.build = Some(Vec::new());
374 }
375 continue;
376 };
377 let ids: Vec<u64> = rows
378 .iter()
379 .filter(|r| r.repo == repo)
380 .map(|r| r.id)
381 .collect();
382 let mut statuses = crate::commands::pr_build::statuses_for(client, &slug, &ids).await?;
383 for row in rows.iter_mut().filter(|r| r.repo == repo) {
384 let found = statuses.remove(&row.id).unwrap_or_default();
385 row.build_state = Some(BuildState::rollup(&found));
386 row.build = Some(found);
387 }
388 }
389 Ok(())
390}
391
392fn render(format: Format, rows: Vec<MineRow>, partial: Vec<String>, build: bool) -> Result<()> {
393 match format {
394 Format::Json => {
395 let report = MineReport {
396 pull_requests: rows,
397 partial,
398 };
399 output::print_json(&report)?;
400 }
401 Format::Human => {
402 if !partial.is_empty() {
403 output::warn(&format!(
404 "could not read {} — the scan is incomplete",
405 partial.join(", ")
406 ));
407 }
408 let mut headers: Vec<&str> = vec!["REPO", "ID", "TITLE", "STATE"];
409 if build {
410 headers.push("BUILD");
411 }
412 headers.extend(["ROLE", "MINE", "UPDATED"]);
413 output::print_table(
414 &headers,
415 rows.iter()
416 .map(|r| {
417 let mut cells = vec![
418 r.repo.clone(),
419 r.id.to_string(),
420 r.title.clone(),
421 r.state.clone(),
422 ];
423 if build {
424 let state = r.build_state.unwrap_or(BuildState::None);
425 cells
426 .push(output::colored_cell(state.label(), output::tone_for(state)));
427 }
428 cells.extend([
429 r.my_role.clone(),
430 r.my_review_state
431 .map(|s| s.as_str().to_string())
432 .unwrap_or_else(|| "-".into()),
433 r.updated_on
434 .as_deref()
435 .map(output::relative_time)
436 .unwrap_or_else(|| "-".into()),
437 ]);
438 cells
439 })
440 .collect(),
441 );
442 }
443 }
444 Ok(())
445}
446
447#[cfg(test)]
448#[allow(clippy::unwrap_used)]
449mod tests {
450 use super::*;
451
452 fn pr_from(json: &str) -> PullRequest {
453 serde_json::from_str(json).unwrap()
454 }
455
456 #[test]
457 fn browse_url_prefers_the_api_link() {
458 let pr = pr_from(
459 r#"{"id":42,"links":{"html":{"href":"https://bitbucket.org/acme/api/pull-requests/42"}}}"#,
460 );
461 assert_eq!(
462 browse_url("acme/api", &pr),
463 "https://bitbucket.org/acme/api/pull-requests/42"
464 );
465 }
466
467 #[test]
468 fn browse_url_is_derived_when_the_api_omits_the_link() {
469 let pr = pr_from(r#"{"id":42}"#);
472 assert_eq!(
473 browse_url("acme/api", &pr),
474 "https://bitbucket.org/acme/api/pull-requests/42"
475 );
476 }
477}