Skip to main content

bb_cli/commands/
pr_reviewers.rs

1use crate::api::models::{PullRequest, ReviewerRef, ReviewerState, User};
2use crate::commands::pr::Ctx;
3use crate::error::{BbError, Result};
4use crate::output::{self, Format};
5use crate::users::resolve_user;
6
7async fn fetch(ctx: &Ctx, id: u64) -> Result<PullRequest> {
8    ctx.client
9        .get_json(&ctx.path(&format!("/pullrequests/{id}")))
10        .await
11}
12
13fn render(ctx: &Ctx, states: &[ReviewerState]) -> Result<()> {
14    match ctx.format {
15        Format::Json => output::print_json(&states)?,
16        Format::Human => output::print_table(
17            &["NAME", "STATE"],
18            states
19                .iter()
20                .map(|s| {
21                    vec![
22                        s.name.clone(),
23                        // The serialized name is the same vocabulary the --json
24                        // output uses, so humans and scripts read one set of words.
25                        serde_json::to_value(s.state)
26                            .ok()
27                            .and_then(|v| v.as_str().map(str::to_string))
28                            .unwrap_or_else(|| "pending".into()),
29                    ]
30                })
31                .collect(),
32        ),
33    }
34    Ok(())
35}
36
37pub async fn list(ctx: &Ctx, id: u64) -> Result<()> {
38    let pr = fetch(ctx, id).await?;
39    render(ctx, &pr.reviewer_states())
40}
41
42fn split_names(names: &str) -> Vec<&str> {
43    names
44        .split(',')
45        .map(str::trim)
46        .filter(|s| !s.is_empty())
47        .collect()
48}
49
50/// Resolves every name before any write, so a typo in the second name cannot
51/// leave a half-applied change.
52async fn resolve_all(ctx: &Ctx, names: &str, pool: &[User]) -> Result<Vec<User>> {
53    let requested = split_names(names);
54    if requested.is_empty() {
55        return Err(BbError::Config("no reviewer name given".into()));
56    }
57    let mut resolved = Vec::new();
58    for name in requested {
59        resolved.push(resolve_user(&ctx.client, &ctx.slug, name, pool).await?);
60    }
61    Ok(resolved)
62}
63
64/// There is no add-reviewer or remove-reviewer endpoint, so the whole set is
65/// written back. `title` is included because the api rejects a PUT without it;
66/// every other field is omitted and left untouched.
67async fn write_reviewers(
68    ctx: &Ctx,
69    id: u64,
70    pr: &PullRequest,
71    uuids: Vec<String>,
72    success_message: &str,
73) -> Result<()> {
74    let body = serde_json::json!({
75        "title": pr.title.clone().unwrap_or_default(),
76        "reviewers": uuids
77            .into_iter()
78            .map(|uuid| ReviewerRef { uuid })
79            .collect::<Vec<_>>(),
80    });
81    let updated: PullRequest = ctx
82        .client
83        .put_json(&ctx.path(&format!("/pullrequests/{id}")), &body)
84        .await?;
85    // Only announce success once the PUT has actually returned Ok — printing
86    // it earlier would claim success ahead of a write that might still fail.
87    if !ctx.format.is_json() {
88        output::success(success_message);
89    }
90    render(ctx, &updated.reviewer_states())
91}
92
93fn current_uuids(pr: &PullRequest) -> Vec<String> {
94    pr.reviewers.iter().filter_map(|r| r.uuid.clone()).collect()
95}
96
97pub async fn add(ctx: &Ctx, id: u64, names: &str) -> Result<()> {
98    let pr = fetch(ctx, id).await?;
99    let resolved = resolve_all(ctx, names, &pr.reviewers).await?;
100
101    let mut uuids = current_uuids(&pr);
102    let mut added: Vec<String> = Vec::new();
103    let mut already: Vec<String> = Vec::new();
104    for user in &resolved {
105        let uuid = user
106            .uuid
107            .clone()
108            .ok_or_else(|| BbError::Config(format!("`{}` has no uuid to tag", user.name())))?;
109        if uuids.contains(&uuid) {
110            already.push(user.name().to_string());
111        } else {
112            uuids.push(uuid);
113            added.push(user.name().to_string());
114        }
115    }
116
117    if added.is_empty() {
118        if !ctx.format.is_json() {
119            output::info(&format!("already a reviewer: {}", already.join(", ")));
120        }
121        return render(ctx, &pr.reviewer_states());
122    }
123
124    if !already.is_empty() && !ctx.format.is_json() {
125        output::info(&format!("already a reviewer: {}", already.join(", ")));
126    }
127    let message = format!("added {}", added.join(", "));
128    write_reviewers(ctx, id, &pr, uuids, &message).await
129}
130
131pub async fn remove(ctx: &Ctx, id: u64, names: &str) -> Result<()> {
132    let pr = fetch(ctx, id).await?;
133    let resolved = resolve_all(ctx, names, &pr.reviewers).await?;
134
135    let mut uuids = current_uuids(&pr);
136    let mut removed: Vec<String> = Vec::new();
137    for user in &resolved {
138        // A silent no-op would let "remove Raigon" look like it worked when it
139        // matched nobody on this pull request.
140        let uuid = user
141            .uuid
142            .as_deref()
143            .filter(|uuid| uuids.iter().any(|u| u == uuid))
144            .ok_or_else(|| {
145                BbError::Config(format!("`{}` is not a reviewer on #{id}", user.name()))
146            })?
147            .to_string();
148        uuids.retain(|u| *u != uuid);
149        removed.push(user.name().to_string());
150    }
151
152    let message = format!("removed {}", removed.join(", "));
153    write_reviewers(ctx, id, &pr, uuids, &message).await
154}