Skip to main content

bb_cli/commands/
pr_list.rs

1use crate::api::models::{BuildState, BuildStatus, PullRequest, ReviewState, ReviewerState};
2use crate::commands::pr::Ctx;
3use crate::commands::pr_build;
4use crate::error::Result;
5use crate::output::{self, Format};
6use crate::users::{current_user, resolve_user};
7use serde::Serialize;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
10pub enum ReviewStateArg {
11    Approved,
12    ChangesRequested,
13    Pending,
14}
15
16impl ReviewStateArg {
17    fn as_state(self) -> ReviewState {
18        match self {
19            Self::Approved => ReviewState::Approved,
20            Self::ChangesRequested => ReviewState::ChangesRequested,
21            Self::Pending => ReviewState::Pending,
22        }
23    }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
27pub enum BuildStateArg {
28    Successful,
29    Failed,
30    Inprogress,
31    Stopped,
32    /// No check ever reported on the pull request.
33    None,
34}
35
36impl BuildStateArg {
37    fn as_state(self) -> BuildState {
38        match self {
39            Self::Successful => BuildState::Successful,
40            Self::Failed => BuildState::Failed,
41            Self::Inprogress => BuildState::InProgress,
42            Self::Stopped => BuildState::Stopped,
43            Self::None => BuildState::None,
44        }
45    }
46}
47
48#[derive(Debug)]
49pub struct ListArgs {
50    pub destination: Option<String>,
51    pub state: String,
52    pub reviewer: Option<String>,
53    pub author: Option<String>,
54    pub review_state: Option<ReviewStateArg>,
55    pub needs_my_review: bool,
56    /// Show the build column. Costs one extra request per pull request.
57    pub build: bool,
58    pub build_status: Option<BuildStateArg>,
59}
60
61/// Bitbucket's paginated pull-request endpoint returns a reduced object that omits
62/// reviewers, participants and the draft flag. They come back only when asked for
63/// explicitly with a partial-response parameter.
64///
65/// The `+` must arrive url-encoded as `%2B`: a bare `+` in a query string decodes
66/// as a space and bitbucket then ignores the whole parameter, which is exactly the
67/// silent failure this feature exists to fix.
68pub(crate) const REVIEWER_FIELDS: &str =
69    "%2Bvalues.reviewers,%2Bvalues.participants,%2Bvalues.draft,%2Bvalues.comment_count";
70
71const ALL_STATES: &str = "OPEN,MERGED,DECLINED,SUPERSEDED";
72
73#[derive(Debug, Serialize)]
74struct PrRow {
75    id: u64,
76    title: String,
77    /// The api's own value, so `--json` stays faithful to bitbucket.
78    state: String,
79    draft: bool,
80    /// The one word the table shows, folding `draft` into `state`. Carried on the
81    /// row rather than recomputed at render time, because filtering means the rows
82    /// and the fetched pull requests are no longer index-aligned.
83    #[serde(skip)]
84    display_state: String,
85    author: String,
86    source: String,
87    destination: String,
88    reviewers: Vec<ReviewerState>,
89    url: String,
90    /// Absent unless the build column was asked for, so today's `--json` shape
91    /// is unchanged for existing callers.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    build_state: Option<BuildState>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    build: Option<Vec<BuildStatus>>,
96}
97
98fn to_row(pr: &PullRequest) -> PrRow {
99    PrRow {
100        id: pr.id,
101        title: pr.title.clone().unwrap_or_default(),
102        state: pr.state.clone().unwrap_or_else(|| "-".into()),
103        draft: pr.draft,
104        display_state: pr.display_state(),
105        author: pr.author_name().to_string(),
106        source: pr.source_branch().to_string(),
107        destination: pr.destination_branch().to_string(),
108        reviewers: pr.reviewer_states(),
109        url: pr.html_url().to_string(),
110        build_state: None,
111        build: None,
112    }
113}
114
115fn reviewer_cell(reviewers: &[ReviewerState]) -> String {
116    reviewers
117        .iter()
118        .map(|r| format!("{} {}", r.name, r.state.mark()))
119        .collect::<Vec<_>>()
120        .join(", ")
121}
122
123/// `all` and `draft` are bb-level conveniences, not bitbucket states. `draft` is a
124/// boolean on an OPEN pull request, so it asks for OPEN and filters afterwards.
125///
126/// Shared with `pr_mine`, which has no `draft` boolean to filter on afterwards
127/// (a cross-workspace pull request result carries the same fields either way) —
128/// `pr mine` rejects `--state draft` before this is ever called with it.
129pub(crate) fn state_query(state: &str) -> String {
130    if state.eq_ignore_ascii_case("all") {
131        ALL_STATES.to_string()
132    } else if state.eq_ignore_ascii_case("draft") {
133        "OPEN".to_string()
134    } else {
135        state.to_uppercase()
136    }
137}
138
139/// The uuid of whoever the token belongs to, fetched at most once per invocation
140/// and only when a filter actually needs it.
141async fn my_uuid(ctx: &Ctx) -> Result<Option<String>> {
142    Ok(current_user(&ctx.client).await?.uuid)
143}
144
145fn my_review_state(pr: &PullRequest, my_uuid: Option<&str>) -> Option<ReviewState> {
146    let me = my_uuid?;
147    pr.reviewer_states()
148        .into_iter()
149        .find(|r| r.uuid.as_deref() == Some(me))
150        .map(|r| r.state)
151}
152
153pub async fn list(ctx: &Ctx, args: ListArgs) -> Result<()> {
154    // Resolve everything the filters need before fetching, so a bad name fails
155    // fast instead of after a paginated download.
156    let reviewer_uuid = match args.reviewer.as_deref() {
157        Some(name) => resolve_user(&ctx.client, &ctx.slug, name, &[]).await?.uuid,
158        None => None,
159    };
160
161    // `GET /user` must happen at most once per invocation, so every flag that
162    // needs "who am I" (`--author @me`, `--needs-my-review`, `--review-state`)
163    // shares this single fetch instead of each fetching it independently.
164    let author_is_me = args.author.as_deref() == Some("@me");
165    let me = if author_is_me || args.needs_my_review || args.review_state.is_some() {
166        my_uuid(ctx).await?
167    } else {
168        None
169    };
170
171    let author_uuid = match args.author.as_deref() {
172        Some("@me") => me.clone(),
173        Some(name) => resolve_user(&ctx.client, &ctx.slug, name, &[]).await?.uuid,
174        None => None,
175    };
176
177    let want_draft = args.state.eq_ignore_ascii_case("draft");
178
179    let spinner = output::spinner("fetching pull requests");
180    let prs: Vec<PullRequest> = ctx
181        .client
182        .paginate(&ctx.path(&format!(
183            "/pullrequests?state={}&pagelen=50&fields={REVIEWER_FIELDS}",
184            urlencoding::encode(&state_query(&args.state))
185        )))
186        .await?;
187    spinner.finish_and_clear();
188
189    let kept: Vec<&PullRequest> = prs
190        .iter()
191        .filter(|pr| match args.destination.as_deref() {
192            Some(branch) => pr.destination_branch() == branch,
193            None => true,
194        })
195        .filter(|pr| !want_draft || pr.draft)
196        .filter(|pr| match reviewer_uuid.as_deref() {
197            Some(uuid) => pr
198                .reviewer_states()
199                .iter()
200                .any(|r| r.uuid.as_deref() == Some(uuid)),
201            None => true,
202        })
203        .filter(|pr| match author_uuid.as_deref() {
204            Some(uuid) => pr.author.as_ref().and_then(|a| a.uuid.as_deref()) == Some(uuid),
205            None => true,
206        })
207        .filter(|pr| match args.review_state {
208            Some(wanted) => my_review_state(pr, me.as_deref()) == Some(wanted.as_state()),
209            None => true,
210        })
211        .filter(|pr| {
212            if !args.needs_my_review {
213                return true;
214            }
215            // I am a reviewer and I have not approved.
216            matches!(
217                my_review_state(pr, me.as_deref()),
218                Some(ReviewState::ChangesRequested) | Some(ReviewState::Pending)
219            )
220        })
221        .collect();
222
223    let mut rows: Vec<PrRow> = kept.iter().map(|pr| to_row(pr)).collect();
224
225    // Build status is a per-pull-request endpoint, so this is the one place the
226    // command can cost more than one request. Fetching after the filters keeps
227    // `--author @me --build` at one request per surviving row, not per row in
228    // the repository.
229    let want_build = args.build || args.build_status.is_some();
230    if want_build {
231        let ids: Vec<u64> = rows.iter().map(|r| r.id).collect();
232        let spinner = output::spinner("fetching build statuses");
233        let mut statuses = pr_build::statuses_for(&ctx.client, &ctx.slug, &ids).await?;
234        spinner.finish_and_clear();
235        for row in &mut rows {
236            let found = statuses.remove(&row.id).unwrap_or_default();
237            row.build_state = Some(BuildState::rollup(&found));
238            row.build = Some(found);
239        }
240        if let Some(wanted) = args.build_status {
241            rows.retain(|r| r.build_state == Some(wanted.as_state()));
242        }
243    }
244
245    render(ctx, &rows, want_build)
246}
247
248fn build_cell(state: Option<BuildState>) -> String {
249    let state = state.unwrap_or(BuildState::None);
250    output::colored_cell(state.label(), output::tone_for(state))
251}
252
253fn render(ctx: &Ctx, rows: &[PrRow], build: bool) -> Result<()> {
254    match ctx.format {
255        Format::Json => output::print_json(&rows)?,
256        Format::Human => {
257            let mut headers: Vec<&str> = vec!["ID", "TITLE", "STATE"];
258            if build {
259                headers.push("BUILD");
260            }
261            headers.extend(["SOURCE", "→", "TARGET", "AUTHOR", "REVIEWERS"]);
262            output::print_table(
263                &headers,
264                rows.iter()
265                    .map(|r| {
266                        let mut cells =
267                            vec![r.id.to_string(), r.title.clone(), r.display_state.clone()];
268                        if build {
269                            cells.push(build_cell(r.build_state));
270                        }
271                        cells.extend([
272                            r.source.clone(),
273                            "→".into(),
274                            r.destination.clone(),
275                            r.author.clone(),
276                            reviewer_cell(&r.reviewers),
277                        ]);
278                        cells
279                    })
280                    .collect(),
281            )
282        }
283    }
284    Ok(())
285}