Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/integrations/bitbucket/api-CHANGE-2770.md
Original file line number Diff line number Diff line change
@@ -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.
241 changes: 91 additions & 150 deletions internal/providers/bitbucket/project_discovery.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}

Expand Down
6 changes: 4 additions & 2 deletions network/network_status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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) |
Loading
Loading