Skip to main content

bb_cli/commands/
pr_mine.rs

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    /// Pull requests I opened.
18    Author,
19    /// Pull requests I am tagged to review.
20    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/// One pull request, flattened to what a brief needs. `repo` is carried on the
34/// row because the rows come from many repositories and nothing else identifies
35/// which one a given id belongs to.
36#[derive(Debug, Serialize)]
37struct MineRow {
38    repo: String,
39    id: u64,
40    title: String,
41    url: String,
42    /// The api's own value, so `--json` stays faithful to bitbucket.
43    state: String,
44    draft: bool,
45    author: String,
46    /// "author", "reviewer" or "both".
47    my_role: String,
48    /// `None` when I am not a reviewer on this pull request.
49    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/// The scan result. A fixed shape in both directions: a consumer must not have
59/// to handle `pull_requests` changing type when one workspace is unreadable.
60#[derive(Debug, Serialize)]
61struct MineReport {
62    pull_requests: Vec<MineRow>,
63    /// Workspaces skipped because the token could not read them.
64    partial: Vec<String>,
65}
66
67/// Which half of the scan a `(repo, pull request)` pair came from, tracked
68/// alongside it so the dedupe merge in `run` can decide `my_role` from where
69/// the row was actually found rather than from re-deriving it off the pull
70/// request's own fields a second time.
71#[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
115/// Pull requests I authored, in one workspace, in one paginated call.
116///
117/// `GET /pullrequests/{uuid}` — the cross-workspace form of this endpoint —
118/// was removed by Atlassian on 2025-02-20 and now returns 404. The supported
119/// replacement is workspace-scoped: `GET /workspaces/{workspace}/pullrequests/{uuid}`,
120/// which takes the same `state` (repeatable) and pagination parameters, so the
121/// caller now loops this over every workspace instead of making one
122/// cross-workspace call.
123///
124/// The endpoint returns the same reduced object as the paginated
125/// per-repository endpoint — see `REVIEWER_FIELDS`'s doc comment — so this
126/// must ask for the same partial-response fields the reviewer half does, or a
127/// row's `draft`, `reviewers` and `my_review_state` all come back wrong
128/// instead of merely missing.
129async 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
146/// The `workspace/repo` a cross-repository result belongs to, read off the
147/// pull request's own html link — the authored endpoint returns pull requests
148/// from many repositories and this is the only per-row source of that name.
149fn 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
161/// Same bound as the build-status fan-out: fast on a busy morning, clear of the
162/// rate limit.
163const MAX_IN_FLIGHT: usize = 8;
164
165/// Splits a comma-separated `--workspace`/`BB_WORKSPACE` value into slugs:
166/// trims whitespace, drops empty segments, and deduplicates while preserving
167/// order.
168fn 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
182/// The workspaces to scan, in precedence order:
183///
184/// 1. `--workspace` (comma-separated).
185/// 2. `BB_WORKSPACE` (same syntax).
186/// 3. The workspace of the git remote in the current checkout, resolved the
187///    same way every other command resolves a repository — but tried rather
188///    than required, since `pr mine` must work outside a checkout as long as
189///    one of the first two sources is given.
190/// 4. Neither present and no checkout: a config error naming both `--workspace`
191///    and `BB_WORKSPACE`, rather than silently scanning nothing.
192///
193/// There is no api call left that discovers a user's workspaces —
194/// `GET /workspaces`, `GET /user/permissions/workspaces` and
195/// `GET /user/permissions/repositories` were all removed by Atlassian under
196/// CHANGE-2770 and now return 410 — so this resolves entirely from local
197/// input.
198fn 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
221/// The `--repo-limit` most recently updated repositories in one workspace.
222/// Sorting by recency and capping is the bound on the whole reviewer half: a
223/// repository nobody has touched in months cannot hold a review waiting on you.
224///
225/// `--repo-limit 0` means scan nothing, and does not even ask — a zero-sized
226/// request is a request purely to discard. Otherwise this fetches exactly one
227/// page, sized to the limit (capped at bitbucket's own page-size ceiling of
228/// 100): `sort=-updated_on` already puts the wanted repositories on page one,
229/// so following `next` here would only pay for rows that `.take(limit)` was
230/// always going to throw away.
231async 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    // `role=member` was removed by Atlassian on 2026-04-14 under CHANGE-2770
237    // and now returns 410; the unfiltered workspace listing is the supported
238    // replacement.
239    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
253/// Pull requests in one repository where I am a reviewer.
254async 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    // `draft` is a boolean on an individual pull request, not a state the api
283    // will filter on, and there is no per-row `draft` flag here to filter on
284    // afterwards the way `pr list --state draft` does — a cross-workspace row
285    // needs no such degradation, so this is rejected rather than silently
286    // asking bitbucket for an invalid `DRAFT` state.
287    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    // The authored half moved to a workspace-scoped endpoint (see `authored`'s
312    // doc comment), so it now needs the workspace list too — for every role,
313    // not only the reviewer half. `workspaces` is resolved once above, before
314    // any request, per `resolve_workspaces`'s precedence order.
315    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            // A 403 means the token has no scope on this workspace, which is
331            // expected on a shared account and must not sink the whole scan —
332            // the slug is reported instead, so a brief built from a partial
333            // view can say so. Anything else (401, 429, a network failure, a
334            // malformed response) is a real failure and must propagate.
335            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    // Which half a pull request was found in is tracked explicitly rather than
364    // re-derived from `to_row`'s own reading of the pull request's fields —
365    // that way a pull request found by both halves ends as one row marked
366    // "both" regardless of whether the api's own reviewer/author fields agree,
367    // instead of silently depending on the first-seen half having the richer
368    // (or even correct) data.
369    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
389/// One statuses fetch per row, grouped by repository so each group reuses one
390/// slug. Runs after the merge and dedupe, never before: a duplicated row must
391/// not cost a second request.
392async 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            // A link-less row (`repo == "-"`) still owes every sibling row the
399            // same shape when `--build` was asked for — both fields carry
400            // `skip_serializing_if`, so leaving them `None` here would make
401            // this row's JSON shape differ from every other row's for no
402            // reason a consumer could name.
403            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}