diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e0b6c93..b09c9a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [Sourcebot EE] Added search contexts, user-defined groupings of repositories that help focus searches on specific areas of a codebase. [#273](https://github.com/sourcebot-dev/sourcebot/pull/273) +- Added support for Bitbucket Cloud and Bitbucket Data Center connections. [#275](https://github.com/sourcebot-dev/sourcebot/pull/275) ## [3.0.4] - 2025-04-12 diff --git a/docs/docs.json b/docs/docs.json index 75af3813..18c40dc2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -31,6 +31,8 @@ "docs/connections/overview", "docs/connections/github", "docs/connections/gitlab", + "docs/connections/bitbucket-cloud", + "docs/connections/bitbucket-data-center", "docs/connections/gitea", "docs/connections/gerrit", "docs/connections/request-new" diff --git a/docs/docs/connections/bitbucket-cloud.mdx b/docs/docs/connections/bitbucket-cloud.mdx new file mode 100644 index 00000000..aa7b47f0 --- /dev/null +++ b/docs/docs/connections/bitbucket-cloud.mdx @@ -0,0 +1,201 @@ +--- +title: Linking code from Bitbucket Cloud +sidebarTitle: Bitbucket Cloud +--- + +import BitbucketToken from '/snippets/bitbucket-token.mdx'; +import BitbucketAppPassword from '/snippets/bitbucket-app-password.mdx'; + +## Examples + + + + ```json + { + "type": "bitbucket", + "deploymentType": "cloud", + "repos": [ + "myWorkspace/myRepo" + ] + } + ``` + + + ```json + { + "type": "bitbucket", + "deploymentType": "cloud", + "workspaces": [ + "myWorkspace" + ] + } + ``` + + + ```json + { + "type": "bitbucket", + "deploymentType": "cloud", + "projects": [ + "myProject" + ] + } + ``` + + + ```json + { + "type": "bitbucket", + "deploymentType": "cloud", + // Include all repos in my-workspace... + "workspaces": [ + "myWorkspace" + ], + // ...except: + "exclude": { + // repos that are archived + "archived": true, + // repos that are forks + "forks": true, + // repos that match these glob patterns + "repos": [ + "myWorkspace/repo1", + "myWorkspace2/*" + ] + } + } + ``` + + + +## Authenticating with Bitbucket Cloud + +In order to index private repositories, you'll need to provide authentication credentials. You can do this using an `App Password` or an `Access Token` + + + + Navigate to the [app password creation page](https://bitbucket.org/account/settings/app-passwords/) and create an app password. Ensure that it has the proper permissions for the scope + of info you want to fetch (i.e. workspace, project, and/or repo level) + ![Bitbucket App Password Permissions](/images/bitbucket_app_password_perms.png) + + Next, provide your username + app password pair to Sourcebot: + + + + + Create an access token for the desired scope (repo, project, or workspace). Visit the official [Bitbucket Cloud docs](https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/) + for more info. + + Next, provide the access token to Sourcebot: + + + + + + +## Schema reference + + +[schemas/v3/bitbucket.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/bitbucket.json) + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "user": { + "type": "string", + "description": "The username to use for authentication. Only needed if token is an app password." + }, + "token": { + "$ref": "./shared.json#/definitions/Token", + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": ["cloud", "server"], + "default": "cloud", + "description": "The type of Bitbucket deployment" + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspaces to sync. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of projects to sync" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repos to sync" + }, + "exclude": { + "type": "object", + "properties": { + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "cloud_workspace/repo1", + "server_project/repo2" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "$ref": "./shared.json#/definitions/GitRevisions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false +} +``` + + \ No newline at end of file diff --git a/docs/docs/connections/bitbucket-data-center.mdx b/docs/docs/connections/bitbucket-data-center.mdx new file mode 100644 index 00000000..451192cb --- /dev/null +++ b/docs/docs/connections/bitbucket-data-center.mdx @@ -0,0 +1,180 @@ +--- +title: Linking code from Bitbucket Data Center +sidebarTitle: Bitbucket Data Center +--- + +import BitbucketToken from '/snippets/bitbucket-token.mdx'; +import BitbucketAppPassword from '/snippets/bitbucket-app-password.mdx'; + +## Examples + + + + ```json + { + "type": "bitbucket", + "deploymentType": "server", + "url": "https://mybitbucketdeployment.com", + "repos": [ + "myProject/myRepo" + ] + } + ``` + + + ```json + { + "type": "bitbucket", + "deploymentType": "server", + "url": "https://mybitbucketdeployment.com", + "projects": [ + "myProject" + ] + } + ``` + + + ```json + { + "type": "bitbucket", + "deploymentType": "server", + "url": "https://mybitbucketdeployment.com", + // Include all repos in myProject... + "projects": [ + "myProject" + ], + // ...except: + "exclude": { + // repos that are archived + "archived": true, + // repos that are forks + "forks": true, + // repos that match these glob patterns + "repos": [ + "myProject/repo1", + "myProject2/*" + ] + } + } + ``` + + + +## Authenticating with Bitbucket Data Center + +In order to index private repositories, you'll need to provide an access token to Sourcebot. + +Create an access token for the desired scope (repo, project, or workspace). Visit the official [Bitbucket Data Center docs](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html) +for more info. + +Next, provide the access token to Sourcebot: + + + + +## Schema reference + + +[schemas/v3/bitbucket.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/bitbucket.json) + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "user": { + "type": "string", + "description": "The username to use for authentication. Only needed if token is an app password." + }, + "token": { + "$ref": "./shared.json#/definitions/Token", + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": ["cloud", "server"], + "default": "cloud", + "description": "The type of Bitbucket deployment" + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspaces to sync. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of projects to sync" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repos to sync" + }, + "exclude": { + "type": "object", + "properties": { + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "cloud_workspace/repo1", + "server_project/repo2" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "$ref": "./shared.json#/definitions/GitRevisions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false +} +``` + + \ No newline at end of file diff --git a/docs/docs/connections/overview.mdx b/docs/docs/connections/overview.mdx index a105a3de..f7476947 100644 --- a/docs/docs/connections/overview.mdx +++ b/docs/docs/connections/overview.mdx @@ -26,6 +26,8 @@ There are two ways to define connections: + + diff --git a/docs/docs/overview.mdx b/docs/docs/overview.mdx index 8edf17cd..04aa3bd9 100644 --- a/docs/docs/overview.mdx +++ b/docs/docs/overview.mdx @@ -2,8 +2,6 @@ title: "Overview" --- -import ConnectionCards from '/snippets/connection-cards.mdx'; - Sourcebot is an **[open-source](https://github.com/sourcebot-dev/sourcebot) code search tool** that is purpose built to search multi-million line codebases in seconds. It integrates with [GitHub](/docs/connections/github), [GitLab](/docs/connections/gitlab), and [other platforms](/docs/connections). ## Getting Started diff --git a/docs/images/bitbucket_app_password_perms.png b/docs/images/bitbucket_app_password_perms.png new file mode 100644 index 00000000..1fc61955 Binary files /dev/null and b/docs/images/bitbucket_app_password_perms.png differ diff --git a/docs/self-hosting/overview.mdx b/docs/self-hosting/overview.mdx index a07b8082..977a1de3 100644 --- a/docs/self-hosting/overview.mdx +++ b/docs/self-hosting/overview.mdx @@ -76,8 +76,10 @@ Sourcebot is open source and can be self-hosted using our official [Docker image Sourcebot supports indexing public & private code on the following code hosts: - - + + + + diff --git a/docs/snippets/bitbucket-app-password.mdx b/docs/snippets/bitbucket-app-password.mdx new file mode 100644 index 00000000..ac8a1e27 --- /dev/null +++ b/docs/snippets/bitbucket-app-password.mdx @@ -0,0 +1,51 @@ + + + Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + + 1. Add the `token` and `user` (username associated with the app password you created) properties to your connection config: + ```json + { + "type": "bitbucket", + "deploymentType": "cloud", + "user": "myusername", + "token": { + // note: this env var can be named anything. It + // doesn't need to be `BITBUCKET_TOKEN`. + "env": "BITBUCKET_TOKEN" + } + // .. rest of config .. + } + ``` + + 2. Pass this environment variable each time you run Sourcebot: + ```bash + docker run \ + -e BITBUCKET_TOKEN= \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest + ``` + + + + Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + + 1. Navigate to **Secrets** in settings and create a new secret with your access token: + + ![](/images/secrets_list.png) + + 2. Add the `token` and `user` (username associated with the app password you created) properties to your connection config: + + ```json + { + "type": "bitbucket", + "deploymentType": "cloud", + "user": "myusername", + "token": { + "secret": "mysecret" + } + // .. rest of config .. + } + ``` + + + \ No newline at end of file diff --git a/docs/snippets/bitbucket-token.mdx b/docs/snippets/bitbucket-token.mdx new file mode 100644 index 00000000..48f27a87 --- /dev/null +++ b/docs/snippets/bitbucket-token.mdx @@ -0,0 +1,47 @@ + + + Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + + 1. Add the `token` property to your connection config: + ```json + { + "type": "bitbucket", + "token": { + // note: this env var can be named anything. It + // doesn't need to be `BITBUCKET_TOKEN`. + "env": "BITBUCKET_TOKEN" + } + // .. rest of config .. + } + ``` + + 2. Pass this environment variable each time you run Sourcebot: + ```bash + docker run \ + -e BITBUCKET_TOKEN= \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest + ``` + + + + Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + + 1. Navigate to **Secrets** in settings and create a new secret with your PAT: + + ![](/images/secrets_list.png) + + 2. Add the `token` property to your connection config: + + ```json + { + "type": "bitbucket", + "token": { + "secret": "mysecret" + } + // .. rest of config .. + } + ``` + + + \ No newline at end of file diff --git a/docs/snippets/connection-cards.mdx b/docs/snippets/connection-cards.mdx deleted file mode 100644 index 1e224bf4..00000000 --- a/docs/snippets/connection-cards.mdx +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/package.json b/package.json index 778f7084..72631c23 100644 --- a/package.json +++ b/package.json @@ -20,5 +20,8 @@ "dotenv-cli": "^8.0.0", "npm-run-all": "^4.1.5" }, - "packageManager": "yarn@4.7.0" + "packageManager": "yarn@4.7.0", + "dependencies": { + "@coderabbitai/bitbucket": "^1.1.3" + } } diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts new file mode 100644 index 00000000..5ffdf7e0 --- /dev/null +++ b/packages/backend/src/bitbucket.ts @@ -0,0 +1,553 @@ +import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud"; +import { createBitbucketServerClient } from "@coderabbitai/bitbucket/server"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; +import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch"; +import { createLogger } from "./logger.js"; +import { PrismaClient } from "@sourcebot/db"; +import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; +import * as Sentry from "@sentry/node"; +import { + SchemaRepository as CloudRepository, +} from "@coderabbitai/bitbucket/cloud/openapi"; +import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi"; +import { processPromiseResults } from "./connectionUtils.js"; +import { throwIfAnyFailed } from "./connectionUtils.js"; + +const logger = createLogger("Bitbucket"); +const BITBUCKET_CLOUD_GIT = 'https://bitbucket.org'; +const BITBUCKET_CLOUD_API = 'https://api.bitbucket.org/2.0'; +const BITBUCKET_CLOUD = "cloud"; +const BITBUCKET_SERVER = "server"; + +export type BitbucketRepository = CloudRepository | ServerRepository; + +interface BitbucketClient { + deploymentType: string; + token: string | undefined; + apiClient: any; + baseUrl: string; + gitUrl: string; + getReposForWorkspace: (client: BitbucketClient, workspaces: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundWorkspaces: string[]}>; + getReposForProjects: (client: BitbucketClient, projects: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundProjects: string[]}>; + getRepos: (client: BitbucketClient, repos: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundRepos: string[]}>; + shouldExcludeRepo: (repo: BitbucketRepository, config: BitbucketConnectionConfig) => boolean; +} + +type CloudAPI = ReturnType; +type CloudGetRequestPath = ClientPathsWithMethod; + +type ServerAPI = ReturnType; +type ServerGetRequestPath = ClientPathsWithMethod; + +type CloudPaginatedResponse = { + readonly next?: string; + readonly page?: number; + readonly pagelen?: number; + readonly previous?: string; + readonly size?: number; + readonly values?: readonly T[]; +} + +type ServerPaginatedResponse = { + readonly size: number; + readonly limit: number; + readonly isLastPage: boolean; + readonly values: readonly T[]; + readonly start: number; + readonly nextPageStart: number; +} + +export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => { + const token = config.token ? + await getTokenFromConfig(config.token, orgId, db, logger) : + undefined; + + if (config.deploymentType === 'server' && !config.url) { + throw new Error('URL is required for Bitbucket Server'); + } + + const client = config.deploymentType === 'server' ? + serverClient(config.url!, config.user, token) : + cloudClient(config.user, token); + + let allRepos: BitbucketRepository[] = []; + let notFound: { + orgs: string[], + users: string[], + repos: string[], + } = { + orgs: [], + users: [], + repos: [], + }; + + if (config.workspaces) { + const { validRepos, notFoundWorkspaces } = await client.getReposForWorkspace(client, config.workspaces); + allRepos = allRepos.concat(validRepos); + notFound.orgs = notFoundWorkspaces; + } + + if (config.projects) { + const { validRepos, notFoundProjects } = await client.getReposForProjects(client, config.projects); + allRepos = allRepos.concat(validRepos); + notFound.orgs = notFoundProjects; + } + + if (config.repos) { + const { validRepos, notFoundRepos } = await client.getRepos(client, config.repos); + allRepos = allRepos.concat(validRepos); + notFound.repos = notFoundRepos; + } + + const filteredRepos = allRepos.filter((repo) => { + return !client.shouldExcludeRepo(repo, config); + }); + + return { + validRepos: filteredRepos, + notFound, + }; +} + +function cloudClient(user: string | undefined, token: string | undefined): BitbucketClient { + + const authorizationString = + token + ? !user || user == "x-token-auth" + ? `Bearer ${token}` + : `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}` + : undefined; + + const clientOptions: ClientOptions = { + baseUrl: BITBUCKET_CLOUD_API, + headers: { + Accept: "application/json", + ...(authorizationString ? { Authorization: authorizationString } : {}), + }, + }; + + const apiClient = createBitbucketCloudClient(clientOptions); + var client: BitbucketClient = { + deploymentType: BITBUCKET_CLOUD, + token: token, + apiClient: apiClient, + baseUrl: BITBUCKET_CLOUD_API, + gitUrl: BITBUCKET_CLOUD_GIT, + getReposForWorkspace: cloudGetReposForWorkspace, + getReposForProjects: cloudGetReposForProjects, + getRepos: cloudGetRepos, + shouldExcludeRepo: cloudShouldExcludeRepo, + } + + return client; +} + +/** +* We need to do `V extends CloudGetRequestPath` since we will need to call `apiClient.GET(url, ...)`, which +* expects `url` to be of type `CloudGetRequestPath`. See example. +**/ +const getPaginatedCloud = async ( + path: CloudGetRequestPath, + get: (url: CloudGetRequestPath) => Promise> +): Promise => { + const results: T[] = []; + let url = path; + + while (true) { + const response = await get(url); + + if (!response.values || response.values.length === 0) { + break; + } + + results.push(...response.values); + + if (!response.next) { + break; + } + + url = response.next as CloudGetRequestPath; + } + return results; +} + + +async function cloudGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{validRepos: CloudRepository[], notFoundWorkspaces: string[]}> { + const results = await Promise.allSettled(workspaces.map(async (workspace) => { + try { + logger.debug(`Fetching all repos for workspace ${workspace}...`); + + const path = `/repositories/${workspace}` as CloudGetRequestPath; + const { durationMs, data } = await measure(async () => { + const fetchFn = () => getPaginatedCloud(path, async (url) => { + const response = await client.apiClient.GET(url, { + params: { + path: { + workspace, + } + } + }); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch projects for workspace ${workspace}: ${JSON.stringify(error)}`); + } + return data; + }); + return fetchWithRetry(fetchFn, `workspace ${workspace}`, logger); + }); + logger.debug(`Found ${data.length} repos for workspace ${workspace} in ${durationMs}ms.`); + + return { + type: 'valid' as const, + data: data, + }; + } catch (e: any) { + Sentry.captureException(e); + logger.error(`Failed to get repos for workspace ${workspace}: ${e}`); + + const status = e?.cause?.response?.status; + if (status == 404) { + logger.error(`Workspace ${workspace} not found or invalid access`) + return { + type: 'notFound' as const, + value: workspace + } + } + throw e; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundWorkspaces } = processPromiseResults(results); + return { + validRepos, + notFoundWorkspaces, + }; +} + +async function cloudGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{validRepos: CloudRepository[], notFoundProjects: string[]}> { + const results = await Promise.allSettled(projects.map(async (project) => { + const [workspace, project_name] = project.split('/'); + if (!workspace || !project_name) { + logger.error(`Invalid project ${project}`); + return { + type: 'notFound' as const, + value: project + } + } + + logger.debug(`Fetching all repos for project ${project} for workspace ${workspace}...`); + try { + const path = `/repositories/${workspace}` as CloudGetRequestPath; + const repos = await getPaginatedCloud(path, async (url) => { + const response = await client.apiClient.GET(url, { + params: { + query: { + q: `project.key="${project_name}"` + } + } + }); + const { data, error } = response; + if (error) { + throw new Error (`Failed to fetch projects for workspace ${workspace}: ${error.type}`); + } + return data; + }); + + logger.debug(`Found ${repos.length} repos for project ${project_name} for workspace ${workspace}.`); + return { + type: 'valid' as const, + data: repos + } + } catch (e: any) { + Sentry.captureException(e); + logger.error(`Failed to fetch repos for project ${project_name}: ${e}`); + + const status = e?.cause?.response?.status; + if (status == 404) { + logger.error(`Project ${project_name} not found in ${workspace} or invalid access`) + return { + type: 'notFound' as const, + value: project + } + } + throw e; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results); + return { + validRepos, + notFoundProjects + } +} + +async function cloudGetRepos(client: BitbucketClient, repos: string[]): Promise<{validRepos: CloudRepository[], notFoundRepos: string[]}> { + const results = await Promise.allSettled(repos.map(async (repo) => { + const [workspace, repo_slug] = repo.split('/'); + if (!workspace || !repo_slug) { + logger.error(`Invalid repo ${repo}`); + return { + type: 'notFound' as const, + value: repo + }; + } + + logger.debug(`Fetching repo ${repo_slug} for workspace ${workspace}...`); + try { + const path = `/repositories/${workspace}/${repo_slug}` as CloudGetRequestPath; + const response = await client.apiClient.GET(path); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch repo ${repo}: ${error.type}`); + } + return { + type: 'valid' as const, + data: [data] + }; + } catch (e: any) { + Sentry.captureException(e); + logger.error(`Failed to fetch repo ${repo}: ${e}`); + + const status = e?.cause?.response?.status; + if (status === 404) { + logger.error(`Repo ${repo} not found in ${workspace} or invalid access`); + return { + type: 'notFound' as const, + value: repo + }; + } + throw e; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results); + return { + validRepos, + notFoundRepos + }; +} + +function cloudShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketConnectionConfig): boolean { + const cloudRepo = repo as CloudRepository; + + const shouldExclude = (() => { + if (config.exclude?.repos && config.exclude.repos.includes(cloudRepo.full_name!)) { + return true; + } + + if (!!config.exclude?.archived) { + logger.warn(`Exclude archived repos flag provided in config but Bitbucket Cloud does not support archived repos. Ignoring...`); + } + + if (!!config.exclude?.forks && cloudRepo.parent !== undefined) { + return true; + } + })(); + + if (shouldExclude) { + logger.debug(`Excluding repo ${cloudRepo.full_name} because it matches the exclude pattern`); + return true; + } + return false; +} + +function serverClient(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) { + return ""; + } + + // A user must be provided when using basic auth + // https://developer.atlassian.com/server/bitbucket/rest/v906/intro/#authentication + if (!user || user == "x-token-auth") { + return `Bearer ${token}`; + } + return `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}`; + })(); + const clientOptions: ClientOptions = { + baseUrl: url, + headers: { + Accept: "application/json", + Authorization: authorizationString, + }, + }; + + const apiClient = createBitbucketServerClient(clientOptions); + var client: BitbucketClient = { + deploymentType: BITBUCKET_SERVER, + token: token, + apiClient: apiClient, + baseUrl: url, + gitUrl: url, + getReposForWorkspace: serverGetReposForWorkspace, + getReposForProjects: serverGetReposForProjects, + getRepos: serverGetRepos, + shouldExcludeRepo: serverShouldExcludeRepo, + } + + return client; +} + +const getPaginatedServer = async ( + path: ServerGetRequestPath, + get: (url: ServerGetRequestPath, start?: number) => Promise> +): Promise => { + const results: T[] = []; + let nextStart: number | undefined; + + while (true) { + const response = await get(path, nextStart); + + if (!response.values || response.values.length === 0) { + break; + } + + results.push(...response.values); + + if (response.isLastPage) { + break; + } + + nextStart = response.nextPageStart; + } + return results; +} + +async function serverGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{validRepos: ServerRepository[], notFoundWorkspaces: string[]}> { + logger.debug('Workspaces are not supported in Bitbucket Server'); + return { + validRepos: [], + notFoundWorkspaces: workspaces + }; +} + +async function serverGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{validRepos: ServerRepository[], notFoundProjects: string[]}> { + const results = await Promise.allSettled(projects.map(async (project) => { + try { + logger.debug(`Fetching all repos for project ${project}...`); + + const path = `/rest/api/1.0/projects/${project}/repos` as ServerGetRequestPath; + const { durationMs, data } = await measure(async () => { + const fetchFn = () => getPaginatedServer(path, async (url, start) => { + const response = await client.apiClient.GET(url, { + params: { + query: { + start, + } + } + }); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch repos for project ${project}: ${JSON.stringify(error)}`); + } + return data; + }); + return fetchWithRetry(fetchFn, `project ${project}`, logger); + }); + logger.debug(`Found ${data.length} repos for project ${project} in ${durationMs}ms.`); + + return { + type: 'valid' as const, + data: data, + }; + } catch (e: any) { + Sentry.captureException(e); + logger.error(`Failed to get repos for project ${project}: ${e}`); + + const status = e?.cause?.response?.status; + if (status == 404) { + logger.error(`Project ${project} not found or invalid access`); + return { + type: 'notFound' as const, + value: project + }; + } + throw e; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results); + return { + validRepos, + notFoundProjects + }; +} + +async function serverGetRepos(client: BitbucketClient, repos: string[]): Promise<{validRepos: ServerRepository[], notFoundRepos: string[]}> { + const results = await Promise.allSettled(repos.map(async (repo) => { + const [project, repo_slug] = repo.split('/'); + if (!project || !repo_slug) { + logger.error(`Invalid repo ${repo}`); + return { + type: 'notFound' as const, + value: repo + }; + } + + logger.debug(`Fetching repo ${repo_slug} for project ${project}...`); + try { + const path = `/rest/api/1.0/projects/${project}/repos/${repo_slug}` as ServerGetRequestPath; + const response = await client.apiClient.GET(path); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch repo ${repo}: ${error.type}`); + } + return { + type: 'valid' as const, + data: [data] + }; + } catch (e: any) { + Sentry.captureException(e); + logger.error(`Failed to fetch repo ${repo}: ${e}`); + + const status = e?.cause?.response?.status; + if (status === 404) { + logger.error(`Repo ${repo} not found in project ${project} or invalid access`); + return { + type: 'notFound' as const, + value: repo + }; + } + throw e; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results); + return { + validRepos, + notFoundRepos + }; +} + +function serverShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketConnectionConfig): boolean { + const serverRepo = repo as ServerRepository; + + const projectName = serverRepo.project!.key; + const repoSlug = serverRepo.slug!; + + const shouldExclude = (() => { + if (config.exclude?.repos && config.exclude.repos.includes(`${projectName}/${repoSlug}`)) { + return true; + } + + if (!!config.exclude?.archived && serverRepo.archived) { + return true; + } + + if (!!config.exclude?.forks && serverRepo.origin !== undefined) { + return true; + } + })(); + + if (shouldExclude) { + logger.debug(`Excluding repo ${projectName}/${repoSlug} because it matches the exclude pattern`); + return true; + } + return false; +} \ No newline at end of file diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 6985b2de..d6461753 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -4,7 +4,7 @@ import { Settings } from "./types.js"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { createLogger } from "./logger.js"; import { Redis } from 'ioredis'; -import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig } from "./repoCompileUtils.js"; +import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig, compileBitbucketConfig } from "./repoCompileUtils.js"; import { BackendError, BackendException } from "@sourcebot/error"; import { captureEvent } from "./posthog.js"; import { env } from "./env.js"; @@ -170,6 +170,9 @@ export class ConnectionManager implements IConnectionManager { case 'gerrit': { return await compileGerritConfig(config, job.data.connectionId, orgId); } + case 'bitbucket': { + return await compileBitbucketConfig(config, job.data.connectionId, orgId, this.db); + } } })(); } catch (err) { diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index 8787d610..0544d856 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -3,11 +3,14 @@ import { getGitHubReposFromConfig } from "./github.js"; import { getGitLabReposFromConfig } from "./gitlab.js"; import { getGiteaReposFromConfig } from "./gitea.js"; import { getGerritReposFromConfig } from "./gerrit.js"; +import { BitbucketRepository, getBitbucketReposFromConfig } from "./bitbucket.js"; +import { SchemaRestRepository as BitbucketServerRepository } from "@coderabbitai/bitbucket/server/openapi"; +import { SchemaRepository as BitbucketCloudRepository } from "@coderabbitai/bitbucket/cloud/openapi"; import { Prisma, PrismaClient } from '@sourcebot/db'; import { WithRequired } from "./types.js" import { marshalBool } from "./utils.js"; import { createLogger } from './logger.js'; -import { GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; +import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { RepoMetadata } from './types.js'; import path from 'path'; @@ -312,4 +315,120 @@ export const compileGerritConfig = async ( repos: [], } }; +} + +export const compileBitbucketConfig = async ( + config: BitbucketConnectionConfig, + connectionId: number, + orgId: number, + db: PrismaClient) => { + + const bitbucketReposResult = await getBitbucketReposFromConfig(config, orgId, db); + const bitbucketRepos = bitbucketReposResult.validRepos; + const notFound = bitbucketReposResult.notFound; + + const hostUrl = config.url ?? 'https://bitbucket.org'; + const repoNameRoot = new URL(hostUrl) + .toString() + .replace(/^https?:\/\//, ''); + + const getCloneUrl = (repo: BitbucketRepository) => { + if (!repo.links) { + throw new Error(`No clone links found for server repo ${repo.name}`); + } + + // In the cloud case we simply fetch the html link and use that as the clone url. For server we + // need to fetch the actual clone url + if (config.deploymentType === 'cloud') { + const htmlLink = repo.links.html as { href: string }; + return htmlLink.href; + } + + const cloneLinks = repo.links.clone as { + href: string; + name: string; + }[]; + + for (const link of cloneLinks) { + if (link.name === 'http') { + return link.href; + } + } + + throw new Error(`No clone links found for repo ${repo.name}`); + } + + const getWebUrl = (repo: BitbucketRepository) => { + const isServer = config.deploymentType === 'server'; + const repoLinks = (repo as BitbucketServerRepository | BitbucketCloudRepository).links; + const repoName = isServer ? (repo as BitbucketServerRepository).name : (repo as BitbucketCloudRepository).full_name; + + if (!repoLinks) { + throw new Error(`No links found for ${isServer ? 'server' : 'cloud'} repo ${repoName}`); + } + + // In server case we get an array of lenth == 1 links in the self field, while in cloud case we get a single + // link object in the html field + const link = isServer ? (repoLinks.self as { name: string, href: string }[])?.[0] : repoLinks.html as { href: string }; + if (!link || !link.href) { + throw new Error(`No ${isServer ? 'self' : 'html'} link found for ${isServer ? 'server' : 'cloud'} repo ${repoName}`); + } + + return link.href; + } + + const repos = bitbucketRepos.map((repo) => { + const isServer = config.deploymentType === 'server'; + const codeHostType = isServer ? 'bitbucket-server' : 'bitbucket-cloud'; // zoekt expects bitbucket-server + const displayName = isServer ? (repo as BitbucketServerRepository).name! : (repo as BitbucketCloudRepository).full_name!; + const externalId = isServer ? (repo as BitbucketServerRepository).id!.toString() : (repo as BitbucketCloudRepository).uuid!; + const isPublic = isServer ? (repo as BitbucketServerRepository).public : (repo as BitbucketCloudRepository).is_private === false; + const isArchived = isServer ? (repo as BitbucketServerRepository).archived === true : false; + const isFork = isServer ? (repo as BitbucketServerRepository).origin !== undefined : (repo as BitbucketCloudRepository).parent !== undefined; + const repoName = path.join(repoNameRoot, displayName); + const cloneUrl = getCloneUrl(repo); + const webUrl = getWebUrl(repo); + + const record: RepoData = { + external_id: externalId, + external_codeHostType: codeHostType, + external_codeHostUrl: hostUrl, + cloneUrl: cloneUrl, + webUrl: webUrl, + name: repoName, + displayName: displayName, + isFork: isFork, + isArchived: isArchived, + org: { + connect: { + id: orgId, + }, + }, + connections: { + create: { + connectionId: connectionId, + } + }, + metadata: { + gitConfig: { + 'zoekt.web-url-type': codeHostType, + 'zoekt.web-url': webUrl, + 'zoekt.name': repoName, + 'zoekt.archived': marshalBool(isArchived), + 'zoekt.fork': marshalBool(isFork), + 'zoekt.public': marshalBool(isPublic), + 'zoekt.display-name': displayName, + }, + branches: config.revisions?.branches ?? undefined, + tags: config.revisions?.tags ?? undefined, + } satisfies RepoMetadata, + }; + + return record; + }) + + return { + repoData: repos, + notFound, + }; } \ No newline at end of file diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index ca5db265..9158a6b4 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -2,7 +2,7 @@ import { Job, Queue, Worker } from 'bullmq'; import { Redis } from 'ioredis'; import { createLogger } from "./logger.js"; import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; -import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; +import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { AppContext, Settings, repoMetadataSchema } from "./types.js"; import { getRepoPath, getTokenFromConfig, measure, getShardPrefix } from "./utils.js"; import { cloneRepository, fetchRepository, upsertGitConfig } from "./git.js"; @@ -170,31 +170,50 @@ export class RepoManager implements IRepoManager { // fetch the token here using the connections from the repo. Multiple connections could be referencing this repo, and each // may have their own token. This method will just pick the first connection that has a token (if one exists) and uses that. This // may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referrencing. - private async getTokenForRepo(repo: RepoWithConnections, db: PrismaClient) { + private async getAuthForRepo(repo: RepoWithConnections, db: PrismaClient): Promise<{ username: string, password: string } | undefined> { const repoConnections = repo.connections; if (repoConnections.length === 0) { this.logger.error(`Repo ${repo.id} has no connections`); - return; + return undefined; } + let username = (() => { + switch (repo.external_codeHostType) { + case 'gitlab': + return 'oauth2'; + case 'bitbucket-cloud': + case 'bitbucket-server': + case 'github': + case 'gitea': + default: + return ''; + } + })(); - let token: string | undefined; + let password: string | undefined = undefined; for (const repoConnection of repoConnections) { const connection = repoConnection.connection; - if (connection.connectionType !== 'github' && connection.connectionType !== 'gitlab' && connection.connectionType !== 'gitea') { + if (connection.connectionType !== 'github' && connection.connectionType !== 'gitlab' && connection.connectionType !== 'gitea' && connection.connectionType !== 'bitbucket') { continue; } - const config = connection.config as unknown as GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig; + const config = connection.config as unknown as GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig | BitbucketConnectionConfig; if (config.token) { - token = await getTokenFromConfig(config.token, connection.orgId, db, this.logger); - if (token) { + password = await getTokenFromConfig(config.token, connection.orgId, db, this.logger); + if (password) { + // If we're using a bitbucket connection we need to set the username to be able to clone the repo + if (connection.connectionType === 'bitbucket') { + const bitbucketConfig = config as BitbucketConnectionConfig; + username = bitbucketConfig.user ?? "x-token-auth"; + } break; } } } - return token; + return password + ? { username, password } + : undefined; } private async syncGitRepository(repo: RepoWithConnections, repoAlreadyInIndexingState: boolean) { @@ -225,20 +244,11 @@ export class RepoManager implements IRepoManager { } else { this.logger.info(`Cloning ${repo.displayName}...`); - const token = await this.getTokenForRepo(repo, this.db); + const auth = await this.getAuthForRepo(repo, this.db); const cloneUrl = new URL(repo.cloneUrl); - if (token) { - switch (repo.external_codeHostType) { - case 'gitlab': - cloneUrl.username = 'oauth2'; - cloneUrl.password = token; - break; - case 'gitea': - case 'github': - default: - cloneUrl.username = token; - break; - } + if (auth) { + cloneUrl.username = auth.username; + cloneUrl.password = auth.password; } const { durationMs } = await measure(() => cloneRepository(cloneUrl.toString(), repoPath, ({ method, stage, progress }) => { @@ -318,12 +328,12 @@ export class RepoManager implements IRepoManager { attempts++; this.promClient.repoIndexingReattemptsTotal.inc(); if (attempts === maxAttempts) { - this.logger.error(`Failed to sync repository ${repo.id} after ${maxAttempts} attempts. Error: ${error}`); + this.logger.error(`Failed to sync repository ${repo.name} (id: ${repo.id}) after ${maxAttempts} attempts. Error: ${error}`); throw error; } const sleepDuration = 5000 * Math.pow(2, attempts - 1); - this.logger.error(`Failed to sync repository ${repo.id}, attempt ${attempts}/${maxAttempts}. Sleeping for ${sleepDuration / 1000}s... Error: ${error}`); + this.logger.error(`Failed to sync repository ${repo.name} (id: ${repo.id}), attempt ${attempts}/${maxAttempts}. Sleeping for ${sleepDuration / 1000}s... Error: ${error}`); await new Promise(resolve => setTimeout(resolve, sleepDuration)); } } @@ -453,7 +463,7 @@ export class RepoManager implements IRepoManager { } private async runGarbageCollectionJob(job: Job) { - this.logger.info(`Running garbage collection job (id: ${job.id}) for repo ${job.data.repo.id}`); + this.logger.info(`Running garbage collection job (id: ${job.id}) for repo ${job.data.repo.displayName} (id: ${job.data.repo.id})`); this.promClient.activeRepoGarbageCollectionJobs.inc(); const repo = job.data.repo as Repo; diff --git a/packages/schemas/src/v3/bitbucket.schema.ts b/packages/schemas/src/v3/bitbucket.schema.ts new file mode 100644 index 00000000..a7c857ce --- /dev/null +++ b/packages/schemas/src/v3/bitbucket.schema.ts @@ -0,0 +1,179 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "user": { + "type": "string", + "description": "The username to use for authentication. Only needed if token is an app password." + }, + "token": { + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Bitbucket deployment" + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspaces to sync. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of projects to sync" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repos to sync" + }, + "exclude": { + "type": "object", + "properties": { + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "cloud_workspace/repo1", + "server_project/repo2" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "if": { + "properties": { + "deploymentType": { + "const": "server" + } + } + }, + "then": { + "required": [ + "url" + ] + }, + "additionalProperties": false +} as const; +export { schema as bitbucketSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/bitbucket.type.ts b/packages/schemas/src/v3/bitbucket.type.ts new file mode 100644 index 00000000..260d949d --- /dev/null +++ b/packages/schemas/src/v3/bitbucket.type.ts @@ -0,0 +1,76 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! + +export interface BitbucketConnectionConfig { + /** + * Bitbucket configuration + */ + type: "bitbucket"; + /** + * The username to use for authentication. Only needed if token is an app password. + */ + user?: string; + /** + * An authentication token. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Bitbucket URL + */ + url?: string; + /** + * The type of Bitbucket deployment + */ + deploymentType?: "cloud" | "server"; + /** + * List of workspaces to sync. Ignored if deploymentType is server. + */ + workspaces?: string[]; + /** + * List of projects to sync + */ + projects?: string[]; + /** + * List of repos to sync + */ + repos?: string[]; + exclude?: { + /** + * Exclude archived repositories from syncing. + */ + archived?: boolean; + /** + * Exclude forked repositories from syncing. + */ + forks?: boolean; + /** + * List of specific repos to exclude from syncing. + */ + repos?: string[]; + }; + revisions?: GitRevisions; +} +/** + * The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored. + */ +export interface GitRevisions { + /** + * List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored. + */ + branches?: string[]; + /** + * List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored. + */ + tags?: string[]; +} diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index 3ab0c8db..2b65df48 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -504,6 +504,118 @@ const schema = { "url" ], "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "user": { + "type": "string", + "description": "The username to use for authentication. Only needed if token is an app password." + }, + "token": { + "$ref": "#/oneOf/0/properties/token", + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Bitbucket deployment" + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspaces to sync. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of projects to sync" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repos to sync" + }, + "exclude": { + "type": "object", + "properties": { + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "cloud_workspace/repo1", + "server_project/repo2" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "$ref": "#/oneOf/0/properties/revisions" + } + }, + "required": [ + "type" + ], + "if": { + "properties": { + "deploymentType": { + "const": "server" + } + } + }, + "then": { + "required": [ + "url" + ] + }, + "additionalProperties": false } ] } as const; diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index 8b1e479e..355e5104 100644 --- a/packages/schemas/src/v3/connection.type.ts +++ b/packages/schemas/src/v3/connection.type.ts @@ -4,7 +4,8 @@ export type ConnectionConfig = | GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig - | GerritConnectionConfig; + | GerritConnectionConfig + | BitbucketConnectionConfig; export interface GithubConnectionConfig { /** @@ -235,3 +236,64 @@ export interface GerritConnectionConfig { projects?: string[]; }; } +export interface BitbucketConnectionConfig { + /** + * Bitbucket configuration + */ + type: "bitbucket"; + /** + * The username to use for authentication. Only needed if token is an app password. + */ + user?: string; + /** + * An authentication token. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Bitbucket URL + */ + url?: string; + /** + * The type of Bitbucket deployment + */ + deploymentType?: "cloud" | "server"; + /** + * List of workspaces to sync. Ignored if deploymentType is server. + */ + workspaces?: string[]; + /** + * List of projects to sync + */ + projects?: string[]; + /** + * List of repos to sync + */ + repos?: string[]; + exclude?: { + /** + * Exclude archived repositories from syncing. + */ + archived?: boolean; + /** + * Exclude forked repositories from syncing. + */ + forks?: boolean; + /** + * List of specific repos to exclude from syncing. + */ + repos?: string[]; + }; + revisions?: GitRevisions; +} diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index d78811b2..992c58b9 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -633,6 +633,118 @@ const schema = { "url" ], "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "user": { + "type": "string", + "description": "The username to use for authentication. Only needed if token is an app password." + }, + "token": { + "$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/token", + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Bitbucket deployment" + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspaces to sync. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of projects to sync" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repos to sync" + }, + "exclude": { + "type": "object", + "properties": { + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "cloud_workspace/repo1", + "server_project/repo2" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/revisions" + } + }, + "required": [ + "type" + ], + "if": { + "properties": { + "deploymentType": { + "const": "server" + } + } + }, + "then": { + "required": [ + "url" + ] + }, + "additionalProperties": false } ] } diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index 54cb4a8e..461aa6bb 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -8,7 +8,8 @@ export type ConnectionConfig = | GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig - | GerritConnectionConfig; + | GerritConnectionConfig + | BitbucketConnectionConfig; export interface SourcebotConfig { $schema?: string; @@ -330,3 +331,64 @@ export interface GerritConnectionConfig { projects?: string[]; }; } +export interface BitbucketConnectionConfig { + /** + * Bitbucket configuration + */ + type: "bitbucket"; + /** + * The username to use for authentication. Only needed if token is an app password. + */ + user?: string; + /** + * An authentication token. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Bitbucket URL + */ + url?: string; + /** + * The type of Bitbucket deployment + */ + deploymentType?: "cloud" | "server"; + /** + * List of workspaces to sync. Ignored if deploymentType is server. + */ + workspaces?: string[]; + /** + * List of projects to sync + */ + projects?: string[]; + /** + * List of repos to sync + */ + repos?: string[]; + exclude?: { + /** + * Exclude archived repositories from syncing. + */ + archived?: boolean; + /** + * Exclude forked repositories from syncing. + */ + forks?: boolean; + /** + * List of specific repos to exclude from syncing. + */ + repos?: string[]; + }; + revisions?: GitRevisions; +} diff --git a/packages/web/public/bitbucket.svg b/packages/web/public/bitbucket.svg new file mode 100644 index 00000000..da2f871c --- /dev/null +++ b/packages/web/public/bitbucket.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 0db41d62..476e51cf 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -3,7 +3,7 @@ import { env } from "@/env.mjs"; import { ErrorCode } from "@/lib/errorCodes"; import { notAuthenticated, notFound, secretAlreadyExists, ServiceError, unexpectedError } from "@/lib/serviceError"; -import { isServiceError } from "@/lib/utils"; +import { CodeHostType, isServiceError } from "@/lib/utils"; import { prisma } from "@/prisma"; import { render } from "@react-email/components"; import * as Sentry from '@sentry/nextjs'; @@ -27,6 +27,7 @@ import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_U import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { TenancyMode } from "./lib/types"; import { decrementOrgSeatCount, getSubscriptionForOrg, incrementOrgSeatCount } from "./ee/features/billing/serverUtils"; +import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; const ajv = new Ajv({ validateFormats: false, @@ -442,10 +443,10 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt } ), /* allowSingleTenantUnauthedAccess = */ true)); -export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() => +export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() => withAuth((session) => withOrgMembership(session, domain, async ({ orgId }) => { - const parsedConfig = parseConnectionConfig(type, connectionConfig); + const parsedConfig = parseConnectionConfig(connectionConfig); if (isServiceError(parsedConfig)) { return parsedConfig; } @@ -531,7 +532,7 @@ export const updateConnectionConfigAndScheduleSync = async (connectionId: number return notFound(); } - const parsedConfig = parseConnectionConfig(connection.connectionType, config); + const parsedConfig = parseConnectionConfig(config); if (isServiceError(parsedConfig)) { return parsedConfig; } @@ -1154,7 +1155,7 @@ export const getSearchContexts = async (domain: string) => sew(() => ////// Helpers /////// -const parseConnectionConfig = (connectionType: string, config: string) => { +const parseConnectionConfig = (config: string) => { let parsedConfig: ConnectionConfig; try { parsedConfig = JSON.parse(config); @@ -1166,6 +1167,7 @@ const parseConnectionConfig = (connectionType: string, config: string) => { } satisfies ServiceError; } + const connectionType = parsedConfig.type; const schema = (() => { switch (connectionType) { case "github": @@ -1176,6 +1178,8 @@ const parseConnectionConfig = (connectionType: string, config: string) => { return giteaSchema; case 'gerrit': return gerritSchema; + case 'bitbucket': + return bitbucketSchema; } })(); @@ -1205,9 +1209,10 @@ const parseConnectionConfig = (connectionType: string, config: string) => { } const { numRepos, hasToken } = (() => { - switch (parsedConfig.type) { + switch (connectionType) { case "gitea": - case "github": { + case "github": + case "bitbucket": { return { numRepos: parsedConfig.repos?.length, hasToken: !!parsedConfig.token, diff --git a/packages/web/src/app/[domain]/components/codeHostIconButton.tsx b/packages/web/src/app/[domain]/components/codeHostIconButton.tsx index bee932fe..796adf4e 100644 --- a/packages/web/src/app/[domain]/components/codeHostIconButton.tsx +++ b/packages/web/src/app/[domain]/components/codeHostIconButton.tsx @@ -19,7 +19,7 @@ export const CodeHostIconButton = ({ const captureEvent = useCaptureEvent(); return ( ) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx new file mode 100644 index 00000000..52c762fc --- /dev/null +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx @@ -0,0 +1,49 @@ +'use client'; + +import SharedConnectionCreationForm from "./sharedConnectionCreationForm"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; +import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; +import { bitbucketCloudQuickActions } from "../../connections/quickActions"; + +interface BitbucketCloudConnectionCreationFormProps { + onCreated?: (id: number) => void; +} + +const additionalConfigValidation = (config: BitbucketConnectionConfig): { message: string, isValid: boolean } => { + const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0); + const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0); + const hasWorkspaces = config.workspaces && config.workspaces.length > 0 && config.workspaces.some(w => w.trim().length > 0); + + if (!hasProjects && !hasRepos && !hasWorkspaces) { + return { + message: "At least one project, repository, or workspace must be specified", + isValid: false, + } + } + + return { + message: "Valid", + isValid: true, + } +}; + +export const BitbucketCloudConnectionCreationForm = ({ onCreated }: BitbucketCloudConnectionCreationFormProps) => { + const defaultConfig: BitbucketConnectionConfig = { + type: 'bitbucket', + deploymentType: 'cloud', + } + + return ( + + type="bitbucket-cloud" + title="Create a Bitbucket Cloud connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + }} + schema={bitbucketSchema} + additionalConfigValidation={additionalConfigValidation} + quickActions={bitbucketCloudQuickActions} + onCreated={onCreated} + /> + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx new file mode 100644 index 00000000..5065de00 --- /dev/null +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx @@ -0,0 +1,48 @@ +'use client'; + +import SharedConnectionCreationForm from "./sharedConnectionCreationForm"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; +import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; +import { bitbucketDataCenterQuickActions } from "../../connections/quickActions"; + +interface BitbucketDataCenterConnectionCreationFormProps { + onCreated?: (id: number) => void; +} + +const additionalConfigValidation = (config: BitbucketConnectionConfig): { message: string, isValid: boolean } => { + const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0); + const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0); + + if (!hasProjects && !hasRepos) { + return { + message: "At least one project or repository must be specified", + isValid: false, + } + } + + return { + message: "Valid", + isValid: true, + } +}; + +export const BitbucketDataCenterConnectionCreationForm = ({ onCreated }: BitbucketDataCenterConnectionCreationFormProps) => { + const defaultConfig: BitbucketConnectionConfig = { + type: 'bitbucket', + deploymentType: 'server', + } + + return ( + + type="bitbucket-server" + title="Create a Bitbucket Data Center connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + }} + schema={bitbucketSchema} + additionalConfigValidation={additionalConfigValidation} + quickActions={bitbucketDataCenterQuickActions} + onCreated={onCreated} + /> + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts b/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts index 0e22cf0c..db4bcd0f 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts @@ -2,3 +2,5 @@ export { GitHubConnectionCreationForm } from "./githubConnectionCreationForm"; export { GitLabConnectionCreationForm } from "./gitlabConnectionCreationForm"; export { GiteaConnectionCreationForm } from "./giteaConnectionCreationForm"; export { GerritConnectionCreationForm } from "./gerritConnectionCreationForm"; +export { BitbucketCloudConnectionCreationForm } from "./bitbucketCloudConnectionCreationForm"; +export { BitbucketDataCenterConnectionCreationForm } from "./bitbucketDataCenterConnectionCreationForm"; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/importSecretDialog.tsx b/packages/web/src/app/[domain]/components/importSecretDialog.tsx index 853b298e..b67fea7a 100644 --- a/packages/web/src/app/[domain]/components/importSecretDialog.tsx +++ b/packages/web/src/app/[domain]/components/importSecretDialog.tsx @@ -88,6 +88,10 @@ export const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHo return ; case 'gitlab': return ; + case 'bitbucket-cloud': + return ; + case 'bitbucket-server': + return ; case 'gitea': return ; case 'gerrit': @@ -179,7 +183,7 @@ export const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHo Key @@ -262,11 +266,33 @@ const GiteaPATCreationStep = ({ step }: { step: number }) => { ) } +const BitbucketCloudPATCreationStep = ({ step }: { step: number }) => { + return ( + Please check out our docs for more information on how to create auth credentials for Bitbucket Cloud. + > + + ) +} + +const BitbucketServerPATCreationStep = ({ step }: { step: number }) => { + return ( + Please check out our docs for more information on how to create auth credentials for Bitbucket Data Center. + > + + ) +} + interface SecretCreationStepProps { step: number; title: string; description: string | React.ReactNode; - children: React.ReactNode; + children?: React.ReactNode; } const SecretCreationStep = ({ step, title, description, children }: SecretCreationStepProps) => { diff --git a/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx b/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx index a9fc3e42..0da3eb98 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx @@ -13,7 +13,7 @@ import { createZodConnectionConfigValidator } from "../../utils"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type"; -import { githubQuickActions, gitlabQuickActions, giteaQuickActions, gerritQuickActions } from "../../quickActions"; +import { githubQuickActions, gitlabQuickActions, giteaQuickActions, gerritQuickActions, bitbucketCloudQuickActions, bitbucketDataCenterQuickActions } from "../../quickActions"; import { Schema } from "ajv"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; @@ -27,11 +27,13 @@ import { useDomain } from "@/hooks/useDomain"; import { SecretCombobox } from "@/app/[domain]/components/connectionCreationForms/secretCombobox"; import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; import strings from "@/lib/strings"; +import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; interface ConfigSettingProps { connectionId: number; config: string; - type: string; + type: CodeHostType; disabled?: boolean; } @@ -56,6 +58,24 @@ export const ConfigSetting = (props: ConfigSettingProps) => { />; } + if (type === 'bitbucket-cloud') { + return + {...props} + type="bitbucket-cloud" + quickActions={bitbucketCloudQuickActions} + schema={bitbucketSchema} + />; + } + + if (type === 'bitbucket-server') { + return + {...props} + type="bitbucket-server" + quickActions={bitbucketDataCenterQuickActions} + schema={bitbucketSchema} + />; + } + if (type === 'gitea') { return {...props} diff --git a/packages/web/src/app/[domain]/connections/[id]/page.tsx b/packages/web/src/app/[domain]/connections/[id]/page.tsx index d08b97f2..93a53e9d 100644 --- a/packages/web/src/app/[domain]/connections/[id]/page.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/page.tsx @@ -21,6 +21,9 @@ import { getOrgMembership } from "@/actions" import { isServiceError } from "@/lib/utils" import { notFound } from "next/navigation" import { OrgRole } from "@sourcebot/db" +import { CodeHostType } from "@/lib/utils" +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type" + interface ConnectionManagementPageProps { params: { domain: string @@ -93,7 +96,7 @@ export default async function ConnectionManagementPage({ params, searchParams }: diff --git a/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx index 30b545a1..043e7045 100644 --- a/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx +++ b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx @@ -49,6 +49,18 @@ export const NewConnectionCard = ({ className, role }: NewConnectionCardProps) = subtitle="Cloud and Self-Hosted supported." disabled={!isOwner} /> + + ; } + if (type === 'bitbucket-cloud') { + return ; + } + + if (type === 'bitbucket-server') { + return ; + } + + router.push(`/${domain}/connections`); } diff --git a/packages/web/src/app/[domain]/connections/quickActions.tsx b/packages/web/src/app/[domain]/connections/quickActions.tsx index 50d85947..d0e7736d 100644 --- a/packages/web/src/app/[domain]/connections/quickActions.tsx +++ b/packages/web/src/app/[domain]/connections/quickActions.tsx @@ -1,7 +1,8 @@ import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type" import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; import { QuickAction } from "../components/configEditor"; -import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; +import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type"; import { cn } from "@/lib/utils"; @@ -100,7 +101,7 @@ export const githubQuickActions: QuickAction[] = [ ...previous, url: previous.url ?? "https://github.example.com", }), - name: "Set a custom url", + name: "Set url to GitHub instance", selectionText: "https://github.example.com", description: Set a custom GitHub host. Defaults to https://github.com. }, @@ -290,7 +291,7 @@ export const gitlabQuickActions: QuickAction[] = [ ...previous, url: previous.url ?? "https://gitlab.example.com", }), - name: "Set a custom url", + name: "Set url to GitLab instance", selectionText: "https://gitlab.example.com", description: Set a custom GitLab host. Defaults to https://gitlab.com. }, @@ -360,7 +361,7 @@ export const giteaQuickActions: QuickAction[] = [ ...previous, url: previous.url ?? "https://gitea.example.com", }), - name: "Set a custom url", + name: "Set url to Gitea instance", selectionText: "https://gitea.example.com", } ] @@ -390,3 +391,196 @@ export const gerritQuickActions: QuickAction[] = [ name: "Exclude a project", } ] + +export const bitbucketCloudQuickActions: QuickAction[] = [ + { + // add user + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + user: previous.user ?? "username" + }), + name: "Add username", + selectionText: "username", + description: ( +
+ Username to use for authentication. This is only required if you're using an App Password (stored in token) for authentication. +
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + workspaces: [ + ...(previous.workspaces ?? []), + "myWorkspace" + ] + }), + name: "Add a workspace", + selectionText: "myWorkspace", + description: ( +
+ Add a workspace to sync with. Ensure the workspace is visible to the provided token (if any). +
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + repos: [ + ...(previous.repos ?? []), + "myWorkspace/myRepo" + ] + }), + name: "Add a repo", + selectionText: "myWorkspace/myRepo", + description: ( +
+ Add an individual repository to sync with. Ensure the repository is visible to the provided token (if any). +
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + projects: [ + ...(previous.projects ?? []), + "myProject" + ] + }), + name: "Add a project", + selectionText: "myProject", + description: ( +
+ Add a project to sync with. Ensure the project is visible to the provided token (if any). +
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + exclude: { + ...previous.exclude, + repos: [...(previous.exclude?.repos ?? []), "myWorkspace/myExcludedRepo"] + } + }), + name: "Exclude a repo", + selectionText: "myWorkspace/myExcludedRepo", + description: ( +
+ Exclude a repository from syncing. Glob patterns are supported. +
+ ) + }, + // exclude forked + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + exclude: { + ...previous.exclude, + forks: true + } + }), + name: "Exclude forked repos", + description: Exclude forked repositories from syncing. + } +] + +export const bitbucketDataCenterQuickActions: QuickAction[] = [ + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + url: previous.url ?? "https://bitbucket.example.com", + }), + name: "Set url to Bitbucket DC instance", + selectionText: "https://bitbucket.example.com", + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + repos: [ + ...(previous.repos ?? []), + "myProject/myRepo" + ] + }), + name: "Add a repo", + selectionText: "myProject/myRepo", + description: ( +
+ Add a individual repository to sync with. Ensure the repository is visible to the provided token (if any). + Examples: +
+ {[ + "PROJ/repo-name", + "MYPROJ/api" + ].map((repo) => ( + {repo} + ))} +
+
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + projects: [ + ...(previous.projects ?? []), + "myProject" + ] + }), + name: "Add a project", + selectionText: "myProject", + description: ( +
+ Add a project to sync with. Ensure the project is visible to the provided token (if any). +
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + exclude: { + ...previous.exclude, + repos: [...(previous.exclude?.repos ?? []), "myProject/myExcludedRepo"] + } + }), + name: "Exclude a repo", + selectionText: "myProject/myExcludedRepo", + description: ( +
+ Exclude a repository from syncing. Glob patterns are supported. + Examples: +
+ {[ + "myProject/myExcludedRepo", + "myProject2/*" + ].map((repo) => ( + {repo} + ))} +
+
+ ) + }, + // exclude archived + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + exclude: { + ...previous.exclude, + archived: true + } + }), + name: "Exclude archived repos", + }, + // exclude forked + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + exclude: { + ...previous.exclude, + forks: true + } + }), + name: "Exclude forked repos", + } +] + diff --git a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx index dc0f4589..00fecdb0 100644 --- a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx +++ b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx @@ -7,7 +7,9 @@ import { GitHubConnectionCreationForm, GitLabConnectionCreationForm, GiteaConnectionCreationForm, - GerritConnectionCreationForm + GerritConnectionCreationForm, + BitbucketCloudConnectionCreationForm, + BitbucketDataCenterConnectionCreationForm } from "@/app/[domain]/components/connectionCreationForms"; import { useRouter } from "next/navigation"; import { useCallback } from "react"; @@ -79,6 +81,24 @@ export const ConnectCodeHost = ({ nextStep, securityCardEnabled }: ConnectCodeHo ) } + if (selectedCodeHost === "bitbucket-cloud") { + return ( + <> + + + + ) + } + + if (selectedCodeHost === "bitbucket-server") { + return ( + <> + + + + ) + } + return null; } @@ -90,7 +110,7 @@ const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => { const captureEvent = useCaptureEvent(); return ( -
+
{ captureEvent("wa_onboard_gitlab_selected", {}); }} /> + { + onSelect("bitbucket-cloud"); + captureEvent("wa_onboard_bitbucket_cloud_selected", {}); + }} + /> + { + onSelect("bitbucket-server"); + captureEvent("wa_onboard_bitbucket_server_selected", {}); + }} + />