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 da403d6f..b4f5074e 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 @@ -22,228 +25,166 @@ 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. +// 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) { - 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) + fmt.Printf("Warning: failed to get repositories for workspace %s: %v\n", ws, err) + continue } - 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)) - } - - // 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 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) + ctx := context.Background() 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) + 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() - // 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)) + resp.Body.Close() + 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) - } - - // Add repositories to result - for _, repo := range response.Values { - repositories = append(repositories, repo.FullName) + resp.Body.Close() + return nil, fmt.Errorf("failed to decode workspace response: %w", err) } + resp.Body.Close() - // 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()) + ctx := context.Background() 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) + 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() - // 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)) + resp.Body.Close() + 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) + resp.Body.Close() + return nil, fmt.Errorf("failed to decode repository response: %w", err) } + resp.Body.Close() - // 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/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) +} 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 ? (