From 5858ad80ccb85f8cc7bfec9360002e19c0280fa7 Mon Sep 17 00:00:00 2001 From: Haris Khan Date: Fri, 10 Apr 2026 11:14:15 -0400 Subject: [PATCH] feat(repo): #54 add --details flag to repo list and repo move command repo list --details fetches last commit date and open PR count concurrently for each repo. repo move relocates a repo to a different project with an optional name prefix (e.g. --prefix "Archived-"). --- cmd/repo/list.go | 70 +++++++++++++++++++++++ cmd/repo/move.go | 113 +++++++++++++++++++++++++++++++++++++ cmd/repo/repo.go | 1 + docs/bb-skill.md | 13 ++++- docs/commands.md | 30 ++++++++++ internal/api/pagination.go | 25 +++++++- 6 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 cmd/repo/move.go diff --git a/cmd/repo/list.go b/cmd/repo/list.go index 0fb4ece..1701acd 100644 --- a/cmd/repo/list.go +++ b/cmd/repo/list.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "sync" "github.com/tyrantkhan/bb/internal/api" "github.com/tyrantkhan/bb/internal/cmdutil" @@ -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", @@ -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) @@ -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), + } + } + return output.Format(format, repos, headers, rows) + } headers := []string{"Name", "Project", "Slug", "Visibility", "Language", "Updated"} rows := make([][]string, len(repos)) diff --git a/cmd/repo/move.go b/cmd/repo/move.go new file mode 100644 index 0000000..9833f15 --- /dev/null +++ b/cmd/repo/move.go @@ -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: "", + 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 + }, + } +} diff --git a/cmd/repo/repo.go b/cmd/repo/repo.go index 0a53a6b..d18115f 100644 --- a/cmd/repo/repo.go +++ b/cmd/repo/repo.go @@ -16,6 +16,7 @@ func NewCmdRepo() *cli.Command { newCmdView(), newCmdClone(), newCmdCreate(), + newCmdMove(), }, } } diff --git a/docs/bb-skill.md b/docs/bb-skill.md index 948a355..e7c0ef7 100644 --- a/docs/bb-skill.md +++ b/docs/bb-skill.md @@ -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 *) --- @@ -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 @@ -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 ` | Clone a repository | +| `bb repo move --project KEY` | Move a repo to a different project (`--prefix` to rename) | ### Pull Requests @@ -114,6 +119,12 @@ bb pipeline stop {uuid} # stop running pipeline |---|---| | `bb search code ` | Search for code across repos (`--repo`, `--extension`, `--language`, `--path`) | +### Workspaces + +| Command | Description | +|---|---| +| `bb workspace members` | List workspace members (`--workspace`, `--limit`, `--format`) | + ### Pipelines | Command | Description | diff --git a/docs/commands.md b/docs/commands.md index f0a25f4..7467fad 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -70,6 +70,7 @@ 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 ``` @@ -77,6 +78,7 @@ bb repo list --format json |---|---| | `--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]` @@ -104,6 +106,20 @@ bb repo create --name myrepo --private --project PROJ | `--private` | Make private (default: true) | | `--project` | Project key | +### `bb repo move ` + +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 [directory]` Clone a repository. @@ -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` diff --git a/internal/api/pagination.go b/internal/api/pagination.go index 6af0b24..0424c15 100644 --- a/internal/api/pagination.go +++ b/internal/api/pagination.go @@ -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. @@ -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 +}