Skip to main content

bb_cli/commands/
repo.rs

1use crate::api::models::Repository;
2use crate::error::{BbError, Result};
3use crate::output::{self, Format};
4use crate::workspace::{projects, WorkspaceCtx};
5use serde::Serialize;
6
7#[derive(Debug, Serialize)]
8struct RepoRow {
9    name: String,
10    project: String,
11    access: String,
12    updated: String,
13}
14
15pub async fn list(
16    ctx: &WorkspaceCtx,
17    project: Option<String>,
18    name: Option<String>,
19    limit: usize,
20) -> Result<()> {
21    // `q` narrows server-side so a large workspace is not paged through only
22    // to be discarded locally. The quotes are part of bitbucket's query
23    // grammar, and `urlencoding::encode` covers them along with the `=`.
24    let query = match &project {
25        Some(key) => format!(
26            "?pagelen=100&sort=-updated_on&q={}",
27            urlencoding::encode(&format!("project.key=\"{key}\""))
28        ),
29        None => "?pagelen=100&sort=-updated_on".to_string(),
30    };
31
32    let spinner = output::spinner("fetching repositories");
33    let repos: Vec<Repository> = ctx.client.paginate(&ctx.repos_path(&query)).await?;
34    spinner.finish_and_clear();
35
36    let needle = name.map(|n| n.to_lowercase());
37    let rows: Vec<RepoRow> = repos
38        .iter()
39        .filter(|r| match &needle {
40            Some(needle) => r.display_name().to_lowercase().contains(needle),
41            None => true,
42        })
43        .take(limit)
44        .map(|r| RepoRow {
45            name: r.display_name().to_string(),
46            project: r.project_key().to_string(),
47            access: r.access().to_string(),
48            updated: r
49                .updated_on
50                .as_deref()
51                .map(output::relative_time)
52                .unwrap_or_else(|| "-".into()),
53        })
54        .collect();
55
56    match ctx.format {
57        Format::Json => output::print_json(&rows)?,
58        Format::Human => output::print_table(
59            &["NAME", "PROJECT", "ACCESS", "UPDATED"],
60            rows.iter()
61                .map(|r| {
62                    vec![
63                        r.name.clone(),
64                        r.project.clone(),
65                        r.access.clone(),
66                        r.updated.clone(),
67                    ]
68                })
69                .collect(),
70        ),
71    }
72    Ok(())
73}
74
75/// The creation request body.
76///
77/// Only three fields, and two of them optional. `scm`, `fork_policy`,
78/// `mainbranch`, `has_wiki` and `has_issues` are deliberately absent so
79/// bitbucket and the workspace's own settings decide them.
80#[derive(Debug, Serialize)]
81struct CreateBody {
82    /// Sent always. Omitting it does not reliably produce a private
83    /// repository — the effective default depends on workspace configuration,
84    /// and getting it wrong publishes source code to the internet.
85    is_private: bool,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    project: Option<ProjectKey>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    description: Option<String>,
90}
91
92#[derive(Debug, Serialize)]
93struct ProjectKey {
94    key: String,
95}
96
97/// The project the new repository goes into.
98///
99/// The lookup runs only on the picker path, so `--project` costs no extra
100/// request — the same rule `pr resolve` and the request-changes gate follow: a
101/// caller that already has its answer must not pay for a prompt it will never
102/// see.
103async fn resolve_project(ctx: &WorkspaceCtx, explicit: Option<String>) -> Result<String> {
104    if let Some(key) = explicit {
105        return Ok(key);
106    }
107    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
108        return Err(BbError::Config(
109            "a project is required — pass --project KEY".into(),
110        ));
111    }
112
113    let spinner = output::spinner("fetching projects");
114    let projects = projects(ctx).await?;
115    spinner.finish_and_clear();
116
117    let choices: Vec<String> = projects
118        .iter()
119        .filter(|p| p.key.is_some())
120        .map(|p| format!("{} — {}", p.key_or_dash(), p.name_or_dash()))
121        .collect();
122    if choices.is_empty() {
123        return Err(BbError::Config(format!(
124            "workspace {} has no projects you can see — pass --project KEY",
125            ctx.workspace
126        )));
127    }
128
129    // inquire writes to stderr, so `--json` stdout stays pure.
130    let picked = inquire::Select::new("which project?", choices)
131        .prompt()
132        .map_err(|e| BbError::Config(format!("no project chosen: {e}")))?;
133    Ok(picked
134        .split_once(" — ")
135        .map(|(key, _)| key.to_string())
136        .unwrap_or(picked))
137}
138
139pub async fn create(
140    ctx: &WorkspaceCtx,
141    name: String,
142    project: Option<String>,
143    description: Option<String>,
144    public: bool,
145) -> Result<()> {
146    let key = resolve_project(ctx, project).await?;
147
148    let body = CreateBody {
149        is_private: !public,
150        project: Some(ProjectKey { key }),
151        description,
152    };
153
154    // `name` is sent verbatim as the slug: bitbucket normalises it and derives
155    // the display name. A local guess that disagrees with the server's would
156    // produce a url that 404s.
157    let path = format!("/{}", urlencoding::encode(&name));
158    let spinner = output::spinner("creating repository");
159    let repo: Repository = ctx
160        .client
161        .post_json(&ctx.repos_path(&path), &body)
162        .await
163        .inspect_err(|_| spinner.finish_and_clear())?;
164    spinner.finish_and_clear();
165
166    match ctx.format {
167        Format::Json => output::print_json(&repo)?,
168        Format::Human => {
169            output::success(&format!(
170                "created {} in project {}",
171                repo.full_name.as_deref().unwrap_or(repo.display_name()),
172                repo.project_key()
173            ));
174            // A missing convenience url must never fail a create that
175            // succeeded, so each line is printed only if the server sent it.
176            if let Some(url) = repo.html_url() {
177                println!("  {url}");
178            }
179            if let Some(url) = repo.clone_url() {
180                println!("  {url}");
181            }
182        }
183    }
184    Ok(())
185}