diff --git a/CHANGELOG.md b/CHANGELOG.md index e41a1ba65..cb05995bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ### Changed - Hide version upgrade toast for askgithub deployment (`EXPERIMENT_ASK_GH_ENABLED`). [#931](https://github.com/sourcebot-dev/sourcebot/pull/931) diff --git a/docs/docs/features/permission-syncing.mdx b/docs/docs/features/permission-syncing.mdx index cb801288d..12ddf3645 100644 --- a/docs/docs/features/permission-syncing.mdx +++ b/docs/docs/features/permission-syncing.mdx @@ -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 | 🛑 | @@ -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. + + +**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. + + +**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 diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index ef0b547e6..fd4c03ed3 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -1,5 +1,5 @@ -import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud"; -import { createBitbucketServerClient } from "@coderabbitai/bitbucket/server"; +import { createBitbucketCloudClient as createBitbucketCloudClientBase } from "@coderabbitai/bitbucket/cloud"; +import { createBitbucketServerClient as createBitbucketServerClientBase } from "@coderabbitai/bitbucket/server"; import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch"; import { createLogger } from "@sourcebot/shared"; @@ -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"; @@ -34,10 +36,10 @@ interface BitbucketClient { shouldExcludeRepo: (repo: BitbucketRepository, config: BitbucketConnectionConfig) => boolean; } -type CloudAPI = ReturnType; +type CloudAPI = ReturnType; type CloudGetRequestPath = ClientPathsWithMethod; -type ServerAPI = ReturnType; +type ServerAPI = ReturnType; type ServerGetRequestPath = ClientPathsWithMethod; type CloudPaginatedResponse = { @@ -68,8 +70,8 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon } const client = config.deploymentType === 'server' ? - serverClient(config.url!, config.user, token) : - cloudClient(config.user, token); + createBitbucketServerClient(config.url!, config.user, token) : + createBitbucketCloudClient(config.user, token); let allRepos: BitbucketRepository[] = []; let allWarnings: string[] = []; @@ -102,11 +104,10 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon }; } -function cloudClient(user: string | undefined, token: string | undefined): BitbucketClient { - +export function createBitbucketCloudClient(user: string | undefined, token: string | undefined): BitbucketClient { const authorizationString = token - ? !user || user == "x-token-auth" + ? (!user || user === "x-token-auth") ? `Bearer ${token}` : `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}` : undefined; @@ -119,7 +120,7 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu }, }; - const apiClient = createBitbucketCloudClient(clientOptions); + const apiClient = createBitbucketCloudClientBase(clientOptions); var client: BitbucketClient = { deploymentType: BITBUCKET_CLOUD, token: token, @@ -378,7 +379,7 @@ export function cloudShouldExcludeRepo(repo: BitbucketRepository, config: Bitbuc return false; } -function serverClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient { +function createBitbucketServerClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient { const authorizationString = (() => { // If we're not given any credentials we return an empty auth string. This will only work if the project/repos are public if(!user && !token) { @@ -400,7 +401,7 @@ function serverClient(url: string, user: string | undefined, token: string | und }, }; - const apiClient = createBitbucketServerClient(clientOptions); + const apiClient = createBitbucketServerClientBase(clientOptions); var client: BitbucketClient = { deploymentType: BITBUCKET_SERVER, token: token, @@ -560,7 +561,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)) { @@ -587,4 +588,69 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu return true; } return false; -} \ No newline at end of file +} + +/** + * 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 ( + client: BitbucketClient, + workspace: string, + repoSlug: string, +): Promise> => { + const path = `/repositories/${workspace}/${repoSlug}/permissions-config/users` as CloudGetRequestPath; + + const users = await getPaginatedCloud(path, async (p, query) => { + const response = await client.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 ( + client: BitbucketClient, +): Promise> => { + const path = `/user/permissions/repositories` as CloudGetRequestPath; + + const permissions = await getPaginatedCloud(path, async (p, query) => { + const response = await client.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 })); +}; \ No newline at end of file diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index dc3d7fcce..76e865ed7 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -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'); diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index 283a0fcf9..3cbc02639 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -1,5 +1,5 @@ import * as Sentry from "@sentry/node"; -import { PrismaClient, AccountPermissionSyncJobStatus, Account} from "@sourcebot/db"; +import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db"; import { env, hasEntitlement, createLogger, loadConfig, decryptOAuthToken } from "@sourcebot/shared"; import { Job, Queue, Worker } from "bullmq"; import { Redis } from "ioredis"; @@ -14,6 +14,7 @@ import { getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser, getProjectsForAuthenticatedUser, } from "../gitlab.js"; +import { createBitbucketCloudClient, getReposForAuthenticatedBitbucketCloudUser } from "../bitbucket.js"; import { Settings } from "../types.js"; import { setIntervalAsync } from "../utils.js"; @@ -266,6 +267,27 @@ 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.`); + } + + // @note: we don't pass a user here since we want to use a bearer token + // for authentication. + const client = createBitbucketCloudClient(/* user = */ undefined, accessToken) + const bitbucketRepos = await getReposForAuthenticatedBitbucketCloudUser(client); + 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)); } @@ -287,6 +309,7 @@ export class AccountPermissionSyncer { data: repoIds.map(repoId => ({ accountId: account.id, repoId, + source: PermissionSyncSource.ACCOUNT_DRIVEN, })), skipDuplicates: true, }) diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index 618746d59..dfae24ae9 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -1,5 +1,5 @@ import * as Sentry from "@sentry/node"; -import { PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db"; +import { PermissionSyncSource, PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db"; import { createLogger } from "@sourcebot/shared"; import { env, hasEntitlement } from "@sourcebot/shared"; import { Job, Queue, Worker } from 'bullmq'; @@ -7,8 +7,11 @@ 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 { createBitbucketCloudClient, getExplicitUserPermissionsForCloudRepo } from "../bitbucket.js"; +import { repoMetadataSchema } from "@sourcebot/shared"; import { Settings } from "../types.js"; import { getAuthCredentialsForRepo, setIntervalAsync } from "../utils.js"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/index.type"; type RepoPermissionSyncJob = { jobId: string; @@ -181,7 +184,13 @@ export class RepoPermissionSyncer { throw new Error(`No credentials found for repo ${id}`); } - const accountIds = await (async () => { + const { + accountIds, + isPartialSync = false, + } = await (async (): Promise<{ + accountIds: string[], + isPartialSync?: boolean + }> => { if (repo.external_codeHostType === 'github') { const isGitHubCloud = credentials.hostUrl ? new URL(credentials.hostUrl).hostname === GITHUB_CLOUD_HOSTNAME : true; const { octokit } = await createOctokitFromToken({ @@ -210,7 +219,9 @@ export class RepoPermissionSyncer { }, }); - return accounts.map(account => account.id); + return { + accountIds: accounts.map(account => account.id), + } } else if (repo.external_codeHostType === 'gitlab') { const api = await createGitLabFromPersonalAccessToken({ token: credentials.token, @@ -234,10 +245,58 @@ export class RepoPermissionSyncer { }, }); - return accounts.map(account => account.id); + return { + accountIds: accounts.map(account => account.id), + } + } else if (repo.external_codeHostType === 'bitbucketCloud') { + const config = credentials.connectionConfig as BitbucketConnectionConfig | undefined; + if (!config) { + throw new Error(`No connection config found for repo ${id}`); + } + + const client = createBitbucketCloudClient(config.user, credentials.token); + + const parsedMetadata = repoMetadataSchema.safeParse(repo.metadata); + if (!parsedMetadata.success) { + throw new Error(`Repo ${id} has invalid metadata: ${JSON.stringify(parsedMetadata.error.errors)}`); + } + const bitbucketCloudMetadata = parsedMetadata.data.codeHostMetadata?.bitbucketCloud; + 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(client, workspace, repoSlug); + const userAccountIds = users.map(u => u.accountId); + + const accounts = await this.db.account.findMany({ + where: { + provider: 'bitbucket-cloud', + providerAccountId: { + in: userAccountIds, + } + }, + }); + + return { + accountIds: accounts.map(account => account.id), + // Since we only fetch users who have been explicitly granted access to the repo, + // this is a partial sync. + isPartialSync: true, + } } - return []; + return { + accountIds: [], + } })(); await this.db.$transaction([ @@ -247,7 +306,11 @@ export class RepoPermissionSyncer { }, data: { permittedAccounts: { - deleteMany: {}, + // @note: if this is a partial sync, we only want to delete the repo-driven permissions + // since we don't want to overwrite the account-driven permissions. + deleteMany: isPartialSync ? { + source: PermissionSyncSource.REPO_DRIVEN, + } : {}, } } }), @@ -255,7 +318,9 @@ export class RepoPermissionSyncer { data: accountIds.map(accountId => ({ accountId, repoId: repo.id, + source: PermissionSyncSource.REPO_DRIVEN, })), + skipDuplicates: true, }) ]); } diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index a6707b4df..0365758a2 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -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, }; diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 70e16c05d..8803b48b9 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -1,4 +1,5 @@ import { Connection, Repo, RepoToConnection } from "@sourcebot/db"; +import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type"; export type Settings = Required; @@ -19,4 +20,8 @@ export type RepoAuthCredentials = { token: string; cloneUrlWithToken?: string; authHeader?: string; + /** The connection that configured the + * credentials for this repo. + */ + connectionConfig?: ConnectionConfig; } \ No newline at end of file diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index f897e6fb7..79b7c8b6c 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -145,6 +145,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logge password: token, } ), + connectionConfig: config, } } } else if (connection.connectionType === 'gitlab') { @@ -161,6 +162,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logge password: token } ), + connectionConfig: config, } } } else if (connection.connectionType === 'gitea') { @@ -176,6 +178,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logge password: token } ), + connectionConfig: config, } } } else if (connection.connectionType === 'bitbucket') { @@ -193,6 +196,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logge password: token } ), + connectionConfig: config, } } } else if (connection.connectionType === 'azuredevops') { @@ -223,6 +227,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logge password: token } ), + connectionConfig: config, } } } diff --git a/packages/db/prisma/migrations/20260224202008_add_source_field_to_account_to_repo_permission_table/migration.sql b/packages/db/prisma/migrations/20260224202008_add_source_field_to_account_to_repo_permission_table/migration.sql new file mode 100644 index 000000000..c61add7ee --- /dev/null +++ b/packages/db/prisma/migrations/20260224202008_add_source_field_to_account_to_repo_permission_table/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "PermissionSyncSource" AS ENUM ('ACCOUNT_DRIVEN', 'REPO_DRIVEN'); + +-- AlterTable +ALTER TABLE "AccountToRepoPermission" ADD COLUMN "source" "PermissionSyncSource" NOT NULL DEFAULT 'ACCOUNT_DRIVEN'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 16c2460eb..6421e7c27 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -390,6 +390,11 @@ model AccountPermissionSyncJob { accountId String } +enum PermissionSyncSource { + ACCOUNT_DRIVEN + REPO_DRIVEN +} + model AccountToRepoPermission { createdAt DateTime @default(now()) @@ -399,6 +404,8 @@ model AccountToRepoPermission { account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) accountId String + source PermissionSyncSource @default(ACCOUNT_DRIVEN) + @@id([repoId, accountId]) } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 2a556491a..4e69c42fc 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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; diff --git a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts index 6ea5849ac..234a272fa 100644 --- a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts +++ b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts @@ -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: {