Conversation
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds Bitbucket Cloud as a fully supported plugin in the gh-devlake CLI, following the same patterns established for GitLab, Jenkins, and Jira. It enables connection creation with BasicAuth (username + app password), workspace→repository interactive scope browsing, flag-driven scope addition, and blueprint integration.
Changes:
- Added
BitbucketRepoScopestruct tointernal/devlake/types.gofor the DevLake Bitbucket plugin scope payload - Added
bitbucketentry toconnectionRegistryincmd/connection_types.gowith BasicAuth, env var resolution, and scope flags - Added
scopeBitbucketHandlerand supporting functions (resolveBitbucketRepos,searchBitbucketReposBySlugs,browseBitbucketReposInteractively,parseBitbucketRepo,putBitbucketScopes) incmd/configure_scopes.go
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
internal/devlake/types.go |
Adds BitbucketRepoScope type with bitbucketId, connectionId, name, fullName, and optional URL fields |
cmd/connection_types.go |
Adds the Bitbucket ConnectionDef registry entry with BasicAuth, credential env vars, endpoint, and scope flags |
cmd/configure_scopes.go |
Implements the full Bitbucket scope handler: flag-driven slug lookup, interactive workspace→repo picker, parseBitbucketRepo helper, and putBitbucketScopes batch upsert |
cmd/configure_scopes.go
Outdated
| if r == nil { | ||
| continue | ||
| } | ||
| if r.FullName == slug || r.Name == slug { |
There was a problem hiding this comment.
The searchBitbucketReposBySlugs exact-match check compares r.FullName == slug || r.Name == slug (line 879), but never checks r.BitbucketID == slug. Users pass slugs in workspace/repo-slug format, which is the canonical bitbucketId. When the DevLake Bitbucket plugin returns a scope where bitbucketId is already set to workspace/repo-slug but fullName is populated with a display name (e.g., the repo's human-readable title rather than its slug path), the lookup will silently fall back to the first result instead of finding the exact match. The match condition should include r.BitbucketID == slug so that the canonical scope identifier is always tried as an exact-match criterion.
| if r.FullName == slug || r.Name == slug { | |
| if r.BitbucketID == slug || r.FullName == slug || r.Name == slug { |
| { | ||
| 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)"}, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
There is no test for the Bitbucket plugin registry entry in connection_types_test.go. Every other plugin that has been added — GitHub (TestGitLabRegistryEntry), Jenkins (TestConnectionRegistry_Jenkins), Jira (TestJiraConnectionDef) — has a corresponding registry-entry test that verifies Plugin, AuthMethod, NeedsUsername, ScopeIDField, HasRepoScopes, ScopeFunc, EnvVarNames, EnvFileKeys, etc. A TestConnectionRegistry_Bitbucket test covering these fields should be added to match the project's testing conventions.
|
@copilot Please fix:
|
…cket Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Both fixes applied in 36523ce:
|
| // 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) |
There was a problem hiding this comment.
The plugin name "bitbucket" is hardcoded in API calls (and also appears elsewhere in this new handler, e.g. ListRemoteScopes, PutScopes, and BlueprintConnection.PluginName). This duplicates a key routing string in multiple places and makes future refactors/error prevention harder. Consider defining a single local constant (e.g. const plugin = "bitbucket") and using it throughout these helper functions (including the returned BlueprintConnection) so the plugin identifier is sourced from one place.
| // 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) |
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
cmd/configure_scopes.go
Outdated
| 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, err |
There was a problem hiding this comment.
The error returned from resolveBitbucketRepos is propagated without additional context at this top-level handler boundary. Wrapping it here (e.g., "resolving Bitbucket repositories: %w") would make failures easier to diagnose when surfaced from higher-level calls.
| return nil, err | |
| return nil, fmt.Errorf("resolving Bitbucket repositories: %w", err) |
|
@copilot Please fix: Wrap the error from |
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Fixed in acdd607 — the error from |
| // 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) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
The TestConnectionRegistry_Bitbucket test does not verify the ScopeFlags field. The comparable TestConnectionRegistry_Jenkins test (starting at line 620 in the same file) checks that the jobs scope flag is registered. For consistency, the Bitbucket test should verify that at least repos and repos-file scope flags are present, since these are the primary mechanism for flag-mode scoping and could silently be omitted or misspelled in the registry entry.
|
@copilot Please fix: Add ScopeFlags verification to |
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Added in fa10e5a — |
| { | ||
| 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)"}, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
The "Supported Plugins" table in README.md (around line 191-198) lists GitHub, GitHub Copilot, Jenkins, Jira, Azure DevOps, and GitLab — but does not include Bitbucket Cloud, which is newly added as an available plugin in this PR. The table should include a row for Bitbucket Cloud showing its status (✅ Available), what it collects, and the required credentials (Bitbucket username + app password).
|
@copilot Please fix: Add a Bitbucket Cloud row to the README Supported Plugins table. It should show status as available, note that it collects repository/PR data, and requires Bitbucket username + app password. |
Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Added in 1b776eb — Bitbucket Cloud now appears in the Supported Plugins table as ✅ Available, collecting repos/PRs/commits, with Bitbucket username + app password as the required credentials. |
|
@copilot Please fix: Add pagination to |
…stings Co-authored-by: ewega <26189114+ewega@users.noreply.github.com>
Fixed in 46a395b — |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
cmd/configure_scopes.go:914
- The pagination loops in
browseBitbucketReposInteractivelysilently swallow errors by usingbreakwhenListRemoteScopesfails during paginated fetches. This means the user may be shown only a partial list of workspaces or repositories without any indication that data is missing. The established codebase pattern (used inscopeJiraHandler, lines 726–731) is to propagate the pagination error with a formatted message (e.g.,return nil, fmt.Errorf("failed to list Bitbucket workspaces (page token %s): %w", nextToken, err)). The same applies to the repository pagination loop at lines 953–960.
This divergence from the Jira handler pattern means a transient or persistent API failure during pagination would silently truncate the list, potentially causing the user to inadvertently skip repositories or workspaces they wanted to select.
for nextToken != "" {
page, err := client.ListRemoteScopes("bitbucket", connID, "", nextToken)
if err != nil {
break
}
| for nextToken != "" { | ||
| page, err := client.ListRemoteScopes("bitbucket", connID, workspaceID, nextToken) | ||
| if err != nil { | ||
| break | ||
| } |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
The resolveBitbucketRepos function's flag-based and file-based paths (lines 839–854) are not covered by any tests, even though the analogous GitHub (TestResolveRepos_WithReposFlag, TestResolveRepos_WithReposFile) and Jenkins (TestResolveJenkinsJobs_WithJobsFlag) resolver functions are thoroughly tested in configure_scopes_test.go. Adding tests for the Repos comma-separated flag path and the ReposFile path would be consistent with the established codebase convention.
| | 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. |
There was a problem hiding this comment.
The README at line 201 directs users to docs/token-handling.md for env key names and multi-plugin .devlake.env examples, 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.
| | 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. | |
| | Bitbucket Cloud | ✅ Available | Repos, PRs, commits | Bitbucket username (`BITBUCKET_USERNAME`/`BITBUCKET_USER`) + app password (`BITBUCKET_APP_PASSWORD`/`BITBUCKET_TOKEN`) | | |
| See [Token Handling](docs/token-handling.md) for common token patterns, env key names for most plugins, and multi-plugin `.devlake.env` examples. Bitbucket Cloud and Jenkins env variable names are documented in the table above. |
BitbucketRepoScopetype tointernal/devlake/types.gobitbucketConnectionDeftoconnectionRegistryincmd/connection_types.goscopeBitbucketHandler(and helpers) incmd/configure_scopes.gor.BitbucketID == slugto exact-match condition insearchBitbucketReposBySlugsTestConnectionRegistry_Bitbuckettest incmd/connection_types_test.goresolveBitbucketReposerror with context:fmt.Errorf("resolving Bitbucket repositories: %w", err)reposandrepos-fileScopeFlags verification toTestConnectionRegistry_BitbucketbrowseBitbucketReposInteractively(workspace list + repo list) usingNextPageTokenloop, matching Jira handler patterngo build ./...,go vet ./...,go test ./...all pass🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.