From 09c9738909a796f9a26b181185e7618a3ac9818d Mon Sep 17 00:00:00 2001 From: Kenneth Wong Date: Wed, 10 Jun 2026 16:26:48 +0800 Subject: [PATCH] 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. --- backend/plugins/bitbucket/api/remote_api.go | 115 ++++++++++++++------ backend/plugins/bitbucket/models/repo.go | 17 +-- 2 files changed, 88 insertions(+), 44 deletions(-) diff --git a/backend/plugins/bitbucket/api/remote_api.go b/backend/plugins/bitbucket/api/remote_api.go index 211901ec3c6..78f1ddd75c9 100644 --- a/backend/plugins/bitbucket/api/remote_api.go +++ b/backend/plugins/bitbucket/api/remote_api.go @@ -67,11 +67,13 @@ func listBitbucketWorkspaces( err errors.Error, ) { var res *http.Response + // /user/permissions/workspaces was removed by Bitbucket CHANGE-2770; /workspaces + // lists the current user's workspaces and is the supported replacement. res, err = apiClient.Get( - "/user/permissions/workspaces", + "/workspaces", url.Values{ - "sort": {"workspace.slug"}, - "fields": {"values.workspace.slug,values.workspace.name,pagelen,page,size"}, + "sort": {"slug"}, + "fields": {"values.slug,values.name,pagelen,page,size"}, "page": {fmt.Sprintf("%v", page.Page)}, "pagelen": {fmt.Sprintf("%v", page.PageLen)}, }, @@ -98,9 +100,9 @@ func listBitbucketWorkspaces( for _, r := range resBody.Values { children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.BitbucketRepo]{ Type: api.RAS_ENTRY_TYPE_GROUP, - Id: r.Workspace.Slug, - Name: r.Workspace.Name, - FullName: r.Workspace.Name, + Id: r.Slug, + Name: r.Name, + FullName: r.Name, }) } return @@ -152,6 +154,10 @@ func listBitbucketRepos( return } +// searchBitbucketRepos searches repositories by name across the user's workspaces. +// The cross-workspace GET /repositories?role=member was removed by Bitbucket +// CHANGE-2770, so we enumerate workspaces and query the workspace-scoped +// GET /repositories/{workspace} endpoint for each, aggregating up to PageSize hits. func searchBitbucketRepos( apiClient plugin.ApiClient, params *dsmodels.DsRemoteApiScopeSearchParams, @@ -159,39 +165,84 @@ func searchBitbucketRepos( children []dsmodels.DsRemoteApiScopeListEntry[models.BitbucketRepo], err errors.Error, ) { - var res *http.Response - res, err = apiClient.Get( - "/repositories", - url.Values{ - "sort": {"name"}, - "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"}, - "role": {"member"}, - "q": {fmt.Sprintf(`full_name~"%s"`, params.Search)}, - "page": {fmt.Sprintf("%v", params.Page)}, - "pagelen": {fmt.Sprintf("%v", params.PageSize)}, - }, - nil, - ) - if err != nil { - return nil, err + pageSize := params.PageSize + if pageSize == 0 { + pageSize = 100 } - var resBody models.ReposResponse - err = api.UnmarshalResponse(res, &resBody) + + workspaces, err := listAllBitbucketWorkspaces(apiClient) if err != nil { - return + return nil, err } - for _, r := range resBody.Values { - children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.BitbucketRepo]{ - Type: api.RAS_ENTRY_TYPE_SCOPE, - Id: r.FullName, - Name: r.Name, - FullName: r.FullName, - Data: r.ConvertApiScope(), - }) + + for _, workspace := range workspaces { + if len(children) >= pageSize { + break + } + var res *http.Response + res, err = apiClient.Get( + fmt.Sprintf("/repositories/%s", workspace), + url.Values{ + "sort": {"name"}, + "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"}, + "q": {fmt.Sprintf(`name~"%s"`, params.Search)}, + "pagelen": {fmt.Sprintf("%v", pageSize)}, + }, + nil, + ) + if err != nil { + return nil, err + } + var resBody models.ReposResponse + err = api.UnmarshalResponse(res, &resBody) + if err != nil { + return + } + for _, r := range resBody.Values { + children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.BitbucketRepo]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + Id: r.FullName, + Name: r.Name, + FullName: r.FullName, + Data: r.ConvertApiScope(), + }) + } } return } +// listAllBitbucketWorkspaces returns every workspace slug accessible to the +// authenticated user, following pagination of GET /2.0/workspaces. +func listAllBitbucketWorkspaces(apiClient plugin.ApiClient) ([]string, errors.Error) { + var slugs []string + for page := 1; ; page++ { + res, err := apiClient.Get( + "/workspaces", + url.Values{ + "sort": {"slug"}, + "fields": {"values.slug,pagelen,page,size"}, + "page": {fmt.Sprintf("%v", page)}, + "pagelen": {"100"}, + }, + nil, + ) + if err != nil { + return nil, err + } + var resBody models.WorkspaceResponse + if err = api.UnmarshalResponse(res, &resBody); err != nil { + return nil, err + } + for _, w := range resBody.Values { + slugs = append(slugs, w.Slug) + } + if len(resBody.Values) == 0 || page*resBody.Pagelen >= resBody.Size { + break + } + } + return slugs, nil +} + // RemoteScopes list all available scopes on the remote server // @Summary list all available scopes on the remote server // @Description list all available scopes on the remote server diff --git a/backend/plugins/bitbucket/models/repo.go b/backend/plugins/bitbucket/models/repo.go index 799b549b923..d0785cbba1e 100644 --- a/backend/plugins/bitbucket/models/repo.go +++ b/backend/plugins/bitbucket/models/repo.go @@ -117,27 +117,20 @@ type WorkspaceResponse struct { Values []GroupResponse `json:"values"` } +// GroupResponse maps an entry from GET /2.0/workspaces. The cross-workspace +// GET /2.0/user/permissions/workspaces was removed by Bitbucket CHANGE-2770, +// so slug/name now live at the top level instead of under a nested workspace. type GroupResponse struct { - //Type string `json:"type"` - //Permission string `json:"permission"` - //LastAccessed time.Time `json:"last_accessed"` - //AddedOn time.Time `json:"added_on"` - Workspace WorkspaceItem `json:"workspace"` -} - -type WorkspaceItem struct { - //Type string `json:"type"` - //Uuid string `json:"uuid"` Slug string `json:"slug" group:"id"` Name string `json:"name" group:"name"` } func (p GroupResponse) GroupId() string { - return p.Workspace.Slug + return p.Slug } func (p GroupResponse) GroupName() string { - return p.Workspace.Name + return p.Name } type ReposResponse struct {