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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +199 to 201
Copy link

Copilot AI Mar 6, 2026

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.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.

Suggested change
| 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.

Copilot uses AI. Check for mistakes.

Expand Down
222 changes: 222 additions & 0 deletions cmd/configure_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
}

// 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
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
// 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 uses AI. Check for mistakes.
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
}
}
Comment on lines +879 to +886
Copy link

Copilot AI Mar 5, 2026

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 uses AI. Check for mistakes.
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
}
Comment on lines +953 to +957
Copy link

Copilot AI Mar 6, 2026

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.

Copilot uses AI. Check for mistakes.
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})
}
24 changes: 24 additions & 0 deletions cmd/connection_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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 generated this review using guidance from repository custom instructions.
Comment on lines +276 to +299
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

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 uses AI. Check for mistakes.
{
Plugin: "azure-devops",
DisplayName: "Azure DevOps",
Expand Down
78 changes: 78 additions & 0 deletions cmd/connection_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

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 generated this review using guidance from repository custom instructions.

func TestConnectionRegistry_Jenkins(t *testing.T) {
def := FindConnectionDef("jenkins")
if def == nil {
Expand Down
12 changes: 12 additions & 0 deletions internal/devlake/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down