Skip to main content

bb_cli/commands/
pr_list.rs

1use crate::api::models::{PullRequest, ReviewState, ReviewerState};
2use crate::commands::pr::Ctx;
3use crate::error::Result;
4use crate::output::{self, Format};
5use crate::users::{current_user, resolve_user};
6use serde::Serialize;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
9pub enum ReviewStateArg {
10    Approved,
11    ChangesRequested,
12    Pending,
13}
14
15impl ReviewStateArg {
16    fn as_state(self) -> ReviewState {
17        match self {
18            Self::Approved => ReviewState::Approved,
19            Self::ChangesRequested => ReviewState::ChangesRequested,
20            Self::Pending => ReviewState::Pending,
21        }
22    }
23}
24
25#[derive(Debug)]
26pub struct ListArgs {
27    pub destination: Option<String>,
28    pub state: String,
29    pub reviewer: Option<String>,
30    pub author: Option<String>,
31    pub review_state: Option<ReviewStateArg>,
32    pub needs_my_review: bool,
33}
34
35/// Bitbucket's paginated pull-request endpoint returns a reduced object that omits
36/// reviewers, participants and the draft flag. They come back only when asked for
37/// explicitly with a partial-response parameter.
38///
39/// The `+` must arrive url-encoded as `%2B`: a bare `+` in a query string decodes
40/// as a space and bitbucket then ignores the whole parameter, which is exactly the
41/// silent failure this feature exists to fix.
42const REVIEWER_FIELDS: &str = "%2Bvalues.reviewers,%2Bvalues.participants,%2Bvalues.draft";
43
44const ALL_STATES: &str = "OPEN,MERGED,DECLINED,SUPERSEDED";
45
46#[derive(Debug, Serialize)]
47struct PrRow {
48    id: u64,
49    title: String,
50    /// The api's own value, so `--json` stays faithful to bitbucket.
51    state: String,
52    draft: bool,
53    /// The one word the table shows, folding `draft` into `state`. Carried on the
54    /// row rather than recomputed at render time, because filtering means the rows
55    /// and the fetched pull requests are no longer index-aligned.
56    #[serde(skip)]
57    display_state: String,
58    author: String,
59    source: String,
60    destination: String,
61    reviewers: Vec<ReviewerState>,
62    url: String,
63}
64
65fn to_row(pr: &PullRequest) -> PrRow {
66    PrRow {
67        id: pr.id,
68        title: pr.title.clone().unwrap_or_default(),
69        state: pr.state.clone().unwrap_or_else(|| "-".into()),
70        draft: pr.draft,
71        display_state: pr.display_state(),
72        author: pr.author_name().to_string(),
73        source: pr.source_branch().to_string(),
74        destination: pr.destination_branch().to_string(),
75        reviewers: pr.reviewer_states(),
76        url: pr.html_url().to_string(),
77    }
78}
79
80fn reviewer_cell(reviewers: &[ReviewerState]) -> String {
81    reviewers
82        .iter()
83        .map(|r| format!("{} {}", r.name, r.state.mark()))
84        .collect::<Vec<_>>()
85        .join(", ")
86}
87
88/// `all` and `draft` are bb-level conveniences, not bitbucket states. `draft` is a
89/// boolean on an OPEN pull request, so it asks for OPEN and filters afterwards.
90fn state_query(state: &str) -> String {
91    if state.eq_ignore_ascii_case("all") {
92        ALL_STATES.to_string()
93    } else if state.eq_ignore_ascii_case("draft") {
94        "OPEN".to_string()
95    } else {
96        state.to_uppercase()
97    }
98}
99
100/// The uuid of whoever the token belongs to, fetched at most once per invocation
101/// and only when a filter actually needs it.
102async fn my_uuid(ctx: &Ctx) -> Result<Option<String>> {
103    Ok(current_user(&ctx.client).await?.uuid)
104}
105
106fn my_review_state(pr: &PullRequest, my_uuid: Option<&str>) -> Option<ReviewState> {
107    let me = my_uuid?;
108    pr.reviewer_states()
109        .into_iter()
110        .find(|r| r.uuid.as_deref() == Some(me))
111        .map(|r| r.state)
112}
113
114pub async fn list(ctx: &Ctx, args: ListArgs) -> Result<()> {
115    // Resolve everything the filters need before fetching, so a bad name fails
116    // fast instead of after a paginated download.
117    let reviewer_uuid = match args.reviewer.as_deref() {
118        Some(name) => resolve_user(&ctx.client, &ctx.slug, name, &[]).await?.uuid,
119        None => None,
120    };
121
122    // `GET /user` must happen at most once per invocation, so every flag that
123    // needs "who am I" (`--author @me`, `--needs-my-review`, `--review-state`)
124    // shares this single fetch instead of each fetching it independently.
125    let author_is_me = args.author.as_deref() == Some("@me");
126    let me = if author_is_me || args.needs_my_review || args.review_state.is_some() {
127        my_uuid(ctx).await?
128    } else {
129        None
130    };
131
132    let author_uuid = match args.author.as_deref() {
133        Some("@me") => me.clone(),
134        Some(name) => resolve_user(&ctx.client, &ctx.slug, name, &[]).await?.uuid,
135        None => None,
136    };
137
138    let want_draft = args.state.eq_ignore_ascii_case("draft");
139
140    let spinner = output::spinner("fetching pull requests");
141    let prs: Vec<PullRequest> = ctx
142        .client
143        .paginate(&ctx.path(&format!(
144            "/pullrequests?state={}&pagelen=50&fields={REVIEWER_FIELDS}",
145            urlencoding::encode(&state_query(&args.state))
146        )))
147        .await?;
148    spinner.finish_and_clear();
149
150    let rows: Vec<PrRow> = prs
151        .iter()
152        .filter(|pr| match args.destination.as_deref() {
153            Some(branch) => pr.destination_branch() == branch,
154            None => true,
155        })
156        .filter(|pr| !want_draft || pr.draft)
157        .filter(|pr| match reviewer_uuid.as_deref() {
158            Some(uuid) => pr
159                .reviewer_states()
160                .iter()
161                .any(|r| r.uuid.as_deref() == Some(uuid)),
162            None => true,
163        })
164        .filter(|pr| match author_uuid.as_deref() {
165            Some(uuid) => pr.author.as_ref().and_then(|a| a.uuid.as_deref()) == Some(uuid),
166            None => true,
167        })
168        .filter(|pr| match args.review_state {
169            Some(wanted) => my_review_state(pr, me.as_deref()) == Some(wanted.as_state()),
170            None => true,
171        })
172        .filter(|pr| {
173            if !args.needs_my_review {
174                return true;
175            }
176            // I am a reviewer and I have not approved.
177            matches!(
178                my_review_state(pr, me.as_deref()),
179                Some(ReviewState::ChangesRequested) | Some(ReviewState::Pending)
180            )
181        })
182        .map(to_row)
183        .collect();
184
185    render(ctx, &rows)
186}
187
188fn render(ctx: &Ctx, rows: &[PrRow]) -> Result<()> {
189    match ctx.format {
190        Format::Json => output::print_json(&rows)?,
191        Format::Human => output::print_table(
192            &[
193                "ID",
194                "TITLE",
195                "STATE",
196                "SOURCE",
197                "→",
198                "TARGET",
199                "AUTHOR",
200                "REVIEWERS",
201            ],
202            rows.iter()
203                .map(|r| {
204                    vec![
205                        r.id.to_string(),
206                        r.title.clone(),
207                        r.display_state.clone(),
208                        r.source.clone(),
209                        "→".into(),
210                        r.destination.clone(),
211                        r.author.clone(),
212                        reviewer_cell(&r.reviewers),
213                    ]
214                })
215                .collect(),
216        ),
217    }
218    Ok(())
219}