diff --git a/README.md b/README.md index 4fc624d..9152940 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ For the full guide, see [Day-2 Operations](docs/day-2.md). | Jira | ✅ Available | Boards, issues, sprints (change lead time, cycle time) | API token (permissions from user account) | | Azure DevOps | 🔜 Coming soon | Repos, pipelines, deployments (DORA) | (TBD) | | GitLab | ✅ Available | Repos, MRs, pipelines, deployments (DORA) | `read_api`, `read_repository` | +| Bitbucket Cloud | ✅ Available | Repos, PRs, commits | Bitbucket username + app password | See [Token Handling](docs/token-handling.md) for env key names and multi-plugin `.devlake.env` examples. diff --git a/cmd/configure_scopes.go b/cmd/configure_scopes.go index b5b76fa..90645da 100644 --- a/cmd/configure_scopes.go +++ b/cmd/configure_scopes.go @@ -799,3 +799,225 @@ func scopeJiraHandler(client *devlake.Client, connID int, org, enterprise string Scopes: blueprintScopes, }, nil } + +// scopeBitbucketHandler is the ScopeHandler for the bitbucket plugin. +// It resolves repositories via the DevLake remote-scope API and PUTs the selected +// repositories as scopes on the connection. +func scopeBitbucketHandler(client *devlake.Client, connID int, org, enterprise string, opts *ScopeOpts) (*devlake.BlueprintConnection, error) { + repos, err := resolveBitbucketRepos(client, connID, org, opts) + if err != nil { + return nil, fmt.Errorf("resolving Bitbucket repositories: %w", err) + } + if len(repos) == 0 { + return nil, fmt.Errorf("at least one Bitbucket repository is required") + } + + fmt.Println("\n📝 Adding Bitbucket repository scopes...") + if err := putBitbucketScopes(client, connID, repos); err != nil { + return nil, fmt.Errorf("failed to add Bitbucket repository scopes: %w", err) + } + fmt.Printf(" ✅ Added %d Bitbucket repository scope(s)\n", len(repos)) + + var bpScopes []devlake.BlueprintScope + for _, r := range repos { + bpScopes = append(bpScopes, devlake.BlueprintScope{ + ScopeID: r.BitbucketID, + ScopeName: r.FullName, + }) + } + return &devlake.BlueprintConnection{ + PluginName: "bitbucket", + ConnectionID: connID, + Scopes: bpScopes, + }, nil +} + +// resolveBitbucketRepos determines which Bitbucket repositories to scope via flags or +// interactive browsing of the DevLake remote-scope hierarchy. +func resolveBitbucketRepos(client *devlake.Client, connID int, workspace string, opts *ScopeOpts) ([]*devlake.BitbucketRepoScope, error) { + fmt.Println("\n📦 Resolving Bitbucket repositories...") + if opts != nil && opts.Repos != "" { + var slugs []string + for _, s := range strings.Split(opts.Repos, ",") { + if s = strings.TrimSpace(s); s != "" { + slugs = append(slugs, s) + } + } + return searchBitbucketReposBySlugs(client, connID, slugs) + } + if opts != nil && opts.ReposFile != "" { + slugs, err := repofile.Parse(opts.ReposFile) + if err != nil { + return nil, fmt.Errorf("failed to read repos file: %w", err) + } + fmt.Printf(" Loaded %d repository slug(s) from file\n", len(slugs)) + return searchBitbucketReposBySlugs(client, connID, slugs) + } + return browseBitbucketReposInteractively(client, connID, workspace) +} + +// searchBitbucketReposBySlugs looks up Bitbucket repositories by their full name +// (workspace/repo-slug) using the DevLake search-remote-scopes API. +func searchBitbucketReposBySlugs(client *devlake.Client, connID int, slugs []string) ([]*devlake.BitbucketRepoScope, error) { + var repos []*devlake.BitbucketRepoScope + for _, slug := range slugs { + fmt.Printf("\n🔍 Searching for Bitbucket repository %q...\n", slug) + resp, err := client.SearchRemoteScopes("bitbucket", connID, slug, 1, 20) + if err != nil { + return nil, fmt.Errorf("searching for Bitbucket repository %q: %w", slug, err) + } + var found *devlake.BitbucketRepoScope + for i := range resp.Children { + child := &resp.Children[i] + if child.Type != "scope" { + continue + } + r := parseBitbucketRepo(child) + if r == nil { + continue + } + if r.BitbucketID == slug || r.FullName == slug || r.Name == slug { + found = r + break + } + if found == nil { + found = r // use first match if no exact match + } + } + if found == nil { + return nil, fmt.Errorf("Bitbucket repository %q not found", slug) + } + found.ConnectionID = connID + repos = append(repos, found) + fmt.Printf(" Found: %s\n", found.FullName) + } + return repos, nil +} + +// browseBitbucketReposInteractively walks the workspace→repository hierarchy via the +// DevLake remote-scope API, prompting the user to select a workspace then repositories. +func browseBitbucketReposInteractively(client *devlake.Client, connID int, workspace string) ([]*devlake.BitbucketRepoScope, error) { + workspaceID := workspace + + if workspaceID == "" { + fmt.Println("\n🔍 Listing Bitbucket workspaces...") + resp, err := client.ListRemoteScopes("bitbucket", connID, "", "") + if err != nil { + return nil, fmt.Errorf("listing Bitbucket workspaces: %w", err) + } + allWS := resp.Children + nextToken := resp.NextPageToken + for nextToken != "" { + page, err := client.ListRemoteScopes("bitbucket", connID, "", nextToken) + if err != nil { + break + } + allWS = append(allWS, page.Children...) + nextToken = page.NextPageToken + } + + var workspaceLabels []string + workspaceIDByLabel := make(map[string]string) + for _, child := range allWS { + if child.Type == "group" { + label := child.FullName + if label == "" { + label = child.Name + } + workspaceLabels = append(workspaceLabels, label) + workspaceIDByLabel[label] = child.ID + } + } + if len(workspaceLabels) == 0 { + return nil, fmt.Errorf("no Bitbucket workspaces found — verify your app password has repository read access") + } + + fmt.Println() + selected := prompt.Select("Select a Bitbucket workspace", workspaceLabels) + if selected == "" { + return nil, fmt.Errorf("no workspace selected") + } + workspaceID = workspaceIDByLabel[selected] + if workspaceID == "" { + workspaceID = selected + } + } + + fmt.Printf("\n🔍 Listing repositories in workspace %q...\n", workspaceID) + resp, err := client.ListRemoteScopes("bitbucket", connID, workspaceID, "") + if err != nil { + return nil, fmt.Errorf("listing repositories in workspace %q: %w", workspaceID, err) + } + allChildren := resp.Children + nextToken := resp.NextPageToken + for nextToken != "" { + page, err := client.ListRemoteScopes("bitbucket", connID, workspaceID, nextToken) + if err != nil { + break + } + allChildren = append(allChildren, page.Children...) + nextToken = page.NextPageToken + } + + var repoLabels []string + repoByLabel := make(map[string]*devlake.RemoteScopeChild) + for i := range allChildren { + child := &allChildren[i] + if child.Type != "scope" { + continue + } + label := child.FullName + if label == "" { + label = child.Name + } + repoLabels = append(repoLabels, label) + repoByLabel[label] = child + } + if len(repoLabels) == 0 { + return nil, fmt.Errorf("no repositories found in workspace %q", workspaceID) + } + + fmt.Println() + selectedLabels := prompt.SelectMulti("Select Bitbucket repositories to configure", repoLabels) + var repos []*devlake.BitbucketRepoScope + for _, label := range selectedLabels { + child, ok := repoByLabel[label] + if !ok { + continue + } + r := parseBitbucketRepo(child) + if r != nil { + r.ConnectionID = connID + repos = append(repos, r) + } + } + return repos, nil +} + +// parseBitbucketRepo extracts repository fields from a RemoteScopeChild's Data payload. +func parseBitbucketRepo(child *devlake.RemoteScopeChild) *devlake.BitbucketRepoScope { + var r devlake.BitbucketRepoScope + if err := json.Unmarshal(child.Data, &r); err != nil { + return nil + } + if r.FullName == "" { + r.FullName = child.FullName + } + if r.Name == "" { + r.Name = child.Name + } + // BitbucketID is the full name (workspace/repo-slug) + if r.BitbucketID == "" { + r.BitbucketID = r.FullName + } + return &r +} + +// putBitbucketScopes batch-upserts Bitbucket repository scopes on a connection. +func putBitbucketScopes(client *devlake.Client, connID int, repos []*devlake.BitbucketRepoScope) error { + var data []any + for _, r := range repos { + data = append(data, r) + } + return client.PutScopes("bitbucket", connID, &devlake.ScopeBatchRequest{Data: data}) +} diff --git a/cmd/connection_types.go b/cmd/connection_types.go index 034fe77..4058d03 100644 --- a/cmd/connection_types.go +++ b/cmd/connection_types.go @@ -273,6 +273,30 @@ var connectionRegistry = []*ConnectionDef{ {Name: "repos-file", Description: "Path to file with project paths (one per line)"}, }, }, + { + Plugin: "bitbucket", + DisplayName: "Bitbucket Cloud", + Available: true, + Endpoint: "https://api.bitbucket.org/2.0/", + SupportsTest: true, + AuthMethod: "BasicAuth", + NeedsUsername: true, + UsernamePrompt: "Bitbucket username", + UsernameEnvVars: []string{"BITBUCKET_USER", "BITBUCKET_USERNAME"}, + UsernameEnvFileKeys: []string{"BITBUCKET_USER", "BITBUCKET_USERNAME"}, + TokenPrompt: "Bitbucket app password", + EnvVarNames: []string{"BITBUCKET_TOKEN", "BITBUCKET_APP_PASSWORD"}, + EnvFileKeys: []string{"BITBUCKET_TOKEN", "BITBUCKET_APP_PASSWORD"}, + RequiredScopes: []string{}, + ScopeHint: "", + ScopeFunc: scopeBitbucketHandler, + ScopeIDField: "bitbucketId", + HasRepoScopes: true, + ScopeFlags: []FlagDef{ + {Name: "repos", Description: "Comma-separated Bitbucket repos (workspace/repo-slug)"}, + {Name: "repos-file", Description: "Path to file with repo slugs (one per line)"}, + }, + }, { Plugin: "azure-devops", DisplayName: "Azure DevOps", diff --git a/cmd/connection_types_test.go b/cmd/connection_types_test.go index 58452e8..e728372 100644 --- a/cmd/connection_types_test.go +++ b/cmd/connection_types_test.go @@ -556,6 +556,84 @@ func TestResolveUsername(t *testing.T) { }) } +// TestConnectionRegistry_Bitbucket verifies the Bitbucket Cloud plugin registry entry. +func TestConnectionRegistry_Bitbucket(t *testing.T) { + def := FindConnectionDef("bitbucket") + if def == nil { + t.Fatal("bitbucket connection def not found") + } + if !def.Available { + t.Errorf("bitbucket should be available") + } + if def.AuthMethod != "BasicAuth" { + t.Errorf("bitbucket AuthMethod = %q, want BasicAuth", def.AuthMethod) + } + if !def.NeedsUsername { + t.Errorf("bitbucket NeedsUsername should be true") + } + if def.ScopeIDField != "bitbucketId" { + t.Errorf("bitbucket ScopeIDField = %q, want %q", def.ScopeIDField, "bitbucketId") + } + if !def.HasRepoScopes { + t.Errorf("bitbucket HasRepoScopes should be true") + } + if def.ScopeFunc == nil { + t.Errorf("bitbucket ScopeFunc should be set") + } + wantEnvVars := []string{"BITBUCKET_TOKEN", "BITBUCKET_APP_PASSWORD"} + if len(def.EnvVarNames) != len(wantEnvVars) { + t.Errorf("bitbucket EnvVarNames length: got %d, want %d", len(def.EnvVarNames), len(wantEnvVars)) + } else { + for i, v := range wantEnvVars { + if def.EnvVarNames[i] != v { + t.Errorf("bitbucket EnvVarNames[%d]: got %q, want %q", i, def.EnvVarNames[i], v) + } + } + } + wantUserEnvVars := []string{"BITBUCKET_USER", "BITBUCKET_USERNAME"} + if len(def.UsernameEnvVars) != len(wantUserEnvVars) { + t.Errorf("bitbucket UsernameEnvVars length: got %d, want %d", len(def.UsernameEnvVars), len(wantUserEnvVars)) + } else { + for i, v := range wantUserEnvVars { + if def.UsernameEnvVars[i] != v { + t.Errorf("bitbucket UsernameEnvVars[%d]: got %q, want %q", i, def.UsernameEnvVars[i], v) + } + } + } + + // ScopeFlags: repos and repos-file flags must be registered + foundRepos, foundReposFile := false, false + for _, f := range def.ScopeFlags { + switch f.Name { + case "repos": + foundRepos = true + case "repos-file": + foundReposFile = true + } + } + if !foundRepos { + t.Errorf("bitbucket ScopeFlags should include repos flag") + } + if !foundReposFile { + t.Errorf("bitbucket ScopeFlags should include repos-file flag") + } + + // BasicAuth: BuildCreateRequest puts credentials into username/password, not token + req := def.BuildCreateRequest("test-conn", ConnectionParams{ + Token: "app-password", + Username: "myuser", + }) + if req.Username != "myuser" { + t.Errorf("bitbucket create request Username = %q, want %q", req.Username, "myuser") + } + if req.Password != "app-password" { + t.Errorf("bitbucket create request Password = %q, want %q", req.Password, "app-password") + } + if req.Token != "" { + t.Errorf("bitbucket create request Token should be empty for BasicAuth, got %q", req.Token) + } +} + func TestConnectionRegistry_Jenkins(t *testing.T) { def := FindConnectionDef("jenkins") if def == nil { diff --git a/internal/devlake/types.go b/internal/devlake/types.go index 6e889c3..f8a80f9 100644 --- a/internal/devlake/types.go +++ b/internal/devlake/types.go @@ -69,6 +69,18 @@ type JiraBoardScope struct { Name string `json:"name"` } +// BitbucketRepoScope represents a Bitbucket Cloud repository scope entry for PUT /scopes. +// BitbucketID holds the repository full name (workspace/repo-slug), which is the +// canonical scope identifier used by the DevLake Bitbucket plugin. +type BitbucketRepoScope struct { + BitbucketID string `json:"bitbucketId"` + ConnectionID int `json:"connectionId"` + Name string `json:"name"` + FullName string `json:"fullName"` + CloneURL string `json:"cloneUrl,omitempty"` + HTMLURL string `json:"htmlUrl,omitempty"` +} + // ScopeBatchRequest is the payload for PUT /scopes (batch upsert). type ScopeBatchRequest struct { Data []any `json:"data"`