Skip to content

Commit 52278b0

Browse files
authored
fix(bitbucket): migrate off cross-workspace APIs removed by CHANGE-2770 (apache#8916)
* fix(bitbucket): migrate off cross-workspace APIs removed by CHANGE-2770 - listBitbucketWorkspaces: replace GET /user/permissions/workspaces with the supported GET /workspaces (lists the current user's workspaces). Workspace slug/name now parsed from the top level of each value instead of a nested "workspace" object. - searchBitbucketRepos: replace cross-workspace GET /repositories?role=member with per-workspace GET /repositories/{workspace}, enumerating the user's workspaces first and aggregating up to PageSize matches. - models: flatten GroupResponse to match the /workspaces response shape. * fix(bitbucket): list workspaces via supported /user/workspaces (CHANGE-2770) GET /2.0/workspaces is also deprecated, not just /user/permissions/workspaces. The supported replacement for enumerating the current user's workspaces is GET /2.0/user/workspaces, which nests slug/name under a workspace object. - remote_api.go: listBitbucketWorkspaces / listAllBitbucketWorkspaces now call /user/workspaces with workspace.* fields and read via GroupId()/GroupName(). - models/repo.go: re-nest GroupResponse under WorkspaceItem to match the /user/workspaces response shape. Repo listing stays on the supported GET /2.0/repositories/{workspace}. * fix(bitbucket): drop sort/fields on /user/workspaces (400 Invalid field name) GET /2.0/user/workspaces rejects sort=workspace.slug with HTTP 400 'Invalid field name'. The bare paginated call returns the nested workspace objects we parse, so drop the sort/fields query params in both listBitbucketWorkspaces and listAllBitbucketWorkspaces. * fix(bitbucket): ignore 410 Gone on sunset issues API Bitbucket has sunset the issue tracker/wiki API; the issues endpoint now returns 410 Gone instead of 404. ignoreHTTPStatus404 only mapped 404 to ErrIgnoreAndContinue, so a 410 fell through to nil, DoAsync retried 3x and failed the whole pipeline ("Collect Issues ended unexpectedly"). Treat 410 like 404 (ignore and continue). Shared helper also covers issue comments and pr commits. Adds a table test for the status-code mapping.
1 parent 50f664e commit 52278b0

4 files changed

Lines changed: 146 additions & 39 deletions

File tree

backend/plugins/bitbucket/api/remote_api.go

Lines changed: 85 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,15 @@ func listBitbucketWorkspaces(
6767
err errors.Error,
6868
) {
6969
var res *http.Response
70+
// /user/permissions/workspaces was removed and /workspaces deprecated by
71+
// Bitbucket CHANGE-2770; /user/workspaces lists the current user's workspaces
72+
// and is the supported replacement.
7073
res, err = apiClient.Get(
71-
"/user/permissions/workspaces",
74+
"/user/workspaces",
7275
url.Values{
73-
"sort": {"workspace.slug"},
74-
"fields": {"values.workspace.slug,values.workspace.name,pagelen,page,size"},
76+
// No sort/fields: /user/workspaces rejects sort=workspace.slug with
77+
// HTTP 400 "Invalid field name". The bare call returns the nested
78+
// workspace objects we need; WorkspaceResponse ignores extra fields.
7579
"page": {fmt.Sprintf("%v", page.Page)},
7680
"pagelen": {fmt.Sprintf("%v", page.PageLen)},
7781
},
@@ -98,9 +102,9 @@ func listBitbucketWorkspaces(
98102
for _, r := range resBody.Values {
99103
children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.BitbucketRepo]{
100104
Type: api.RAS_ENTRY_TYPE_GROUP,
101-
Id: r.Workspace.Slug,
102-
Name: r.Workspace.Name,
103-
FullName: r.Workspace.Name,
105+
Id: r.GroupId(),
106+
Name: r.GroupName(),
107+
FullName: r.GroupName(),
104108
})
105109
}
106110
return
@@ -152,46 +156,95 @@ func listBitbucketRepos(
152156
return
153157
}
154158

159+
// searchBitbucketRepos searches repositories by name across the user's workspaces.
160+
// The cross-workspace GET /repositories?role=member was removed by Bitbucket
161+
// CHANGE-2770, so we enumerate workspaces and query the workspace-scoped
162+
// GET /repositories/{workspace} endpoint for each, aggregating up to PageSize hits.
155163
func searchBitbucketRepos(
156164
apiClient plugin.ApiClient,
157165
params *dsmodels.DsRemoteApiScopeSearchParams,
158166
) (
159167
children []dsmodels.DsRemoteApiScopeListEntry[models.BitbucketRepo],
160168
err errors.Error,
161169
) {
162-
var res *http.Response
163-
res, err = apiClient.Get(
164-
"/repositories",
165-
url.Values{
166-
"sort": {"name"},
167-
"fields": {"values.name,values.full_name,values.language,values.description,values.owner.display_name,values.created_on,values.updated_on,values.links.clone,values.links.html,pagelen,page,size"},
168-
"role": {"member"},
169-
"q": {fmt.Sprintf(`full_name~"%s"`, params.Search)},
170-
"page": {fmt.Sprintf("%v", params.Page)},
171-
"pagelen": {fmt.Sprintf("%v", params.PageSize)},
172-
},
173-
nil,
174-
)
175-
if err != nil {
176-
return nil, err
170+
pageSize := params.PageSize
171+
if pageSize == 0 {
172+
pageSize = 100
177173
}
178-
var resBody models.ReposResponse
179-
err = api.UnmarshalResponse(res, &resBody)
174+
175+
workspaces, err := listAllBitbucketWorkspaces(apiClient)
180176
if err != nil {
181-
return
177+
return nil, err
182178
}
183-
for _, r := range resBody.Values {
184-
children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.BitbucketRepo]{
185-
Type: api.RAS_ENTRY_TYPE_SCOPE,
186-
Id: r.FullName,
187-
Name: r.Name,
188-
FullName: r.FullName,
189-
Data: r.ConvertApiScope(),
190-
})
179+
180+
for _, workspace := range workspaces {
181+
if len(children) >= pageSize {
182+
break
183+
}
184+
var res *http.Response
185+
res, err = apiClient.Get(
186+
fmt.Sprintf("/repositories/%s", workspace),
187+
url.Values{
188+
"sort": {"name"},
189+
"fields": {"values.name,values.full_name,values.language,values.description,values.owner.display_name,values.created_on,values.updated_on,values.links.clone,values.links.html,pagelen,page,size"},
190+
"q": {fmt.Sprintf(`name~"%s"`, params.Search)},
191+
"pagelen": {fmt.Sprintf("%v", pageSize)},
192+
},
193+
nil,
194+
)
195+
if err != nil {
196+
return nil, err
197+
}
198+
var resBody models.ReposResponse
199+
err = api.UnmarshalResponse(res, &resBody)
200+
if err != nil {
201+
return
202+
}
203+
for _, r := range resBody.Values {
204+
children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.BitbucketRepo]{
205+
Type: api.RAS_ENTRY_TYPE_SCOPE,
206+
Id: r.FullName,
207+
Name: r.Name,
208+
FullName: r.FullName,
209+
Data: r.ConvertApiScope(),
210+
})
211+
}
191212
}
192213
return
193214
}
194215

216+
// listAllBitbucketWorkspaces returns every workspace slug accessible to the
217+
// authenticated user, following pagination of GET /2.0/user/workspaces.
218+
func listAllBitbucketWorkspaces(apiClient plugin.ApiClient) ([]string, errors.Error) {
219+
var slugs []string
220+
for page := 1; ; page++ {
221+
res, err := apiClient.Get(
222+
"/user/workspaces",
223+
url.Values{
224+
// No sort/fields (see listBitbucketWorkspaces): /user/workspaces
225+
// returns 400 on sort=workspace.slug.
226+
"page": {fmt.Sprintf("%v", page)},
227+
"pagelen": {"100"},
228+
},
229+
nil,
230+
)
231+
if err != nil {
232+
return nil, err
233+
}
234+
var resBody models.WorkspaceResponse
235+
if err = api.UnmarshalResponse(res, &resBody); err != nil {
236+
return nil, err
237+
}
238+
for _, w := range resBody.Values {
239+
slugs = append(slugs, w.GroupId())
240+
}
241+
if len(resBody.Values) == 0 || page*resBody.Pagelen >= resBody.Size {
242+
break
243+
}
244+
}
245+
return slugs, nil
246+
}
247+
195248
// RemoteScopes list all available scopes on the remote server
196249
// @Summary list all available scopes on the remote server
197250
// @Description list all available scopes on the remote server

backend/plugins/bitbucket/models/repo.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,17 +117,15 @@ type WorkspaceResponse struct {
117117
Values []GroupResponse `json:"values"`
118118
}
119119

120+
// GroupResponse maps an entry from GET /2.0/user/workspaces, the supported
121+
// replacement after Bitbucket CHANGE-2770 removed the cross-workspace
122+
// GET /2.0/user/permissions/workspaces and deprecated GET /2.0/workspaces.
123+
// Each value nests the workspace slug/name under a "workspace" object.
120124
type GroupResponse struct {
121-
//Type string `json:"type"`
122-
//Permission string `json:"permission"`
123-
//LastAccessed time.Time `json:"last_accessed"`
124-
//AddedOn time.Time `json:"added_on"`
125125
Workspace WorkspaceItem `json:"workspace"`
126126
}
127127

128128
type WorkspaceItem struct {
129-
//Type string `json:"type"`
130-
//Uuid string `json:"uuid"`
131129
Slug string `json:"slug" group:"id"`
132130
Name string `json:"name" group:"name"`
133131
}

backend/plugins/bitbucket/tasks/api_common.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,10 @@ func ignoreHTTPStatus404(res *http.Response) errors.Error {
238238
if res.StatusCode == http.StatusUnauthorized {
239239
return errors.Unauthorized.New("authentication failed, please check your AccessToken")
240240
}
241-
if res.StatusCode == http.StatusNotFound {
241+
// 404: repo has no issue tracker. 410 Gone: Bitbucket has sunset the issue
242+
// tracker/wiki API, so the endpoint is permanently removed for the repo.
243+
// Both mean "nothing to collect" — skip gracefully instead of retrying.
244+
if res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusGone {
242245
return api.ErrIgnoreAndContinue
243246
}
244247
return nil
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package tasks
19+
20+
import (
21+
"net/http"
22+
"testing"
23+
24+
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
25+
"github.com/stretchr/testify/assert"
26+
)
27+
28+
func TestIgnoreHTTPStatus404(t *testing.T) {
29+
cases := []struct {
30+
name string
31+
statusCode int
32+
wantIgnore bool // expect ErrIgnoreAndContinue (graceful skip, no retry)
33+
wantErr bool // expect a real error
34+
}{
35+
{"404 no issue tracker -> ignore", http.StatusNotFound, true, false},
36+
{"410 issue tracker sunset -> ignore", http.StatusGone, true, false},
37+
{"401 unauthorized -> error", http.StatusUnauthorized, false, true},
38+
{"200 ok -> continue", http.StatusOK, false, false},
39+
}
40+
for _, tc := range cases {
41+
t.Run(tc.name, func(t *testing.T) {
42+
err := ignoreHTTPStatus404(&http.Response{StatusCode: tc.statusCode})
43+
switch {
44+
case tc.wantIgnore:
45+
assert.Equal(t, api.ErrIgnoreAndContinue, err)
46+
case tc.wantErr:
47+
assert.Error(t, err)
48+
default:
49+
assert.NoError(t, err)
50+
}
51+
})
52+
}
53+
}

0 commit comments

Comments
 (0)