Skip to content

Commit 4311c13

Browse files
authored
Add robust Bitbucket Cloud scope handling and coverage (#138)
* Initial plan * feat: improve bitbucket scope handling * fix: doc and test updates for bitbucket review --------- Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>
1 parent da53593 commit 4311c13

3 files changed

Lines changed: 110 additions & 19 deletions

File tree

cmd/configure_scopes.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,7 +1088,7 @@ func browseBitbucketReposInteractively(client *devlake.Client, connID int, works
10881088
for nextToken != "" {
10891089
page, err := client.ListRemoteScopes("bitbucket", connID, "", nextToken)
10901090
if err != nil {
1091-
break
1091+
return nil, fmt.Errorf("listing Bitbucket workspaces (page token %s): %w", nextToken, err)
10921092
}
10931093
allWS = append(allWS, page.Children...)
10941094
nextToken = page.NextPageToken
@@ -1131,7 +1131,7 @@ func browseBitbucketReposInteractively(client *devlake.Client, connID int, works
11311131
for nextToken != "" {
11321132
page, err := client.ListRemoteScopes("bitbucket", connID, workspaceID, nextToken)
11331133
if err != nil {
1134-
break
1134+
return nil, fmt.Errorf("listing repositories in workspace %q (page token %s): %w", workspaceID, nextToken, err)
11351135
}
11361136
allChildren = append(allChildren, page.Children...)
11371137
nextToken = page.NextPageToken
@@ -1175,8 +1175,11 @@ func browseBitbucketReposInteractively(client *devlake.Client, connID int, works
11751175
// parseBitbucketRepo extracts repository fields from a RemoteScopeChild's Data payload.
11761176
func parseBitbucketRepo(child *devlake.RemoteScopeChild) *devlake.BitbucketRepoScope {
11771177
var r devlake.BitbucketRepoScope
1178-
if err := json.Unmarshal(child.Data, &r); err != nil {
1179-
return nil
1178+
if len(child.Data) > 0 {
1179+
if err := json.Unmarshal(child.Data, &r); err != nil {
1180+
// Treat missing/invalid payload as empty to fall back to child fields.
1181+
r = devlake.BitbucketRepoScope{}
1182+
}
11801183
}
11811184
if r.FullName == "" {
11821185
r.FullName = child.FullName

cmd/configure_scopes_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,85 @@ func TestAzureDevOpsScopePayload_KeepsExistingFields(t *testing.T) {
125125
}
126126
}
127127

128+
func TestParseBitbucketRepo(t *testing.T) {
129+
t.Run("uses payload fields when present", func(t *testing.T) {
130+
data, _ := json.Marshal(map[string]any{
131+
"bitbucketId": "workspace/api",
132+
"name": "api",
133+
"fullName": "workspace/api",
134+
"htmlUrl": "https://bitbucket.org/workspace/api",
135+
"cloneUrl": "https://bitbucket.org/workspace/api.git",
136+
})
137+
child := devlake.RemoteScopeChild{
138+
ID: "ignored",
139+
Name: "api-child",
140+
FullName: "workspace/api-child",
141+
Data: data,
142+
}
143+
repo := parseBitbucketRepo(&child)
144+
if repo == nil {
145+
t.Fatal("expected repo, got nil")
146+
}
147+
if repo.BitbucketID != "workspace/api" {
148+
t.Fatalf("bitbucketId = %q, want %q", repo.BitbucketID, "workspace/api")
149+
}
150+
if repo.Name != "api" {
151+
t.Fatalf("name = %q, want %q", repo.Name, "api")
152+
}
153+
if repo.FullName != "workspace/api" {
154+
t.Fatalf("fullName = %q, want %q", repo.FullName, "workspace/api")
155+
}
156+
if repo.CloneURL != "https://bitbucket.org/workspace/api.git" {
157+
t.Fatalf("cloneUrl = %q, want https://bitbucket.org/workspace/api.git", repo.CloneURL)
158+
}
159+
if repo.HTMLURL != "https://bitbucket.org/workspace/api" {
160+
t.Fatalf("htmlUrl = %q, want https://bitbucket.org/workspace/api", repo.HTMLURL)
161+
}
162+
})
163+
164+
t.Run("falls back to child fields when payload is sparse", func(t *testing.T) {
165+
child := devlake.RemoteScopeChild{
166+
Name: "frontend",
167+
FullName: "team/frontend",
168+
Data: []byte(`{"bitbucketId":"","name":"","fullName":""}`),
169+
}
170+
repo := parseBitbucketRepo(&child)
171+
if repo == nil {
172+
t.Fatal("expected repo, got nil")
173+
}
174+
if repo.BitbucketID != "team/frontend" {
175+
t.Fatalf("bitbucketId = %q, want %q", repo.BitbucketID, "team/frontend")
176+
}
177+
if repo.Name != "frontend" {
178+
t.Fatalf("name = %q, want %q", repo.Name, "frontend")
179+
}
180+
if repo.FullName != "team/frontend" {
181+
t.Fatalf("fullName = %q, want %q", repo.FullName, "team/frontend")
182+
}
183+
})
184+
185+
t.Run("handles missing data by using child fields", func(t *testing.T) {
186+
child := devlake.RemoteScopeChild{
187+
Name: "ui",
188+
FullName: "workspace/ui",
189+
Data: nil,
190+
}
191+
repo := parseBitbucketRepo(&child)
192+
if repo == nil {
193+
t.Fatal("expected repo, got nil")
194+
}
195+
if repo.BitbucketID != "workspace/ui" {
196+
t.Fatalf("bitbucketId = %q, want %q", repo.BitbucketID, "workspace/ui")
197+
}
198+
if repo.Name != "ui" {
199+
t.Fatalf("name = %q, want %q", repo.Name, "ui")
200+
}
201+
if repo.FullName != "workspace/ui" {
202+
t.Fatalf("fullName = %q, want %q", repo.FullName, "workspace/ui")
203+
}
204+
})
205+
}
206+
128207
func TestRunConfigureScopes_PluginFlag(t *testing.T) {
129208
makeCmd := func() (*cobra.Command, *ScopeOpts) {
130209
opts := &ScopeOpts{}

docs/configure-scope.md

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ gh devlake configure scope add [flags]
3232

3333
| Flag | Default | Description |
3434
|------|---------|-------------|
35-
| `--plugin` | *(interactive or required)* | Plugin to configure (`github`, `gh-copilot`, `jenkins`) |
35+
| `--plugin` | *(interactive or required)* | Plugin to configure (`github`, `gitlab`, `bitbucket`, `gh-copilot`, `jenkins`, `azure-devops`, `sonarqube`) |
3636
| `--connection-id` | *(auto-detected)* | Override the connection ID to scope |
37-
| `--org` | *(required)* | GitHub organization slug |
37+
| `--org` | *(plugin-dependent)* | Org/workspace slug (`github`, `gitlab` group path, `bitbucket` workspace, `azure-devops` org). Required for plugins whose connection definition needs an org (for example, Azure DevOps) or when running non-interactively; optional in interactive mode for plugins that support workspace discovery (for example, Bitbucket). |
3838
| `--enterprise` | | Enterprise slug (enables enterprise-level Copilot metrics) |
39-
| `--repos` | | Comma-separated repos to add (`owner/repo,owner/repo2`) |
40-
| `--repos-file` | | Path to a file with repos (one `owner/repo` per line) |
39+
| `--repos` | | Comma-separated repos to add (`owner/repo` for GitHub, `group/project` for GitLab, `workspace/repo-slug` for Bitbucket) |
40+
| `--repos-file` | | Path to a file with repos (one per line: `owner/repo` for GitHub, `group/project` for GitLab, `workspace/repo-slug` for Bitbucket) |
4141
| `--jobs` | | Comma-separated Jenkins job full names |
4242
| `--deployment-pattern` | `(?i)deploy` | Regex matching CI/CD workflow names for deployments |
4343
| `--production-pattern` | `(?i)prod` | Regex matching environment names for production |
@@ -77,12 +77,15 @@ gh devlake configure scope add --plugin github --org my-org \
7777
--repos my-org/api,my-org/frontend
7878

7979
# Load repos from a file
80-
gh devlake configure scope add --plugin github --org my-org \
81-
--repos-file repos.txt
82-
83-
# Interactive repo selection (omit --repos)
84-
gh devlake configure scope add --plugin github --org my-org
85-
80+
gh devlake configure scope add --plugin github --org my-org \
81+
--repos-file repos.txt
82+
83+
# Interactive repo selection (omit --repos)
84+
gh devlake configure scope add --plugin github --org my-org
85+
86+
# Bitbucket repos (interactive remote-scope picker)
87+
gh devlake configure scope add --plugin bitbucket --org my-workspace
88+
8689
# Add Copilot org scope
8790
gh devlake configure scope add --plugin gh-copilot --org my-org
8891

@@ -101,11 +104,17 @@ gh devlake configure scope add
101104

102105
### What It Does (GitHub)
103106

104-
1. Resolves repos from `--repos`, `--repos-file`, or interactive selection
105-
2. Fetches repo details via `gh api repos/<owner>/<repo>`
106-
3. Creates or reuses a DORA scope config (deployment/production patterns, incident label)
107-
4. Calls `PUT /plugins/github/connections/{id}/scopes` to add repos
108-
107+
1. Resolves repos from `--repos`, `--repos-file`, or interactive selection
108+
2. Fetches repo details via `gh api repos/<owner>/<repo>`
109+
3. Creates or reuses a DORA scope config (deployment/production patterns, incident label)
110+
4. Calls `PUT /plugins/github/connections/{id}/scopes` to add repos
111+
112+
### What It Does (Bitbucket)
113+
114+
1. Resolves workspaces and repos via the DevLake remote-scope API (interactive picker when `--repos` is omitted)
115+
2. Accepts repo slugs from `--repos` / `--repos-file` (`workspace/repo-slug`)
116+
3. Calls `PUT /plugins/bitbucket/connections/{id}/scopes` with `bitbucketId` = `workspace/repo-slug`
117+
109118
### What It Does (Copilot)
110119

111120
1. Computes scope ID from org + enterprise: `enterprise/org`, `enterprise`, or `org`

0 commit comments

Comments
 (0)