Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added PostHog events for chat UI interactions (details card expand/collapse, copy answer, table of contents toggle) and repo tracking in `wa_chat_message_sent`. [#922](https://github.com/sourcebot-dev/sourcebot/pull/922)
- Added Bitbucket Cloud OAuth identity provider support (`provider: "bitbucket-cloud"`) for SSO and account-linked permission syncing. [#924](https://github.com/sourcebot-dev/sourcebot/pull/924)
- Added permission syncing support for Bitbucket Cloud. [#925](https://github.com/sourcebot-dev/sourcebot/pull/925)

## [4.11.7] - 2026-02-23

Expand Down
24 changes: 23 additions & 1 deletion docs/docs/features/permission-syncing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ We are actively working on supporting more code hosts. If you'd like to see a sp
|:----------|------------------------------|
| [GitHub (GHEC & GHEC Server)](/docs/features/permission-syncing#github) | ✅ |
| [GitLab (Self-managed & Cloud)](/docs/features/permission-syncing#gitlab) | ✅ |
| Bitbucket Cloud | 🛑 |
| [Bitbucket Cloud](/docs/features/permission-syncing#bitbucket-cloud) | ⚠️ Partial |
| Bitbucket Data Center | 🛑 |
| Gitea | 🛑 |
| Gerrit | 🛑 |
Expand Down Expand Up @@ -78,6 +78,28 @@ Permission syncing works with **GitLab Self-managed** and **GitLab Cloud**. User
- OAuth tokens require the `read_api` scope in order to use the [List projects for the authenticated user API](https://docs.gitlab.com/ee/api/projects.html#list-all-projects) during [User driven syncing](/docs/features/permission-syncing#how-it-works).
- [Internal GitLab projects](https://docs.gitlab.com/user/public_access/#internal-projects-and-groups) are **not** enforced by permission syncing and therefore are visible to all users. Only [private projects](https://docs.gitlab.com/user/public_access/#private-projects-and-groups) are enforced.

## Bitbucket Cloud

Prerequisites:
- Configure Bitbucket Cloud as an [external identity provider](/docs/configuration/idp).

Permission syncing works with **Bitbucket Cloud**. OAuth tokens must assume the `account` and `repository` scopes.

<Warning>
**Partial coverage for repo-driven syncing.** Bitbucket Cloud's [repository user permissions API](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get) only returns users who have been **directly and explicitly** granted access to a repository. Users who have access via any of the following are **not** captured by repo-driven syncing:

- Membership in a [group that is added to the repository](https://support.atlassian.com/bitbucket-cloud/docs/grant-repository-access-to-users-and-groups/)
- Membership in the [project that contains the repository](https://support.atlassian.com/bitbucket-cloud/docs/configure-project-permissions-for-users-and-groups/)
- Membership in a group that is part of a project containing the repository

These users **will** still gain access via [user-driven syncing](/docs/features/permission-syncing#how-it-works), which fetches all private repositories accessible to each authenticated user. However, there may be a delay between when a repository is added and when affected users gain access in Sourcebot (up to the `experiment_userDrivenPermissionSyncIntervalMs` interval, which defaults to 24 hours).

If your workspace relies heavily on group or project-level permissions rather than direct user grants, we recommend reducing the `experiment_userDrivenPermissionSyncIntervalMs` interval to limit the window of delay.
</Warning>

**Notes:**
- A Bitbucket Cloud [external identity provider](/docs/configuration/idp) must be configured to (1) correlate a Sourcebot user with a Bitbucket Cloud user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works).
- OAuth tokens require the `account` and `repository` scopes. The `repository` scope is required to list private repositories during [User driven syncing](/docs/features/permission-syncing#how-it-works).

# How it works

Expand Down
87 changes: 85 additions & 2 deletions packages/backend/src/bitbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import * as Sentry from "@sentry/node";
import micromatch from "micromatch";
import {
SchemaRepository as CloudRepository,
SchemaRepositoryUserPermission as CloudRepositoryUserPermission,
SchemaRepositoryPermission as CloudRepositoryPermission,
} from "@coderabbitai/bitbucket/cloud/openapi";
import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi";
import { processPromiseResults } from "./connectionUtils.js";
Expand Down Expand Up @@ -560,7 +562,7 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu
const repoSlug = serverRepo.slug!;
const repoName = `${projectName}/${repoSlug}`;
let reason = '';

const shouldExclude = (() => {
if (config.exclude?.repos) {
if (micromatch.isMatch(repoName, config.exclude.repos)) {
Expand All @@ -587,4 +589,85 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu
return true;
}
return false;
}
}

/**
* Returns the account IDs of users who have been *explicitly* granted permission on a Bitbucket Cloud repository.
*
* @note This only covers direct user-to-repo grants. It does NOT include users who have access via:
* - A group that is explicitly added to the repo
* - Membership in the project that contains the repo
* - A group that is part of a project that contains the repo
* As a result, permission syncing may under-grant access for workspaces that rely on group or
* project-level permissions rather than direct user grants.
*
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get
*/
export const getExplicitUserPermissionsForCloudRepo = async (
workspace: string,
repoSlug: string,
token: string | undefined,
): Promise<Array<{ accountId: string }>> => {
const apiClient = createBitbucketCloudClient({
baseUrl: BITBUCKET_CLOUD_API,
headers: {
Accept: "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});

const path = `/repositories/${workspace}/${repoSlug}/permissions-config/users` as CloudGetRequestPath;

const users = await getPaginatedCloud<CloudRepositoryUserPermission>(path, async (p, query) => {
const response = await apiClient.GET(p, {
params: {
path: { workspace, repo_slug: repoSlug },
query,
},
});
const { data, error } = response;
if (error) {
throw new Error(`Failed to get explicit user permissions for ${workspace}/${repoSlug}: ${JSON.stringify(error)}`);
}
return data;
});

return users
.filter(u => u.user?.account_id != null)
.map(u => ({ accountId: u.user!.account_id as string }));
};

/**
* Returns the UUIDs of all private repositories accessible to the authenticated Bitbucket Cloud user.
* Used for account-driven permission syncing.
*
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-user-permissions-repositories-get
*/
export const getReposForAuthenticatedBitbucketCloudUser = async (
accessToken: string,
): Promise<Array<{ uuid: string }>> => {
const apiClient = createBitbucketCloudClient({
baseUrl: BITBUCKET_CLOUD_API,
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`,
},
});

const path = `/user/permissions/repositories` as CloudGetRequestPath;

const permissions = await getPaginatedCloud<CloudRepositoryPermission>(path, async (p, query) => {
const response = await apiClient.GET(p, {
params: { query },
});
const { data, error } = response;
if (error) {
throw new Error(`Failed to get user repository permissions: ${JSON.stringify(error)}`);
}
return data;
});

return permissions
.filter(p => p.repository?.uuid != null)
.map(p => ({ uuid: p.repository!.uuid as string }));
};
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ export const SINGLE_TENANT_ORG_ID = 1;
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES: CodeHostType[] = [
'github',
'gitlab',
'bitbucketCloud',
];

export const PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS: IdentityProviderType[] = [
'github',
'gitlab',
'bitbucket-cloud',
];

export const REPOS_CACHE_DIR = path.join(env.DATA_CACHE_DIR, 'repos');
Expand Down
19 changes: 19 additions & 0 deletions packages/backend/src/ee/accountPermissionSyncer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser,
getProjectsForAuthenticatedUser,
} from "../gitlab.js";
import { getReposForAuthenticatedBitbucketCloudUser } from "../bitbucket.js";
import { Settings } from "../types.js";
import { setIntervalAsync } from "../utils.js";

Expand Down Expand Up @@ -266,6 +267,24 @@ export class AccountPermissionSyncer {
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
} else if (account.provider === 'bitbucket-cloud') {
if (!accessToken) {
throw new Error(`User '${account.user.email}' does not have a Bitbucket Cloud OAuth access token associated with their account. Please re-authenticate with Bitbucket Cloud to refresh the token.`);
}

const bitbucketRepos = await getReposForAuthenticatedBitbucketCloudUser(accessToken);
const bitbucketRepoUuids = bitbucketRepos.map(repo => repo.uuid);

const repos = await this.db.repo.findMany({
where: {
external_codeHostType: 'bitbucketCloud',
external_id: {
in: bitbucketRepoUuids,
}
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
}

Expand Down
31 changes: 31 additions & 0 deletions packages/backend/src/ee/repoPermissionSyncer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { Redis } from 'ioredis';
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js";
import { createGitLabFromPersonalAccessToken, getProjectMembers } from "../gitlab.js";
import { getExplicitUserPermissionsForCloudRepo } from "../bitbucket.js";
import { repoMetadataSchema } from "@sourcebot/shared";
import { Settings } from "../types.js";
import { getAuthCredentialsForRepo, setIntervalAsync } from "../utils.js";

Expand Down Expand Up @@ -234,6 +236,35 @@ export class RepoPermissionSyncer {
},
});

return accounts.map(account => account.id);
} else if (repo.external_codeHostType === 'bitbucketCloud') {
const parsedMetadata = repoMetadataSchema.safeParse(repo.metadata);
const bitbucketCloudMetadata = parsedMetadata.success ? parsedMetadata.data.codeHostMetadata?.bitbucketCloud : undefined;
if (!bitbucketCloudMetadata) {
throw new Error(`Repo ${id} is missing required Bitbucket Cloud metadata (workspace/repoSlug)`);
}

const { workspace, repoSlug } = bitbucketCloudMetadata;

// @note: The Bitbucket Cloud permissions API only returns users who have been *directly*
// granted access to this repository. Users who have access via a group added to the repo,
// via project-level membership, or via a group in a project are NOT captured here.
// These users will still gain access through user-driven syncing (accountPermissionSyncer),
// but there may be a delay of up to `experiment_userDrivenPermissionSyncIntervalMs` before
// they see the repository in Sourcebot.
// @see: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get
const users = await getExplicitUserPermissionsForCloudRepo(workspace, repoSlug, credentials.token);
const userAccountIds = users.map(u => u.accountId);

const accounts = await this.db.account.findMany({
where: {
provider: 'bitbucket-cloud',
providerAccountId: {
in: userAccountIds,
}
},
});

return accounts.map(account => account.id);
}

Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/repoCompileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,14 @@ export const compileBitbucketConfig = async (
},
branches: config.revisions?.branches ?? undefined,
tags: config.revisions?.tags ?? undefined,
...(codeHostType === 'bitbucketCloud' ? {
codeHostMetadata: {
bitbucketCloud: {
workspace: (repo as BitbucketCloudRepository).full_name!.split('/')[0]!,
repoSlug: (repo as BitbucketCloudRepository).full_name!.split('/')[1]!,
}
}
} : {}),
} satisfies RepoMetadata,
};

Expand Down
10 changes: 10 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ export const repoMetadataSchema = z.object({
* A list of revisions that were indexed for the repo.
*/
indexedRevisions: z.array(z.string()).optional(),

/**
* Code host specific metadata, keyed by code host type.
*/
codeHostMetadata: z.object({
bitbucketCloud: z.object({
workspace: z.string(),
repoSlug: z.string(),
}).optional(),
}).optional(),
});

export type RepoMetadata = z.infer<typeof repoMetadataSchema>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const GET = apiHandler(async () => {
const accounts = await prisma.account.findMany({
where: {
userId: user.id,
provider: { in: ['github', 'gitlab'] }
provider: { in: ['github', 'gitlab', 'bitbucket-cloud'] }
},
include: {
permissionSyncJobs: {
Expand Down