From 1ece9f73cb592c67ae50154b8fb3bab0151b4775 Mon Sep 17 00:00:00 2001
From: Ganesh Kumar
Date: Sat, 25 Apr 2026 21:21:01 +0530
Subject: [PATCH 1/3] Update Bitbucket Project Discovery API and UI Repository
Access
LiveReview Pre-Commit Check: ran (iter:1, coverage:0%)
---
.../providers/bitbucket/project_discovery.go | 207 +++++++-----------
.../pages/GitProviders/ConnectorDetails.tsx | 2 +-
2 files changed, 80 insertions(+), 129 deletions(-)
diff --git a/internal/providers/bitbucket/project_discovery.go b/internal/providers/bitbucket/project_discovery.go
index da403d6f..23af3acb 100644
--- a/internal/providers/bitbucket/project_discovery.go
+++ b/internal/providers/bitbucket/project_discovery.go
@@ -22,228 +22,179 @@ type BitbucketRepositoryBasic struct {
type BitbucketWorkspaceBasic struct {
Slug string `json:"slug"`
Name string `json:"name"`
+ // The new /user/workspaces API might return a nested workspace object depending on token type
+ Workspace *struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ } `json:"workspace"`
}
-// BitbucketAPIResponse represents the paginated response structure from Bitbucket API
+// BitbucketAPIResponse represents the paginated response for repositories
type BitbucketAPIResponse struct {
Values []BitbucketRepositoryBasic `json:"values"`
Next string `json:"next"`
}
-// BitbucketWorkspaceAPIResponse represents the paginated response structure for workspaces
+// BitbucketWorkspaceAPIResponse represents the paginated response for workspaces
type BitbucketWorkspaceAPIResponse struct {
Values []BitbucketWorkspaceBasic `json:"values"`
Next string `json:"next"`
}
-// DiscoverProjectsBitbucket fetches all repositories accessible with the given credentials from Bitbucket
+// DiscoverProjectsBitbucket fetches all repositories accessible with the given credentials.
+//
+// Migration from deprecated APIs (CHANGE-2770):
+// - Removed: GET /2.0/repositories → replaced by /user/workspaces + /repositories/{workspace}
+// - Removed: GET /2.0/workspaces → replaced by GET /2.0/user/workspaces
+// - Removed: GET /2.0/user/permissions/workspaces → replaced by GET /2.0/user/workspaces
func DiscoverProjectsBitbucket(baseURL, email, apiToken string) ([]string, error) {
- var allRepositories []string
-
- // Create HTTP client
client := &http.Client{}
- // Bitbucket API base URL - always use the cloud API
apiBaseURL := "https://api.bitbucket.org/2.0"
if baseURL != "" && baseURL != "https://bitbucket.org" {
- // For Bitbucket Server (on-premise), the API is typically at /rest/api/1.0
- // Note: This implementation focuses on Bitbucket Cloud
- return nil, fmt.Errorf("bitbucket Server is not currently supported, only Bitbucket Cloud")
+ return nil, fmt.Errorf("only Bitbucket Cloud is supported (not Bitbucket Server)")
}
- // Try to get repositories directly from the user's accessible repositories
- // This approach works without requiring workspace enumeration permissions
- userRepos, err := getUserAccessibleRepositories(client, apiBaseURL, email, apiToken)
+ // Step 1: list all accessible workspaces using the new /user/workspaces endpoint.
+ workspaces, err := getUserWorkspaces(client, apiBaseURL, email, apiToken)
if err != nil {
- return nil, fmt.Errorf("failed to get user accessible repositories: %w", err)
+ return nil, fmt.Errorf("failed to list workspaces: %w", err)
}
- allRepositories = append(allRepositories, userRepos...)
+ // Step 2: for each workspace, list repositories using /repositories/{workspace}.
+ seen := make(map[string]struct{})
+ var all []string
+ for _, ws := range workspaces {
+ slug := ws.Slug
+ if slug == "" && ws.Workspace != nil {
+ slug = ws.Workspace.Slug
+ }
- // If we have workspace permissions, try to get additional workspace repositories
- // This is optional and will fail silently if permissions are missing
- workspaces, err := getAccessibleWorkspaces(client, apiBaseURL, email, apiToken)
- if err != nil {
- // Log the warning but don't fail - workspace enumeration requires additional permissions
- fmt.Printf("Warning: Could not enumerate workspaces (may require read:workspace:bitbucket scope): %v\n", err)
- } else {
- // For each workspace, get all repositories
- for _, workspace := range workspaces {
- repos, err := getWorkspaceRepositories(client, apiBaseURL, email, apiToken, workspace.Slug)
- if err != nil {
- // Log the error but continue with other workspaces
- fmt.Printf("Warning: failed to get repositories for workspace %s: %v\n", workspace.Slug, err)
- continue
- }
+ fmt.Printf("[DEBUG] Extracted workspace slug: %q from object: %+v\n", slug, ws)
+ if slug == "" {
+ continue // Skip if we couldn't parse the slug
+ }
- // Add only repositories that aren't already in our list
- for _, repo := range repos {
- found := false
- for _, existingRepo := range allRepositories {
- if existingRepo == repo {
- found = true
- break
- }
- }
- if !found {
- allRepositories = append(allRepositories, repo)
- }
+ repos, err := getWorkspaceRepositories(client, apiBaseURL, email, apiToken, slug)
+ if err != nil {
+ fmt.Printf("Warning: failed to get repositories for workspace %s: %v\n", slug, err)
+ continue
+ }
+ for _, r := range repos {
+ if _, ok := seen[r]; !ok {
+ seen[r] = struct{}{}
+ all = append(all, r)
}
}
}
-
- return allRepositories, nil
+ return all, nil
}
-// getAccessibleWorkspaces fetches all workspaces the user has access to
-func getAccessibleWorkspaces(client *http.Client, apiBaseURL, email, apiToken string) ([]BitbucketWorkspaceBasic, error) {
- var allWorkspaces []BitbucketWorkspaceBasic
- nextURL := fmt.Sprintf("%s/workspaces", apiBaseURL)
-
- for nextURL != "" {
- // Create request
- req, err := http.NewRequest("GET", nextURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- // Add authentication and headers
- req.SetBasicAuth(email, apiToken)
- req.Header.Add("Accept", "application/json")
- req.Header.Add("User-Agent", "LiveReview/1.0")
+// DiscoverProjectsBitbucketForWorkspaces lists repositories for a set of known workspace slugs,
+// bypassing workspace enumeration entirely. Useful as a fallback when the workspace slug is
+// already known (e.g. derived from a past review URL) but enumeration fails.
+func DiscoverProjectsBitbucketForWorkspaces(baseURL, email, apiToken string, workspaces []string) ([]string, error) {
+ client := &http.Client{}
+ apiBaseURL := "https://api.bitbucket.org/2.0"
+ if baseURL != "" && baseURL != "https://bitbucket.org" {
+ return nil, fmt.Errorf("only Bitbucket Cloud is supported (not Bitbucket Server)")
+ }
- // Execute request
- resp, err := client.Do(req)
+ seen := make(map[string]struct{})
+ var all []string
+ for _, ws := range workspaces {
+ repos, err := getWorkspaceRepositories(client, apiBaseURL, email, apiToken, ws)
if err != nil {
- return nil, fmt.Errorf("failed to execute request: %w", err)
- }
- defer resp.Body.Close()
-
- // Check for errors
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
+ fmt.Printf("Warning: failed to get repositories for workspace %s: %v\n", ws, err)
+ continue
}
-
- // Parse response
- var response BitbucketWorkspaceAPIResponse
- if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ for _, r := range repos {
+ if _, ok := seen[r]; !ok {
+ seen[r] = struct{}{}
+ all = append(all, r)
+ }
}
-
- // Add workspaces to result
- allWorkspaces = append(allWorkspaces, response.Values...)
-
- // Set next URL for pagination
- nextURL = response.Next
}
-
- return allWorkspaces, nil
+ return all, nil
}
-// getWorkspaceRepositories fetches all repositories from a specific workspace
-func getWorkspaceRepositories(client *http.Client, apiBaseURL, email, apiToken, workspace string) ([]string, error) {
- var repositories []string
- nextURL := fmt.Sprintf("%s/repositories/%s", apiBaseURL, url.PathEscape(workspace))
-
- // Add query parameters for pagination and filtering
- params := url.Values{}
- params.Add("pagelen", "100") // Maximum allowed by Bitbucket API
- params.Add("role", "member") // Only repositories where user is a member
- nextURL += "?" + params.Encode()
+// getUserWorkspaces calls GET /2.0/user/workspaces — the new public API announced alongside
+// CHANGE-2770, replacing both the deprecated GET /2.0/workspaces and
+// GET /2.0/user/permissions/workspaces endpoints.
+func getUserWorkspaces(client *http.Client, apiBaseURL, email, apiToken string) ([]BitbucketWorkspaceBasic, error) {
+ var all []BitbucketWorkspaceBasic
+ nextURL := fmt.Sprintf("%s/user/workspaces", apiBaseURL)
for nextURL != "" {
- // Create request
req, err := http.NewRequest("GET", nextURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
-
- // Add authentication and headers
req.SetBasicAuth(email, apiToken)
req.Header.Add("Accept", "application/json")
req.Header.Add("User-Agent", "LiveReview/1.0")
- // Execute request
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
- // Check for errors
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
+ return nil, fmt.Errorf("GET /user/workspaces failed (status %d): %s", resp.StatusCode, string(body))
}
- // Parse response
- var response BitbucketAPIResponse
+ var response BitbucketWorkspaceAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ return nil, fmt.Errorf("failed to decode workspace response: %w", err)
}
- // Add repositories to result
- for _, repo := range response.Values {
- repositories = append(repositories, repo.FullName)
- }
-
- // Set next URL for pagination
+ all = append(all, response.Values...)
nextURL = response.Next
}
- return repositories, nil
+ return all, nil
}
-// getUserAccessibleRepositories fetches all repositories the user has access to
-// This uses a more comprehensive approach that works without workspace enumeration
-func getUserAccessibleRepositories(client *http.Client, apiBaseURL, email, apiToken string) ([]string, error) {
+// getWorkspaceRepositories fetches all repositories from a specific workspace using
+// GET /2.0/repositories/{workspace} — the current non-deprecated, workspace-scoped endpoint.
+func getWorkspaceRepositories(client *http.Client, apiBaseURL, email, apiToken, workspace string) ([]string, error) {
var repositories []string
- nextURL := fmt.Sprintf("%s/repositories", apiBaseURL)
- // Add query parameters for pagination and filtering
params := url.Values{}
- params.Add("pagelen", "100") // Maximum allowed by Bitbucket API
- params.Add("role", "member") // Only repositories where user is a member
- nextURL += "?" + params.Encode()
+ params.Set("pagelen", "100")
+ params.Set("role", "member")
+ nextURL := fmt.Sprintf("%s/repositories/%s?%s", apiBaseURL, url.PathEscape(workspace), params.Encode())
for nextURL != "" {
- // Create request
req, err := http.NewRequest("GET", nextURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
-
- // Add authentication and headers
req.SetBasicAuth(email, apiToken)
req.Header.Add("Accept", "application/json")
req.Header.Add("User-Agent", "LiveReview/1.0")
- // Execute request
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
- // Check for errors
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
+ return nil, fmt.Errorf("GET /repositories/%s failed (status %d): %s", workspace, resp.StatusCode, string(body))
}
- // Parse response
var response BitbucketAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ return nil, fmt.Errorf("failed to decode repository response: %w", err)
}
- // Add repositories to result
for _, repo := range response.Values {
repositories = append(repositories, repo.FullName)
}
-
- // Set next URL for pagination (Bitbucket uses URL-based pagination)
nextURL = response.Next
}
diff --git a/ui/src/pages/GitProviders/ConnectorDetails.tsx b/ui/src/pages/GitProviders/ConnectorDetails.tsx
index b44c1323..73fe6c0b 100644
--- a/ui/src/pages/GitProviders/ConnectorDetails.tsx
+++ b/ui/src/pages/GitProviders/ConnectorDetails.tsx
@@ -987,7 +987,7 @@ const ConnectorDetails: React.FC = () => {
{repositoryAccess.error}
- ) : repositoryAccess && repositoryAccess.projects.length > 0 ? (
+ ) : repositoryAccess && (repositoryAccess.projects?.length || 0) > 0 ? (
{/* Repository Summary */}
From 45eb391af5d67513980db706f3481eb8da365272 Mon Sep 17 00:00:00 2001
From: Ganesh Kumar
Date: Sun, 26 Apr 2026 15:39:41 +0530
Subject: [PATCH 2/3] Extract Bitbucket HTTP Client Operations
LiveReview Pre-Commit Check: ran (iter:5, coverage:0%)
---
.../providers/bitbucket/project_discovery.go | 37 ++++++++-----------
network/network_status.md | 6 ++-
.../providers/bitbucket/http_client_ops.go | 30 +++++++++++++++
3 files changed, 49 insertions(+), 24 deletions(-)
diff --git a/internal/providers/bitbucket/project_discovery.go b/internal/providers/bitbucket/project_discovery.go
index 23af3acb..04592cc2 100644
--- a/internal/providers/bitbucket/project_discovery.go
+++ b/internal/providers/bitbucket/project_discovery.go
@@ -1,11 +1,14 @@
package bitbucket
import (
+ "context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
+
+ networkbitbucket "github.com/livereview/network/providers/bitbucket"
)
// BitbucketRepositoryBasic represents basic repository information from Bitbucket API
@@ -124,31 +127,26 @@ func DiscoverProjectsBitbucketForWorkspaces(baseURL, email, apiToken string, wor
func getUserWorkspaces(client *http.Client, apiBaseURL, email, apiToken string) ([]BitbucketWorkspaceBasic, error) {
var all []BitbucketWorkspaceBasic
nextURL := fmt.Sprintf("%s/user/workspaces", apiBaseURL)
+ ctx := context.Background()
for nextURL != "" {
- req, err := http.NewRequest("GET", nextURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- req.SetBasicAuth(email, apiToken)
- req.Header.Add("Accept", "application/json")
- req.Header.Add("User-Agent", "LiveReview/1.0")
-
- resp, err := client.Do(req)
+ resp, err := networkbitbucket.FetchUserWorkspacesPage(ctx, client, nextURL, email, apiToken)
if err != nil {
- return nil, fmt.Errorf("failed to execute request: %w", err)
+ return nil, err
}
- defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
return nil, fmt.Errorf("GET /user/workspaces failed (status %d): %s", resp.StatusCode, string(body))
}
var response BitbucketWorkspaceAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+ resp.Body.Close()
return nil, fmt.Errorf("failed to decode workspace response: %w", err)
}
+ resp.Body.Close()
all = append(all, response.Values...)
nextURL = response.Next
@@ -166,31 +164,26 @@ func getWorkspaceRepositories(client *http.Client, apiBaseURL, email, apiToken,
params.Set("pagelen", "100")
params.Set("role", "member")
nextURL := fmt.Sprintf("%s/repositories/%s?%s", apiBaseURL, url.PathEscape(workspace), params.Encode())
+ ctx := context.Background()
for nextURL != "" {
- req, err := http.NewRequest("GET", nextURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- req.SetBasicAuth(email, apiToken)
- req.Header.Add("Accept", "application/json")
- req.Header.Add("User-Agent", "LiveReview/1.0")
-
- resp, err := client.Do(req)
+ resp, err := networkbitbucket.FetchWorkspaceRepositoriesPage(ctx, client, nextURL, email, apiToken)
if err != nil {
- return nil, fmt.Errorf("failed to execute request: %w", err)
+ return nil, err
}
- defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
return nil, fmt.Errorf("GET /repositories/%s failed (status %d): %s", workspace, resp.StatusCode, string(body))
}
var response BitbucketAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+ resp.Body.Close()
return nil, fmt.Errorf("failed to decode repository response: %w", err)
}
+ resp.Body.Close()
for _, repo := range response.Values {
repositories = append(repositories, repo.FullName)
diff --git a/network/network_status.md b/network/network_status.md
index ec7b95e4..8fc6a000 100644
--- a/network/network_status.md
+++ b/network/network_status.md
@@ -13,8 +13,8 @@ Latest milestone batch note (MF-051, MF-059, MF-073, MF-074, MF-076, MF-083, MF-
| api.GetBillingStatus | updated | [GetBillingStatus](../internal/api/billing_actions_handler.go#L1178) |
| api.GetCurrentSubscription | updated | [GetCurrentSubscription](../internal/api/subscriptions_handler.go#L620) |
| api.ListUserSubscriptions | updated | [ListUserSubscriptions](../internal/api/subscriptions_handler.go#L773) |
-| payment.cancellationVerified | updated | [cancellationVerified](../internal/license/payment/subscription_service.go#L650) |
-| payment.verifyCancellationWithRetry | updated | [verifyCancellationWithRetry](../internal/license/payment/subscription_service.go#L667) |
+| payment.cancellationVerified | updated | [cancellationVerified](../internal/license/payment/subscription_service.go#L1029) |
+| payment.verifyCancellationWithRetry | updated | [verifyCancellationWithRetry](../internal/license/payment/subscription_service.go#L1046) |
| payment.handleSubscriptionCharged | updated | [handleSubscriptionCharged](../internal/license/payment/webhook_handler.go#L530) |
| payment.resolveCancelAtPeriodEndAfterCharge | added | [resolveCancelAtPeriodEndAfterCharge](../internal/license/payment/webhook_handler.go#L738) |
| payment.handleSubscriptionCancelled | updated | [handleSubscriptionCancelled](../internal/license/payment/webhook_handler.go#L764) |
@@ -46,6 +46,8 @@ Latest milestone batch note (MF-051, MF-059, MF-073, MF-074, MF-076, MF-083, MF-
| providersbitbucket.Do | moved | [Do](providers/bitbucket/http_client_ops.go#L29) |
| providersbitbucket.PostCommentAPI | added | [PostCommentAPI](providers/bitbucket/http_client_ops.go#L40) |
| providersbitbucket.FetchUserProfile | added | [FetchUserProfile](providers/bitbucket/http_client_ops.go#L56) |
+| providersbitbucket.FetchUserWorkspacesPage | added | [FetchUserWorkspacesPage](providers/bitbucket/http_client_ops.go#L75) |
+| providersbitbucket.FetchWorkspaceRepositoriesPage | added | [FetchWorkspaceRepositoriesPage](providers/bitbucket/http_client_ops.go#L90) |
| aiconnectors.NewHTTPClient | moved | [NewHTTPClient](aiconnectors/http_client_ops.go#L11) |
| aiconnectors.NewRequestWithContext | moved | [NewRequestWithContext](aiconnectors/http_client_ops.go#L18) |
| aiconnectors.Do | moved | [Do](aiconnectors/http_client_ops.go#L26) |
diff --git a/network/providers/bitbucket/http_client_ops.go b/network/providers/bitbucket/http_client_ops.go
index faf34341..75d3470a 100644
--- a/network/providers/bitbucket/http_client_ops.go
+++ b/network/providers/bitbucket/http_client_ops.go
@@ -69,3 +69,33 @@ func FetchUserProfile(ctx context.Context, client *http.Client, baseURL, email,
req.Header.Set("User-Agent", "LiveReview/1.0")
return Do(client, req)
}
+
+// FetchUserWorkspacesPage executes the HTTP GET request to fetch a page of accessible workspaces.
+// Callers must close resp.Body when err is nil.
+func FetchUserWorkspacesPage(ctx context.Context, client *http.Client, nextURL, email, token string) (*http.Response, error) {
+ req, err := NewRequestWithContext(ctx, "GET", nextURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create workspaces request: %w", err)
+ }
+
+ req.SetBasicAuth(email, token)
+ req.Header.Add("Accept", "application/json")
+ req.Header.Add("User-Agent", "LiveReview/1.0")
+
+ return Do(client, req)
+}
+
+// FetchWorkspaceRepositoriesPage executes the HTTP GET request to fetch a page of repositories for a workspace.
+// Callers must close resp.Body when err is nil.
+func FetchWorkspaceRepositoriesPage(ctx context.Context, client *http.Client, nextURL, email, token string) (*http.Response, error) {
+ req, err := NewRequestWithContext(ctx, "GET", nextURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create repositories request: %w", err)
+ }
+
+ req.SetBasicAuth(email, token)
+ req.Header.Add("Accept", "application/json")
+ req.Header.Add("User-Agent", "LiveReview/1.0")
+
+ return Do(client, req)
+}
From 3423a92f2e6366905008e659934bfa11f5274b8a Mon Sep 17 00:00:00 2001
From: Ganesh Kumar
Date: Sun, 26 Apr 2026 19:05:15 +0530
Subject: [PATCH 3/3] Document Bitbucket API Migration for Project Discovery
LiveReview Pre-Commit Check: ran (iter:1, coverage:0%)
---
.../integrations/bitbucket/api-CHANGE-2770.md | 38 +++++++++++++++++++
.../providers/bitbucket/project_discovery.go | 13 +++----
2 files changed, 43 insertions(+), 8 deletions(-)
create mode 100644 docs/integrations/bitbucket/api-CHANGE-2770.md
diff --git a/docs/integrations/bitbucket/api-CHANGE-2770.md b/docs/integrations/bitbucket/api-CHANGE-2770.md
new file mode 100644
index 00000000..7bca26ec
--- /dev/null
+++ b/docs/integrations/bitbucket/api-CHANGE-2770.md
@@ -0,0 +1,38 @@
+# Bitbucket API Migration: CHANGE-2770
+
+## Replacing Deprecated API
+
+1. Issue found in finding repository and webhook integration.
+2. The error-returning APIs are deprecated, and here is the link to the deprecated API's: [Link](https://developer.atlassian.com/cloud/bitbucket/changelog/#CHANGE-2770)
+
+## How this was fixed and what changes are made?
+
+1. Updated these API with new API endpoints [Link](https://developer.atlassian.com/cloud/bitbucket/changelog/#CHANGE-3022:~:text=ANNOUNCEMENT,per%20app%20installation.)
+2. The API endpoint should have new scope `read:workspace:bitbucket`
+3. Updated network doc and validated function pointing.
+
+## Detailed API Flow Changes in LiveReview
+
+To comply with this deprecation, our project discovery flow in `internal/providers/bitbucket/project_discovery.go` underwent a significant refactor from a single-call pattern to a multi-step iterative pattern.
+
+### Old Deprecated Flow
+Previously, LiveReview could retrieve data globally across all workspaces in a single step using cross-workspace endpoints:
+- **`GET /2.0/workspaces`** (Removed)
+- **`GET /2.0/repositories`** (Removed)
+- **`GET /2.0/user/permissions/workspaces`** (Removed)
+
+*Why it failed:* Atlassian physically removed these endpoints, causing them to return `410 Gone` HTTP errors.
+
+### New Compliant Flow (CHANGE-2770 & CHANGE-3022)
+We have migrated to **workspace-scoped** API endpoints. The retrieval process is now broken down into sequential steps:
+
+1. **Discover Accessible Workspaces**:
+ - **Endpoint:** `GET /2.0/user/workspaces`
+ - **Action:** LiveReview first calls this new endpoint to fetch every workspace the authenticated user is a member of. This specifically relies on the `read:workspace:bitbucket` token scope.
+
+2. **Iterative Repository Discovery**:
+ - **Endpoint:** `GET /2.0/repositories/{workspace}?role=member`
+ - **Action:** Instead of one massive global query, the code iterates through every `slug` returned in Step 1. For each workspace, it performs a separate scoped API call to list the repositories belonging to that specific workspace.
+ - **Handling Permissions:** Because the `GET /2.0/user/permissions/workspaces` endpoint was removed, we now enforce permissions at the repository query level. By appending the `?role=member` query parameter to the repositories endpoint, Bitbucket automatically filters the response to only return repositories where the user has explicit member permissions, completely replacing the need for a separate permissions check beforehand.
+
+These changes are strictly required by Bitbucket Cloud to maintain security, performance, and per-app installation constraints.
diff --git a/internal/providers/bitbucket/project_discovery.go b/internal/providers/bitbucket/project_discovery.go
index 04592cc2..b4f5074e 100644
--- a/internal/providers/bitbucket/project_discovery.go
+++ b/internal/providers/bitbucket/project_discovery.go
@@ -45,11 +45,8 @@ type BitbucketWorkspaceAPIResponse struct {
}
// DiscoverProjectsBitbucket fetches all repositories accessible with the given credentials.
-//
-// Migration from deprecated APIs (CHANGE-2770):
-// - Removed: GET /2.0/repositories → replaced by /user/workspaces + /repositories/{workspace}
-// - Removed: GET /2.0/workspaces → replaced by GET /2.0/user/workspaces
-// - Removed: GET /2.0/user/permissions/workspaces → replaced by GET /2.0/user/workspaces
+// For details on the multi-step API flow due to Atlassian deprecations,
+// see docs/integrations/bitbucket/api-CHANGE-2770.md
func DiscoverProjectsBitbucket(baseURL, email, apiToken string) ([]string, error) {
client := &http.Client{}
@@ -121,9 +118,9 @@ func DiscoverProjectsBitbucketForWorkspaces(baseURL, email, apiToken string, wor
return all, nil
}
-// getUserWorkspaces calls GET /2.0/user/workspaces — the new public API announced alongside
-// CHANGE-2770, replacing both the deprecated GET /2.0/workspaces and
-// GET /2.0/user/permissions/workspaces endpoints.
+// getUserWorkspaces calls GET /2.0/user/workspaces to list all accessible workspaces.
+// For details on the multi-step API flow due to Atlassian deprecations,
+// see docs/integrations/bitbucket/api-CHANGE-2770.md
func getUserWorkspaces(client *http.Client, apiBaseURL, email, apiToken string) ([]BitbucketWorkspaceBasic, error) {
var all []BitbucketWorkspaceBasic
nextURL := fmt.Sprintf("%s/user/workspaces", apiBaseURL)