Skip to main content

bb_cli/
users.rs

1use crate::api::models::User;
2use crate::api::{repo_path, Client};
3use crate::error::{BbError, Result};
4use crate::output;
5use crate::repo::RepoSlug;
6use serde::Deserialize;
7
8/// `/workspaces/{ws}/members` wraps each user in a membership object, unlike
9/// `/default-reviewers`, which returns users directly.
10#[derive(Debug, Deserialize)]
11struct Membership {
12    user: Option<User>,
13}
14
15/// `/repositories/{ws}/{repo}/permissions-config/users` wraps each user in a
16/// permission entry, one per person with explicit repo access. This is
17/// repo-scoped, so it still works when the token lacks workspace scope and
18/// `/workspaces/{ws}/members` 403s — it is the primary pool for that case.
19#[derive(Debug, Deserialize)]
20struct RepoPermission {
21    user: Option<User>,
22}
23
24pub async fn current_user(client: &Client) -> Result<User> {
25    client.get_json("/user").await
26}
27
28/// Everyone `query` could plausibly mean, deduplicated by uuid, plus whether any
29/// lookup that widens the pool was refused (403/401/404) rather than merely
30/// empty — the caller uses that to decide whether an eventual no-match deserves
31/// a warning that the pool may be incomplete.
32async fn candidates(client: &Client, slug: &RepoSlug, extra: &[User]) -> Result<(Vec<User>, bool)> {
33    let mut pool: Vec<User> = Vec::new();
34    let mut pool_incomplete = false;
35
36    // The token may not carry workspace scope. That is not fatal: the
37    // permissions-config and default-reviewers pools below are repo-scoped and
38    // still cover the common cases.
39    match client
40        .paginate::<Membership>(&format!(
41            "/workspaces/{}/members?pagelen=100",
42            slug.workspace
43        ))
44        .await
45    {
46        Ok(memberships) => pool.extend(memberships.into_iter().filter_map(|m| m.user)),
47        Err(BbError::Api { status: 403, .. }) | Err(BbError::Auth) => {
48            pool_incomplete = true;
49        }
50        Err(other) => return Err(other),
51    }
52
53    // `permissions-config/users` generally needs repo *admin*, not merely repo
54    // read/write, so a token with less than admin must degrade here exactly like
55    // it does for `members` above — the other pools below still cover the common
56    // case, and a bare `?` here would turn this fix into a regression for every
57    // token that isn't a repo admin. A 404 is folded into the same tolerant set:
58    // an account with no visibility into this config may see "not found" rather
59    // than "forbidden".
60    match client
61        .paginate::<RepoPermission>(&repo_path(slug, "/permissions-config/users?pagelen=100"))
62        .await
63    {
64        Ok(permissions) => pool.extend(permissions.into_iter().filter_map(|p| p.user)),
65        Err(BbError::Api { status: 403, .. }) | Err(BbError::Auth) | Err(BbError::NotFound) => {
66            pool_incomplete = true;
67        }
68        Err(other) => return Err(other),
69    }
70
71    let defaults: Vec<User> = client
72        .paginate(&repo_path(slug, "/default-reviewers?pagelen=100"))
73        .await?;
74    pool.extend(defaults);
75
76    for user in extra {
77        pool.push(User {
78            uuid: user.uuid.clone(),
79            account_id: user.account_id.clone(),
80            display_name: user.display_name.clone(),
81            nickname: user.nickname.clone(),
82        });
83    }
84
85    let mut seen: Vec<String> = Vec::new();
86    pool.retain(|user| match user.uuid.as_deref() {
87        Some(uuid) => {
88            let fresh = !seen.iter().any(|s| s == uuid);
89            if fresh {
90                seen.push(uuid.to_string());
91            }
92            fresh
93        }
94        None => true,
95    });
96
97    Ok((pool, pool_incomplete))
98}
99
100fn matches(user: &User, needle: &str) -> bool {
101    [user.display_name.as_deref(), user.nickname.as_deref()]
102        .into_iter()
103        .flatten()
104        .any(|field| field.to_lowercase().contains(needle))
105}
106
107fn is_exact(user: &User, needle: &str) -> bool {
108    [user.display_name.as_deref(), user.nickname.as_deref()]
109        .into_iter()
110        .flatten()
111        .any(|field| field.to_lowercase() == needle)
112}
113
114/// One human-typed name to one user.
115///
116/// A `{uuid}` is already exact and is taken verbatim, with no api call. Anything
117/// else is matched case-insensitively as a substring of the display name or
118/// nickname. Emails are not accepted: a reviewer is written to the api as a uuid,
119/// and bitbucket's member listings do not expose email addresses, so an email
120/// could never be resolved — failing here beats failing inside a write.
121pub async fn resolve_user(
122    client: &Client,
123    slug: &RepoSlug,
124    query: &str,
125    extra: &[User],
126) -> Result<User> {
127    let query = query.trim();
128    if query.is_empty() {
129        return Err(BbError::Config("empty user name".into()));
130    }
131    if query.starts_with('{') && query.ends_with('}') {
132        return Ok(User {
133            uuid: Some(query.to_string()),
134            account_id: None,
135            display_name: None,
136            nickname: None,
137        });
138    }
139
140    let needle = query.to_lowercase();
141    let (pool, pool_incomplete) = candidates(client, slug, extra).await?;
142
143    // An exact name wins outright, or a workspace holding both "ana" and
144    // "anastasia" makes "ana" unaddressable forever.
145    let mut found: Vec<User> = pool.into_iter().filter(|u| matches(u, &needle)).collect();
146    if found.iter().any(|u| is_exact(u, &needle)) {
147        found.retain(|u| is_exact(u, &needle));
148    }
149
150    match found.len() {
151        1 => Ok(found.remove(0)),
152        0 => {
153            if pool_incomplete {
154                output::warn(
155                    "some user lists could not be read, so the name search may be \
156                     incomplete — pass a `{uuid}` to be exact",
157                );
158            }
159            Err(BbError::Config(format!(
160                "no user matching `{query}` — pass a `{{uuid}}` to be exact"
161            )))
162        }
163        _ => {
164            let names: Vec<&str> = found.iter().map(|u| u.name()).collect();
165            Err(BbError::Config(format!(
166                "`{query}` matches {} people: {} — pass a `{{uuid}}` to be exact",
167                names.len(),
168                names.join(", ")
169            )))
170        }
171    }
172}