bitbucket_cli/cli/
repo.rs

1use anyhow::{Context, Result};
2use clap::Subcommand;
3use colored::Colorize;
4use tabled::{Table, Tabled};
5
6use crate::api::BitbucketClient;
7use crate::models::CreateRepositoryRequest;
8
9#[derive(Subcommand)]
10pub enum RepoCommands {
11    /// List repositories in a workspace
12    List {
13        /// Workspace slug
14        workspace: String,
15
16        /// Number of results per page
17        #[arg(short, long, default_value = "25")]
18        limit: u32,
19    },
20
21    /// View repository details
22    View {
23        /// Repository in format workspace/repo-slug
24        repo: String,
25
26        /// Open in browser
27        #[arg(short, long)]
28        web: bool,
29    },
30
31    /// Clone a repository
32    Clone {
33        /// Repository in format workspace/repo-slug
34        repo: String,
35
36        /// Directory to clone into
37        #[arg(short, long)]
38        dir: Option<String>,
39    },
40
41    /// Create a new repository
42    Create {
43        /// Workspace slug
44        workspace: String,
45
46        /// Repository name
47        name: String,
48
49        /// Repository description
50        #[arg(short, long)]
51        description: Option<String>,
52
53        /// Make repository public
54        #[arg(long)]
55        public: bool,
56
57        /// Project key to add repository to
58        #[arg(short, long)]
59        project: Option<String>,
60    },
61
62    /// Fork a repository
63    Fork {
64        /// Repository to fork in format workspace/repo-slug
65        repo: String,
66
67        /// Workspace to fork into
68        #[arg(short, long)]
69        workspace: Option<String>,
70
71        /// New repository name
72        #[arg(short, long)]
73        name: Option<String>,
74    },
75
76    /// Delete a repository
77    Delete {
78        /// Repository in format workspace/repo-slug
79        repo: String,
80
81        /// Skip confirmation prompt
82        #[arg(short, long)]
83        yes: bool,
84    },
85}
86
87#[derive(Tabled)]
88struct RepoRow {
89    #[tabled(rename = "NAME")]
90    name: String,
91    #[tabled(rename = "DESCRIPTION")]
92    description: String,
93    #[tabled(rename = "PRIVATE")]
94    private: String,
95    #[tabled(rename = "UPDATED")]
96    updated: String,
97}
98
99impl RepoCommands {
100    pub async fn run(self) -> Result<()> {
101        match self {
102            RepoCommands::List { workspace, limit } => {
103                let client = BitbucketClient::from_stored()?;
104                let repos = client
105                    .list_repositories(&workspace, None, Some(limit))
106                    .await?;
107
108                if repos.values.is_empty() {
109                    println!("No repositories found in workspace '{}'", workspace);
110                    return Ok(());
111                }
112
113                let rows: Vec<RepoRow> = repos
114                    .values
115                    .iter()
116                    .map(|r| RepoRow {
117                        name: r.full_name.clone(),
118                        description: r
119                            .description
120                            .clone()
121                            .unwrap_or_default()
122                            .chars()
123                            .take(40)
124                            .collect::<String>(),
125                        private: if r.is_private.unwrap_or(false) {
126                            "Yes"
127                        } else {
128                            "No"
129                        }
130                        .to_string(),
131                        updated: r
132                            .updated_on
133                            .map(|d| d.format("%Y-%m-%d").to_string())
134                            .unwrap_or_default(),
135                    })
136                    .collect();
137
138                let table = Table::new(rows).to_string();
139                println!("{}", table);
140
141                if repos.next.is_some() {
142                    println!(
143                        "\n{} More repositories available. Use --limit to see more.",
144                        "ℹ".blue()
145                    );
146                }
147
148                Ok(())
149            }
150
151            RepoCommands::View { repo, web } => {
152                let (workspace, repo_slug) = parse_repo(&repo)?;
153                let client = BitbucketClient::from_stored()?;
154                let repository = client.get_repository(&workspace, &repo_slug).await?;
155
156                if web {
157                    if let Some(links) = &repository.links {
158                        if let Some(html) = &links.html {
159                            open::that(&html.href)?;
160                            println!("Opened {} in browser", html.href.cyan());
161                            return Ok(());
162                        }
163                    }
164                    anyhow::bail!("Could not find repository URL");
165                }
166
167                println!("{}", repository.full_name.bold());
168                println!("{}", "─".repeat(50));
169
170                if let Some(desc) = &repository.description {
171                    if !desc.is_empty() {
172                        println!("{}", desc);
173                        println!();
174                    }
175                }
176
177                println!(
178                    "{} {}",
179                    "Private:".dimmed(),
180                    if repository.is_private.unwrap_or(false) {
181                        "Yes"
182                    } else {
183                        "No"
184                    }
185                );
186                println!(
187                    "{} {}",
188                    "SCM:".dimmed(),
189                    repository.scm.as_deref().unwrap_or("unknown")
190                );
191
192                if let Some(lang) = &repository.language {
193                    if !lang.is_empty() {
194                        println!("{} {}", "Language:".dimmed(), lang);
195                    }
196                }
197
198                if let Some(branch) = &repository.mainbranch {
199                    println!("{} {}", "Main branch:".dimmed(), branch.name);
200                }
201
202                if let Some(size) = repository.size {
203                    let size_mb = size as f64 / (1024.0 * 1024.0);
204                    println!("{} {:.2} MB", "Size:".dimmed(), size_mb);
205                }
206
207                if let Some(created) = repository.created_on {
208                    println!("{} {}", "Created:".dimmed(), created.format("%Y-%m-%d"));
209                }
210
211                if let Some(updated) = repository.updated_on {
212                    println!("{} {}", "Updated:".dimmed(), updated.format("%Y-%m-%d"));
213                }
214
215                if let Some(links) = &repository.links {
216                    println!();
217                    if let Some(html) = &links.html {
218                        println!("{} {}", "Web:".dimmed(), html.href.cyan());
219                    }
220                    if let Some(clone_links) = &links.clone {
221                        for link in clone_links {
222                            println!("{} {} ({})", "Clone:".dimmed(), link.href, link.name);
223                        }
224                    }
225                }
226
227                Ok(())
228            }
229
230            RepoCommands::Clone { repo, dir } => {
231                let (workspace, repo_slug) = parse_repo(&repo)?;
232                let client = BitbucketClient::from_stored()?;
233                let repository = client.get_repository(&workspace, &repo_slug).await?;
234
235                let clone_url = repository
236                    .links
237                    .as_ref()
238                    .and_then(|l| l.clone.as_ref())
239                    .and_then(|links| links.iter().find(|l| l.name == "ssh" || l.name == "https"))
240                    .map(|l| &l.href)
241                    .context("Could not find clone URL")?;
242
243                let target_dir = dir.unwrap_or_else(|| repo_slug.clone());
244
245                println!("Cloning {} into {}...", repo.cyan(), target_dir);
246
247                let status = std::process::Command::new("git")
248                    .args(["clone", clone_url, &target_dir])
249                    .status()
250                    .context("Failed to run git clone")?;
251
252                if status.success() {
253                    println!("{} Successfully cloned repository", "✓".green());
254                } else {
255                    anyhow::bail!("git clone failed");
256                }
257
258                Ok(())
259            }
260
261            RepoCommands::Create {
262                workspace,
263                name,
264                description,
265                public,
266                project,
267            } => {
268                let client = BitbucketClient::from_stored()?;
269
270                let slug = name.to_lowercase().replace(' ', "-");
271
272                let request = CreateRepositoryRequest {
273                    scm: "git".to_string(),
274                    name: Some(name.clone()),
275                    description,
276                    is_private: Some(!public),
277                    project: project.map(|key| crate::models::ProjectKey { key }),
278                    ..Default::default()
279                };
280
281                let repository = client
282                    .create_repository(&workspace, &slug, &request)
283                    .await?;
284
285                println!(
286                    "{} Created repository {}",
287                    "✓".green(),
288                    repository.full_name.cyan()
289                );
290
291                if let Some(links) = &repository.links {
292                    if let Some(html) = &links.html {
293                        println!("{} {}", "URL:".dimmed(), html.href);
294                    }
295                }
296
297                Ok(())
298            }
299
300            RepoCommands::Fork {
301                repo,
302                workspace,
303                name,
304            } => {
305                let (src_workspace, src_repo) = parse_repo(&repo)?;
306                let client = BitbucketClient::from_stored()?;
307
308                let forked = client
309                    .fork_repository(
310                        &src_workspace,
311                        &src_repo,
312                        workspace.as_deref(),
313                        name.as_deref(),
314                    )
315                    .await?;
316
317                println!("{} Forked to {}", "✓".green(), forked.full_name.cyan());
318
319                Ok(())
320            }
321
322            RepoCommands::Delete { repo, yes } => {
323                let (workspace, repo_slug) = parse_repo(&repo)?;
324
325                if !yes {
326                    use dialoguer::Confirm;
327                    let confirmed = Confirm::new()
328                        .with_prompt(format!(
329                            "Are you sure you want to delete {}? This cannot be undone!",
330                            repo.red()
331                        ))
332                        .default(false)
333                        .interact()?;
334
335                    if !confirmed {
336                        println!("Aborted");
337                        return Ok(());
338                    }
339                }
340
341                let client = BitbucketClient::from_stored()?;
342                client.delete_repository(&workspace, &repo_slug).await?;
343
344                println!("{} Deleted repository {}", "✓".green(), repo);
345
346                Ok(())
347            }
348        }
349    }
350}
351
352fn parse_repo(repo: &str) -> Result<(String, String)> {
353    let parts: Vec<&str> = repo.split('/').collect();
354    if parts.len() != 2 {
355        anyhow::bail!(
356            "Invalid repository format. Expected 'workspace/repo-slug', got '{}'",
357            repo
358        );
359    }
360    Ok((parts[0].to_string(), parts[1].to_string()))
361}