Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions cmd/repo/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/url"
"sync"

"github.com/tyrantkhan/bb/internal/api"
"github.com/tyrantkhan/bb/internal/cmdutil"
Expand All @@ -12,6 +13,29 @@ import (
"github.com/urfave/cli/v3"
)

type repoDetails struct {
LastCommit string
OpenPRs int
}

func fetchRepoDetails(client *api.Client, workspace, slug string) repoDetails {
var d repoDetails

// Last commit on default branch.
commits, err := api.Paginate[models.Commit](client, fmt.Sprintf("/2.0/repositories/%s/%s/commits", workspace, slug), 1)
if err == nil && len(commits) > 0 {
d.LastCommit = commits[0].Date
}

// Open PR count.
count, err := api.Count(client, fmt.Sprintf("/2.0/repositories/%s/%s/pullrequests?state=OPEN", workspace, slug))
if err == nil {
d.OpenPRs = count
}

return d
}

func newCmdList() *cli.Command {
return &cli.Command{
Name: "list",
Expand All @@ -29,6 +53,10 @@ func newCmdList() *cli.Command {
Name: "exclude-project",
Usage: "Exclude repos in this project",
},
&cli.BoolFlag{
Name: "details",
Usage: "Include last commit date and open PR count (slower)",
},
},
Action: cmdutil.NoArgs(func(ctx context.Context, cmd *cli.Command) error {
f := cmdutil.GetFactory(ctx)
Expand Down Expand Up @@ -67,6 +95,48 @@ func newCmdList() *cli.Command {
}

format := cmdutil.GetFormat(ctx, cmd)
details := cmd.Bool("details")

if details {
// Fetch details concurrently with bounded parallelism.
detailsMap := make([]repoDetails, len(repos))
var wg sync.WaitGroup
sem := make(chan struct{}, 10)

for i, r := range repos {
wg.Add(1)
go func(idx int, slug string) {
defer wg.Done()
sem <- struct{}{}
detailsMap[idx] = fetchRepoDetails(client, workspace, slug)
<-sem
}(i, r.Slug)
}
wg.Wait()

headers := []string{"Name", "Project", "Slug", "Language", "Last Commit", "Open PRs", "Updated"}
rows := make([][]string, len(repos))
for i, r := range repos {
projectKey := ""
if r.Project != nil {
projectKey = r.Project.Key
}
lastCommit := "-"
if detailsMap[i].LastCommit != "" {
lastCommit = models.FormatTime(detailsMap[i].LastCommit)
}
rows[i] = []string{
r.Name,
projectKey,
r.Slug,
r.Language,
lastCommit,
fmt.Sprintf("%d", detailsMap[i].OpenPRs),
models.FormatTime(r.UpdatedOn),
}
}
Comment on lines +117 to +137
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Visibility column is missing in the detailed view, which creates an inconsistency with the default list view. It's recommended to include it to maintain a consistent user experience across both views.

				headers := []string{"Name", "Project", "Slug", "Visibility", "Language", "Last Commit", "Open PRs", "Updated"}
				rows := make([][]string, len(repos))
				for i, r := range repos {
					projectKey := ""
					if r.Project != nil {
						projectKey = r.Project.Key
					}
					lastCommit := "-"
					if detailsMap[i].LastCommit != "" {
						lastCommit = models.FormatTime(detailsMap[i].LastCommit)
					}
					rows[i] = []string{
						r.Name,
						projectKey,
						r.Slug,
						r.Visibility(),
						r.Language,
						lastCommit,
						fmt.Sprintf("%d", detailsMap[i].OpenPRs),
						models.FormatTime(r.UpdatedOn),
					}
				}

return output.Format(format, repos, headers, rows)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When the --details flag is used with --format json, the output currently only contains the standard repository data. The extra details (last commit and open PR count) are not included in the JSON response because repos (a slice of models.Repository) does not contain these fields. To provide a complete JSON response, consider defining a temporary struct that embeds models.Repository and adds the new fields, then populating and returning that instead.

}

headers := []string{"Name", "Project", "Slug", "Visibility", "Language", "Updated"}
rows := make([][]string, len(repos))
Expand Down
113 changes: 113 additions & 0 deletions cmd/repo/move.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package repo

import (
"context"
"fmt"
"strings"

"github.com/tyrantkhan/bb/internal/api"
"github.com/tyrantkhan/bb/internal/cmdutil"
"github.com/tyrantkhan/bb/internal/models"
"github.com/tyrantkhan/bb/internal/output"
"github.com/urfave/cli/v3"
)

func newCmdMove() *cli.Command {
return &cli.Command{
Name: "move",
Usage: "Move a repository to a different project",
ArgsUsage: "<slug>",
Flags: []cli.Flag{
cmdutil.WorkspaceFlag,
cmdutil.FormatFlag,
&cli.StringFlag{
Name: "project",
Usage: "Destination project key",
},
&cli.StringFlag{
Name: "prefix",
Usage: "Prefix to add to the repo name (e.g. \"Archived-\")",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
f := cmdutil.GetFactory(ctx)
client, err := f.APIClient()
if err != nil {
return err
}

workspace, err := cmdutil.ResolveWorkspace(ctx, cmd)
if err != nil {
return err
}

slug := cmd.Args().First()
if slug == "" {
return fmt.Errorf("repository slug is required")
}

if err := api.ValidateSlug("repo", slug); err != nil {
return err
}

projectKey := cmd.String("project")
if projectKey == "" {
return fmt.Errorf("--project flag is required")
}

// Fetch current repo to get existing name.
path := fmt.Sprintf("/2.0/repositories/%s/%s", workspace, slug)
resp, err := client.Get(path)
if err != nil {
return err
}

var repo models.Repository
if err := api.DecodeJSON(resp, &repo); err != nil {
return fmt.Errorf("failed to decode repository: %w", err)
}

body := map[string]interface{}{
"project": map[string]string{
"key": projectKey,
},
}

// Add prefix to name if specified and not already present.
prefix := cmd.String("prefix")
newName := repo.Name
if prefix != "" && !strings.HasPrefix(repo.Name, prefix) {
newName = prefix + repo.Name
body["name"] = newName
}

resp, err = client.Put(path, body)
if err != nil {
return err
}

var updated models.Repository
if err := api.DecodeJSON(resp, &updated); err != nil {
return fmt.Errorf("failed to decode updated repository: %w", err)
}

format := cmdutil.GetFormat(ctx, cmd)
if format == "json" {
return output.RenderJSON(updated)
}

fmt.Fprintln(f.IOOut, output.Success.Render(
fmt.Sprintf("Repository moved: %s → project %s", updated.FullName, projectKey),
))
if newName != repo.Name {
fmt.Fprintf(f.IOOut, "%s %s → %s\n", output.Bold.Render("Renamed:"), repo.Name, updated.Name)
}
fmt.Fprintf(f.IOOut, "%s %s\n", output.Bold.Render("Slug:"), updated.Slug)
if updated.Project != nil {
fmt.Fprintf(f.IOOut, "%s %s (%s)\n", output.Bold.Render("Project:"), updated.Project.Name, updated.Project.Key)
}

return nil
},
}
}
1 change: 1 addition & 0 deletions cmd/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func NewCmdRepo() *cli.Command {
newCmdView(),
newCmdClone(),
newCmdCreate(),
newCmdMove(),
},
}
}
13 changes: 12 additions & 1 deletion docs/bb-skill.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: bb
version: 1.1.0
version: 1.2.0
description: Use the bb CLI to interact with Bitbucket Cloud — manage PRs, repos, and pipelines. Use when the user asks about Bitbucket pull requests, repositories, pipelines, or wants to perform Bitbucket operations.
allowed-tools: Bash(bb *)
---
Expand Down Expand Up @@ -35,9 +35,12 @@ bb pr edit 42 --title "New title" # edit PR fields
bb repo list # list repos in workspace
bb repo list --project PROJ # filter by project
bb repo list --exclude-project PROJ # exclude a project
bb repo list --details # include last commit date and open PR count
bb repo view myrepo # view details
bb repo create --name myrepo --private
bb repo clone myrepo --protocol ssh
bb repo move myrepo --project AR # move repo to a different project
bb repo move myrepo --project AR --prefix "Archived-" # move and add prefix
```

### Search
Expand Down Expand Up @@ -86,9 +89,11 @@ bb pipeline stop {uuid} # stop running pipeline
| Command | Description |
|---|---|
| `bb repo list` | List repositories in a workspace |
| `bb repo list --details` | Include last commit date and open PR count (slower) |
| `bb repo view [slug]` | View repository details |
| `bb repo create` | Create a new repository |
| `bb repo clone <slug>` | Clone a repository |
| `bb repo move <slug> --project KEY` | Move a repo to a different project (`--prefix` to rename) |

### Pull Requests

Expand All @@ -114,6 +119,12 @@ bb pipeline stop {uuid} # stop running pipeline
|---|---|
| `bb search code <query>` | Search for code across repos (`--repo`, `--extension`, `--language`, `--path`) |

### Workspaces

| Command | Description |
|---|---|
| `bb workspace members` | List workspace members (`--workspace`, `--limit`, `--format`) |
Comment on lines +122 to +126
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Documentation for bb workspace members is being added, but the implementation of this command is not present in this pull request. This could lead to user confusion if they attempt to use a command that hasn't been implemented yet. Please ensure the command is included in this PR or remove these documentation changes.


### Pipelines

| Command | Description |
Expand Down
30 changes: 30 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,15 @@ bb repo list
bb repo list -w myworkspace --limit 50
bb repo list --project PROJ
bb repo list --exclude-project PROJ
bb repo list --details
bb repo list --format json
```

| Flag | Description |
|---|---|
| `--project`, `-p` | Filter by project key |
| `--exclude-project` | Exclude repos in this project |
| `--details` | Include last commit date and open PR count (slower) |

### `bb repo view [slug]`

Expand Down Expand Up @@ -104,6 +106,20 @@ bb repo create --name myrepo --private --project PROJ
| `--private` | Make private (default: true) |
| `--project` | Project key |

### `bb repo move <slug>`

Move a repository to a different project. Optionally add a prefix to the repo name.

```sh
bb repo move myrepo --project AR
bb repo move myrepo --project AR --prefix "Archived-"
```

| Flag | Description |
|---|---|
| `--project` | Destination project key (required) |
| `--prefix` | Prefix to add to the repo name |

### `bb repo clone <slug> [directory]`

Clone a repository.
Expand All @@ -120,6 +136,20 @@ bb repo clone myrepo --protocol ssh

---

## Workspaces

### `bb workspace members`

List members of a workspace.

```sh
bb workspace members
bb workspace members -w myworkspace --limit 50
bb workspace members --format json
```

---

## Pull Requests

### `bb pr list`
Expand Down
25 changes: 24 additions & 1 deletion internal/api/pagination.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package api

import "encoding/json"
import (
"encoding/json"
"strings"
)

// Paginate fetches all pages of a paginated endpoint up to the given limit.
// If limit <= 0, all pages are fetched.
Expand Down Expand Up @@ -42,3 +45,23 @@ func Paginate[T any](client *Client, path string, limit int) ([]T, error) {
func PaginateRaw(client *Client, path string, limit int) ([]json.RawMessage, error) {
return Paginate[json.RawMessage](client, path, limit)
}

// Count fetches just the total size from a paginated endpoint without retrieving all items.
func Count(client *Client, path string) (int, error) {
// Request minimal page size since we only need the total count.
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
resp, err := client.Get(path + sep + "pagelen=0") //nolint:bodyclose // closed by DecodeJSON
if err != nil {
return 0, err
}

var page PaginatedResponse[json.RawMessage]
if err := DecodeJSON(resp, &page); err != nil {
return 0, err
}

return page.Size, nil
}
Loading