-
Notifications
You must be signed in to change notification settings - Fork 3
feat: Add Bitbucket Cloud plugin support #129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c529559
edd3373
36523ce
acdd607
fa10e5a
1b776eb
46a395b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+837
to
+856
|
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // 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) | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+859
to
+865
|
||||||||||||||||||||||||||||||||||
| // 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) | |
| const bitbucketPlugin = "bitbucket" | |
| // 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(bitbucketPlugin, connID, slug, 1, 20) |
Copilot
AI
Mar 5, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Falling back to “first match if no exact match” can silently select the wrong repository when the search query is ambiguous (e.g., a plain repo slug that exists in multiple workspaces). A safer approach is to (a) require workspace/repo-slug for non-interactive selection, or (b) if only a slug is provided and a workspace is known, normalize it into workspace/slug, and (c) if multiple matches remain, return an error listing candidates instead of picking the first.
Copilot
AI
Mar 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The same silent-break pagination issue applies to the repository listing loop: when ListRemoteScopes fails while fetching subsequent pages of repositories in a workspace, the error is silently discarded and the user sees only a truncated list. Following the scopeJiraHandler pattern (lines 726–731), this should return an error rather than break silently.
This issue also appears on line 910 of the same file.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)"}, | ||
| }, | ||
| }, | ||
|
Comment on lines
+276
to
+299
|
||
| { | ||
| Plugin: "azure-devops", | ||
| DisplayName: "Azure DevOps", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
| } | ||
|
Comment on lines
+559
to
+635
|
||
|
|
||
| func TestConnectionRegistry_Jenkins(t *testing.T) { | ||
| def := FindConnectionDef("jenkins") | ||
| if def == nil { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The README at line 201 directs users to
docs/token-handling.mdfor env key names and multi-plugin.devlake.envexamples, but that file does not include Bitbucket Cloud env key information (BITBUCKET_TOKEN,BITBUCKET_APP_PASSWORD,BITBUCKET_USER,BITBUCKET_USERNAME). The token-handling doc still labels GitLab as "coming soon" and has no entry for Bitbucket or Jenkins, even though all three are now available. Users following the README's pointer to that doc will not find the Bitbucket env key names they need.