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::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    /// Bitbucket's own comment counter, so a brief can tell a pull request
53    /// somebody has commented on from one nobody has touched. It is the only
54    /// comment signal phase 1 carries: `my_review_state` stays `pending` when a
55    /// reviewer comments without approving or requesting changes, so without
56    /// this a reviewer's unanswered thread is invisible until somebody fetches
57    /// the pull request individually. Serialized even when `None` — absence is
58    /// "the api did not say", which a consumer must treat as "look properly",
59    /// not as zero.
60    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/// The scan result. A fixed shape in both directions: a consumer must not have
68/// to handle `pull_requests` changing type when one workspace is unreadable.
69#[derive(Debug, Serialize)]
70struct MineReport {
71    pull_requests: Vec<MineRow>,
72    /// Workspaces skipped because the token could not read them.
73    partial: Vec<String>,
74}
75
76/// Which half of the scan a `(repo, pull request)` pair came from, tracked
77/// alongside it so the dedupe merge in `run` can decide `my_role` from where
78/// the row was actually found rather than from re-deriving it off the pull
79/// request's own fields a second time.
80#[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
95/// The browser url for a pull request. Bitbucket normally supplies it in
96/// `links.html.href`, and that value is preferred; when it is absent
97/// `html_url()` yields `"-"`, which would reach a consumer as a dead link — and
98/// the daily brief renders this field as a clickable link, so a placeholder is
99/// worse than a reconstruction. The web url is stable and derivable from the
100/// repository and the id, so derive it.
101fn 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
139/// Pull requests I authored, in one workspace, in one paginated call.
140///
141/// `GET /pullrequests/{uuid}` — the cross-workspace form of this endpoint —
142/// was removed by Atlassian on 2025-02-20 and now returns 404. The supported
143/// replacement is workspace-scoped: `GET /workspaces/{workspace}/pullrequests/{uuid}`,
144/// which takes the same `state` (repeatable) and pagination parameters, so the
145/// caller now loops this over every workspace instead of making one
146/// cross-workspace call.
147///
148/// The endpoint returns the same reduced object as the paginated
149/// per-repository endpoint — see `REVIEWER_FIELDS`'s doc comment — so this
150/// must ask for the same partial-response fields the reviewer half does, or a
151/// row's `draft`, `reviewers` and `my_review_state` all come back wrong
152/// instead of merely missing.
153async 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
170/// The `workspace/repo` a cross-repository result belongs to, read off the
171/// pull request's own html link — the authored endpoint returns pull requests
172/// from many repositories and this is the only per-row source of that name.
173fn 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
185/// Same bound as the build-status fan-out: fast on a busy morning, clear of the
186/// rate limit.
187const MAX_IN_FLIGHT: usize = 8;
188
189/// The `--repo-limit` most recently updated repositories in one workspace.
190/// Sorting by recency and capping is the bound on the whole reviewer half: a
191/// repository nobody has touched in months cannot hold a review waiting on you.
192///
193/// `--repo-limit 0` means scan nothing, and does not even ask — a zero-sized
194/// request is a request purely to discard. Otherwise this fetches exactly one
195/// page, sized to the limit (capped at bitbucket's own page-size ceiling of
196/// 100): `sort=-updated_on` already puts the wanted repositories on page one,
197/// so following `next` here would only pay for rows that `.take(limit)` was
198/// always going to throw away.
199async 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    // `role=member` was removed by Atlassian on 2026-04-14 under CHANGE-2770
205    // and now returns 410; the unfiltered workspace listing is the supported
206    // replacement.
207    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
221/// Pull requests in one repository where I am a reviewer.
222async 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    // `draft` is a boolean on an individual pull request, not a state the api
251    // will filter on, and there is no per-row `draft` flag here to filter on
252    // afterwards the way `pr list --state draft` does — a cross-workspace row
253    // needs no such degradation, so this is rejected rather than silently
254    // asking bitbucket for an invalid `DRAFT` state.
255    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    // The authored half moved to a workspace-scoped endpoint (see `authored`'s
280    // doc comment), so it now needs the workspace list too — for every role,
281    // not only the reviewer half. `workspaces` is resolved once above, before
282    // any request, per `workspace::resolve_list`'s precedence order.
283    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            // A 403 means the token has no scope on this workspace, which is
299            // expected on a shared account and must not sink the whole scan —
300            // the slug is reported instead, so a brief built from a partial
301            // view can say so. Anything else (401, 429, a network failure, a
302            // malformed response) is a real failure and must propagate.
303            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    // Which half a pull request was found in is tracked explicitly rather than
332    // re-derived from `to_row`'s own reading of the pull request's fields —
333    // that way a pull request found by both halves ends as one row marked
334    // "both" regardless of whether the api's own reviewer/author fields agree,
335    // instead of silently depending on the first-seen half having the richer
336    // (or even correct) data.
337    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
357/// One statuses fetch per row, grouped by repository so each group reuses one
358/// slug. Runs after the merge and dedupe, never before: a duplicated row must
359/// not cost a second request.
360async 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            // A link-less row (`repo == "-"`) still owes every sibling row the
367            // same shape when `--build` was asked for — both fields carry
368            // `skip_serializing_if`, so leaving them `None` here would make
369            // this row's JSON shape differ from every other row's for no
370            // reason a consumer could name.
371            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        // `html_url()` yields "-" here, which would reach the daily brief as a
470        // dead markdown link.
471        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}