From ff43d10d60401e4f6a11707de32b161fba41b3f3 Mon Sep 17 00:00:00 2001 From: msukkari Date: Mon, 21 Apr 2025 21:33:30 -0700 Subject: [PATCH 01/14] [wip] add bitbucket schema --- schemas/v3/bitbucket_data_center.json | 104 ++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 schemas/v3/bitbucket_data_center.json diff --git a/schemas/v3/bitbucket_data_center.json b/schemas/v3/bitbucket_data_center.json new file mode 100644 index 00000000..a7c71fb5 --- /dev/null +++ b/schemas/v3/bitbucket_data_center.json @@ -0,0 +1,104 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "serverType": { + "type": "string", + "enum": ["cloud", "server"], + "default": "cloud", + "description": "server type" + }, + "token": { + "$ref": "./shared.json#/definitions/Token", + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ] + }, + "user": { + "type": "string", + "description": "User name for authentication" + }, + "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." + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "workspace1/repo1", + "workspace2/**" + ] + ], + "description": "List of specific workspaces to exclude from syncing." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ], + "description": "List of specific projects to exclude from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "workspace1/repo1", + "workspace2/**" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "$ref": "./shared.json#/definitions/GitRevisions" + } + }, + "required": [ + "token", + "type", + "user" + ], + "additionalProperties": false +} \ No newline at end of file From 9fb9973115e1bd5769547cef3a3054c76a9c470e Mon Sep 17 00:00:00 2001 From: msukkari Date: Tue, 22 Apr 2025 20:57:45 -0700 Subject: [PATCH 02/14] wip bitbucket support --- package.json | 5 +- packages/backend/src/bitbucket.ts | 235 ++++++++++++++++++ packages/backend/src/connectionManager.ts | 5 +- packages/backend/src/repoCompileUtils.ts | 117 ++++++++- packages/backend/src/repoManager.ts | 47 ++-- packages/schemas/src/v3/bitbucket.schema.ts | 191 ++++++++++++++ packages/schemas/src/v3/bitbucket.type.ts | 84 +++++++ packages/schemas/src/v3/connection.schema.ts | 124 +++++++++ packages/schemas/src/v3/connection.type.ts | 72 +++++- packages/schemas/src/v3/index.schema.ts | 124 +++++++++ packages/schemas/src/v3/index.type.ts | 72 +++++- ...bucket_data_center.json => bitbucket.json} | 61 +++-- schemas/v3/connection.json | 3 + yarn.lock | 28 +++ 14 files changed, 1119 insertions(+), 49 deletions(-) create mode 100644 packages/backend/src/bitbucket.ts create mode 100644 packages/schemas/src/v3/bitbucket.schema.ts create mode 100644 packages/schemas/src/v3/bitbucket.type.ts rename schemas/v3/{bitbucket_data_center.json => bitbucket.json} (72%) diff --git a/package.json b/package.json index 4ad48cb2..ddfe29b5 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..48c468d0 --- /dev/null +++ b/packages/backend/src/bitbucket.ts @@ -0,0 +1,235 @@ +import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud"; +import { paths } from "@coderabbitai/bitbucket/cloud"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; +import type { ClientOptions, Client, ClientPathsWithMethod } from "openapi-fetch"; +import { createLogger } from "./logger.js"; +import { PrismaClient, Repo } from "@sourcebot/db"; +import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; +import { env } from "./env.js"; +import * as Sentry from "@sentry/node"; +import { + SchemaBranch as CloudBranch, + SchemaProject as CloudProject, + SchemaRepository as CloudRepository, + SchemaTag as CloudTag, + SchemaWorkspace as CloudWorkspace +} from "@coderabbitai/bitbucket/cloud/openapi"; +import { SchemaRepository 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; + getPaginated: (path: V, get: (url: V) => Promise>) => Promise; + getReposForWorkspace: (client: BitbucketClient, workspace: string) => Promise<{validRepos: BitbucketRepository[], notFoundWorkspaces: string[]}>; + getReposForProjects: (client: BitbucketClient, projects: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundProjects: string[]}>; + /* + getRepos: (client: BitbucketClient, workspace: string, project: string) => Promise; + countForks: (client: BitbucketClient, repo: Repo) => Promise; + countWatchers: (client: BitbucketClient, repo: Repo) => Promise; + getBranches: (client: BitbucketClient, repo: string) => Promise; + getTags: (client: BitbucketClient, repo: string) => Promise; + */ +} + +// afaik, this is the only way of extracting the client API type +type CloudAPI = ReturnType; + +// Defines a type that is a union of all API paths that have a GET method in the +// client api. +type CloudGetRequestPath = ClientPathsWithMethod; + +type PaginatedResponse = { + readonly next?: string; + readonly page?: number; + readonly pagelen?: number; + readonly previous?: string; + readonly size?: number; + readonly values?: readonly T[]; +} + +export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => { + const token = await getTokenFromConfig(config.token, orgId, db, logger); + + //const deploymentType = config.deploymentType; + const client = cloudClient(token); + + let allRepos: BitbucketRepository[] = []; + let notFound: { + orgs: string[], + users: string[], + repos: string[], + } = { + orgs: [], + users: [], + repos: [], + }; + + if (config.workspace) { + const { validRepos, notFoundWorkspaces } = await client.getReposForWorkspace(client, config.workspace); + allRepos = allRepos.concat(validRepos); + notFound.orgs = notFoundWorkspaces; + } + + if (config.projects) { + const { validRepos, notFoundProjects } = await client.getReposForProjects(client, config.projects); + allRepos = allRepos.concat(validRepos); + notFound.repos = notFoundProjects; + } + + return { + validRepos: allRepos, + notFound, + }; +} + +function cloudClient(token: string | undefined): BitbucketClient { + const clientOptions: ClientOptions = { + baseUrl: BITBUCKET_CLOUD_API, + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + }; + + const apiClient = createBitbucketCloudClient(clientOptions); + var client: BitbucketClient = { + deploymentType: BITBUCKET_CLOUD, + token: token, + apiClient: apiClient, + baseUrl: BITBUCKET_CLOUD_API, + gitUrl: BITBUCKET_CLOUD_GIT, + getPaginated: getPaginatedCloud, + getReposForWorkspace: cloudGetReposForWorkspace, + getReposForProjects: cloudGetReposForProjects, + /* + getRepos: cloudGetRepos, + countForks: cloudCountForks, + countWatchers: cloudCountWatchers, + getBranches: cloudGetBranches, + getTags: cloudGetTags, + */ + } + + 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: V, get: (url: V) => 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; + } + + // cast required here since response.next is a string. + url = response.next as V; + } + return results; +} + + +async function cloudGetReposForWorkspace(client: BitbucketClient, workspace: string): Promise<{validRepos: CloudRepository[], notFoundWorkspaces: string[]}> { + try { + logger.debug(`Fetching all repos for workspace ${workspace}...`); + + const path = `/repositories/${workspace}` as CloudGetRequestPath; + const { durationMs, data } = await measure(async () => { + const fetchFn = () => client.getPaginated(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}: ${error.type}`); + } + return data; + }); + return fetchWithRetry(fetchFn, `workspace ${workspace}`, logger); + }); + logger.debug(`Found ${data.length} repos for workspace ${workspace} in ${durationMs}ms.`); + + return { + validRepos: data, + notFoundWorkspaces: [], + }; + } catch (e) { + Sentry.captureException(e); + logger.error(`Failed to get repos for workspace ${workspace}: ${e}`); + throw e; + } +} + +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('/'); + logger.debug(`Fetching all repos for project ${project} for workspace ${workspace}...`); + + try { + const path = `/repositories/${workspace}?q=project.key="${project_name}"` as CloudGetRequestPath; + const repos = await client.getPaginated(path, async (url) => { + const response = await client.apiClient.GET(url); + 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 + } +} 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..352df853 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 { getBitbucketReposFromConfig } from "./bitbucket.js"; +import { SchemaRepository 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,116 @@ 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 repos = bitbucketRepos.map((repo) => { + const deploymentType = config.deploymentType; + let record: RepoData; + if (deploymentType === 'server') { + const serverRepo = repo as BitbucketServerRepository; + + const repoDisplayName = serverRepo.name!; + const repoName = path.join(repoNameRoot, repoDisplayName); + const cloneUrl = `${hostUrl}/${serverRepo.slug}`; + const webUrl = `${hostUrl}/${serverRepo.slug}`; + + record = { + external_id: serverRepo.id!.toString(), + external_codeHostType: 'bitbucket-server', + external_codeHostUrl: hostUrl, + cloneUrl: cloneUrl.toString(), + webUrl: webUrl.toString(), + name: repoName, + displayName: repoDisplayName, + isFork: false, + isArchived: false, + org: { + connect: { + id: orgId, + }, + }, + connections: { + create: { + connectionId: connectionId, + } + }, + metadata: { + gitConfig: { + 'zoekt.web-url-type': 'bitbucket-server', + 'zoekt.web-url': webUrl ?? '', + 'zoekt.name': repoName, + 'zoekt.archived': marshalBool(false), + 'zoekt.fork': marshalBool(false), + 'zoekt.public': marshalBool(serverRepo.public), + 'zoekt.display-name': repoDisplayName, + }, + }, + } + } else { + const cloudRepo = repo as BitbucketCloudRepository; + + const repoDisplayName = cloudRepo.full_name!; + const repoName = path.join(repoNameRoot, repoDisplayName); + + const cloneInfo = cloudRepo.links!.clone![0]; + const webInfo = cloudRepo.links!.self!; + const cloneUrl = new URL(cloneInfo.href!); + const webUrl = new URL(webInfo.href!); + + record = { + external_id: cloudRepo.uuid!, + external_codeHostType: 'bitbucket-cloud', + external_codeHostUrl: hostUrl, + cloneUrl: cloneUrl.toString(), + webUrl: webUrl.toString(), + name: repoName, + displayName: repoDisplayName, + isFork: false, + isArchived: false, + org: { + connect: { + id: orgId, + }, + }, + connections: { + create: { + connectionId: connectionId, + } + }, + metadata: { + gitConfig: { + 'zoekt.web-url-type': 'bitbucket-cloud', + 'zoekt.web-url': webUrl.toString(), + 'zoekt.name': repoName, + 'zoekt.archived': marshalBool(false), + 'zoekt.fork': marshalBool(false), + 'zoekt.public': marshalBool(cloudRepo.is_private === false), + 'zoekt.display-name': repoDisplayName, + }, + }, + } + } + + 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..faaa7d5b 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,43 @@ 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; } + const username = (() => { + switch (repo.external_codeHostType) { + case 'gitlab': + return 'oauth2'; + case 'bitbucket': + return 'x-token-auth'; + case 'github': + case 'gitea': + default: + return ''; + } + })(); - let token: string | undefined; + let password: string = ""; 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) { break; } } } - return token; + return { username, password }; } private async syncGitRepository(repo: RepoWithConnections, repoAlreadyInIndexingState: boolean) { @@ -225,20 +237,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 }) => { diff --git a/packages/schemas/src/v3/bitbucket.schema.ts b/packages/schemas/src/v3/bitbucket.schema.ts new file mode 100644 index 00000000..a1343ca6 --- /dev/null +++ b/packages/schemas/src/v3/bitbucket.schema.ts @@ -0,0 +1,191 @@ +// 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" + }, + "workspace": { + "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." + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "workspace1", + "workspace2" + ] + ], + "description": "List of specific workspaces to exclude from syncing. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1", + "project2" + ] + ], + "description": "List of specific projects to exclude 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": [ + "token", + "type" + ], + "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..777474eb --- /dev/null +++ b/packages/schemas/src/v3/bitbucket.type.ts @@ -0,0 +1,84 @@ +// 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. + */ + workspace?: 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 workspaces to exclude from syncing. Ignored if deploymentType is server. + */ + workspaces?: string[]; + /** + * List of specific projects to exclude from syncing. + */ + projects?: string[]; + /** + * 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..a1cab2fd 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -504,6 +504,130 @@ 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" + }, + "workspace": { + "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." + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "workspace1", + "workspace2" + ] + ], + "description": "List of specific workspaces to exclude from syncing. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1", + "project2" + ] + ], + "description": "List of specific projects to exclude 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": [ + "token", + "type" + ], + "additionalProperties": false } ] } as const; diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index 8b1e479e..4cc7d352 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,72 @@ 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. + */ + workspace?: 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 workspaces to exclude from syncing. Ignored if deploymentType is server. + */ + workspaces?: string[]; + /** + * List of specific projects to exclude from syncing. + */ + projects?: string[]; + /** + * 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 f8b9258c..540380ac 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -583,6 +583,130 @@ 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" + }, + "workspace": { + "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." + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "workspace1", + "workspace2" + ] + ], + "description": "List of specific workspaces to exclude from syncing. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1", + "project2" + ] + ], + "description": "List of specific projects to exclude 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": [ + "token", + "type" + ], + "additionalProperties": false } ] } diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index 01c6c668..d5619b21 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; @@ -301,3 +302,72 @@ 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. + */ + workspace?: 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 workspaces to exclude from syncing. Ignored if deploymentType is server. + */ + workspaces?: string[]; + /** + * List of specific projects to exclude from syncing. + */ + projects?: string[]; + /** + * List of specific repos to exclude from syncing. + */ + repos?: string[]; + }; + revisions?: GitRevisions; +} diff --git a/schemas/v3/bitbucket_data_center.json b/schemas/v3/bitbucket.json similarity index 72% rename from schemas/v3/bitbucket_data_center.json rename to schemas/v3/bitbucket.json index a7c71fb5..b154d0cb 100644 --- a/schemas/v3/bitbucket_data_center.json +++ b/schemas/v3/bitbucket.json @@ -7,6 +7,19 @@ "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", @@ -17,24 +30,29 @@ ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" }, - "serverType": { + "deploymentType": { "type": "string", "enum": ["cloud", "server"], "default": "cloud", - "description": "server type" - }, - "token": { - "$ref": "./shared.json#/definitions/Token", - "description": "An authentication token.", - "examples": [ - { - "secret": "SECRET_KEY" - } - ] + "description": "The type of Bitbucket deployment" }, - "user": { + "workspace": { "type": "string", - "description": "User name for authentication" + "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", @@ -56,11 +74,11 @@ }, "examples": [ [ - "workspace1/repo1", - "workspace2/**" + "workspace1", + "workspace2" ] ], - "description": "List of specific workspaces to exclude from syncing." + "description": "List of specific workspaces to exclude from syncing. Ignored if deploymentType is server." }, "projects": { "type": "array", @@ -69,8 +87,8 @@ }, "examples": [ [ - "project1/repo1", - "project2/**" + "project1", + "project2" ] ], "description": "List of specific projects to exclude from syncing." @@ -82,8 +100,8 @@ }, "examples": [ [ - "workspace1/repo1", - "workspace2/**" + "cloud_workspace/repo1", + "server_project/repo2" ] ], "description": "List of specific repos to exclude from syncing." @@ -97,8 +115,7 @@ }, "required": [ "token", - "type", - "user" + "type" ], "additionalProperties": false } \ No newline at end of file diff --git a/schemas/v3/connection.json b/schemas/v3/connection.json index 45dee7ab..d1cab143 100644 --- a/schemas/v3/connection.json +++ b/schemas/v3/connection.json @@ -13,6 +13,9 @@ }, { "$ref": "./gerrit.json" + }, + { + "$ref": "./bitbucket.json" } ] } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index faa5a864..ca3ff277 100644 --- a/yarn.lock +++ b/yarn.lock @@ -667,6 +667,17 @@ __metadata: languageName: node linkType: hard +"@coderabbitai/bitbucket@npm:^1.1.3": + version: 1.1.3 + resolution: "@coderabbitai/bitbucket@npm:1.1.3" + dependencies: + openapi-fetch: "npm:^0.13.4" + bin: + coderabbitai-bitbucket: dist/main.js + checksum: 10c0/0c034866f8094b9ce68a5292d513fe6226d50b333a0a6ce929542b635a8bcf1d8d5abc7eb1aaf665ebce99e312a192d83b224085843d5641806ca4adc36ab0ff + languageName: node + linkType: hard + "@colors/colors@npm:1.6.0, @colors/colors@npm:^1.6.0": version: 1.6.0 resolution: "@colors/colors@npm:1.6.0" @@ -11609,6 +11620,22 @@ __metadata: languageName: node linkType: hard +"openapi-fetch@npm:^0.13.4": + version: 0.13.5 + resolution: "openapi-fetch@npm:0.13.5" + dependencies: + openapi-typescript-helpers: "npm:^0.0.15" + checksum: 10c0/57736d9d4310d7bc7fa5e4e37e80d28893a7fefee88ee6e0327600de893e0638479445bf0c9f5bd7b1a2429f409425d3945d6e942b23b37b8081630ac52244fb + languageName: node + linkType: hard + +"openapi-typescript-helpers@npm:^0.0.15": + version: 0.0.15 + resolution: "openapi-typescript-helpers@npm:0.0.15" + checksum: 10c0/5eb68d487b787e3e31266470b1a310726549dd45a1079655ab18066ab291b0b3c343fdf629991013706a2329b86964f8798d56ef0272b94b931fe6c19abd7a88 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -13045,6 +13072,7 @@ __metadata: version: 0.0.0-use.local resolution: "root-workspace-0b6124@workspace:." dependencies: + "@coderabbitai/bitbucket": "npm:^1.1.3" cross-env: "npm:^7.0.3" dotenv-cli: "npm:^8.0.0" npm-run-all: "npm:^4.1.5" From 5c21ca705af922147aac4b83f74ae42b0c03b897 Mon Sep 17 00:00:00 2001 From: msukkari Date: Wed, 23 Apr 2025 14:25:19 -0700 Subject: [PATCH 03/14] add support for pulling bitbucket repos and UI support for bitbucket --- packages/backend/src/bitbucket.ts | 67 ++++++++++++++++++++++-- packages/backend/src/repoCompileUtils.ts | 16 +++--- packages/backend/src/repoManager.ts | 3 +- packages/web/public/bitbucket.svg | 5 ++ packages/web/src/lib/utils.ts | 20 ++++++- vendor/zoekt | 2 +- 6 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 packages/web/public/bitbucket.svg diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index 48c468d0..60b3e7cc 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -35,8 +35,8 @@ interface BitbucketClient { getPaginated: (path: V, get: (url: V) => Promise>) => Promise; getReposForWorkspace: (client: BitbucketClient, workspace: 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[]}>; /* - getRepos: (client: BitbucketClient, workspace: string, project: string) => Promise; countForks: (client: BitbucketClient, repo: Repo) => Promise; countWatchers: (client: BitbucketClient, repo: Repo) => Promise; getBranches: (client: BitbucketClient, repo: string) => Promise; @@ -89,6 +89,12 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon notFound.repos = notFoundProjects; } + if (config.repos) { + const { validRepos, notFoundRepos } = await client.getRepos(client, config.repos); + allRepos = allRepos.concat(validRepos); + notFound.repos = notFoundRepos; + } + return { validRepos: allRepos, notFound, @@ -114,6 +120,7 @@ function cloudClient(token: string | undefined): BitbucketClient { getPaginated: getPaginatedCloud, getReposForWorkspace: cloudGetReposForWorkspace, getReposForProjects: cloudGetReposForProjects, + getRepos: cloudGetRepos, /* getRepos: cloudGetRepos, countForks: cloudCountForks, @@ -170,7 +177,7 @@ async function cloudGetReposForWorkspace(client: BitbucketClient, workspace: str }); const { data, error } = response; if (error) { - throw new Error (`Failed to fetch projects for workspace ${workspace}: ${error.type}`); + throw new Error(`Failed to fetch projects for workspace ${workspace}: ${JSON.stringify(error)}`); } return data; }); @@ -192,8 +199,15 @@ async function cloudGetReposForWorkspace(client: BitbucketClient, workspace: str 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('/'); - logger.debug(`Fetching all repos for project ${project} for workspace ${workspace}...`); + 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}?q=project.key="${project_name}"` as CloudGetRequestPath; const repos = await client.getPaginated(path, async (url) => { @@ -233,3 +247,50 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin 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 + }; +} \ No newline at end of file diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index 352df853..084e26ae 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -373,7 +373,7 @@ export const compileBitbucketConfig = async ( 'zoekt.public': marshalBool(serverRepo.public), 'zoekt.display-name': repoDisplayName, }, - }, + } satisfies RepoMetadata, } } else { const cloudRepo = repo as BitbucketCloudRepository; @@ -381,17 +381,13 @@ export const compileBitbucketConfig = async ( const repoDisplayName = cloudRepo.full_name!; const repoName = path.join(repoNameRoot, repoDisplayName); - const cloneInfo = cloudRepo.links!.clone![0]; - const webInfo = cloudRepo.links!.self!; - const cloneUrl = new URL(cloneInfo.href!); - const webUrl = new URL(webInfo.href!); - + const htmlUrl = cloudRepo.links!.html?.href!; record = { external_id: cloudRepo.uuid!, external_codeHostType: 'bitbucket-cloud', external_codeHostUrl: hostUrl, - cloneUrl: cloneUrl.toString(), - webUrl: webUrl.toString(), + cloneUrl: htmlUrl, + webUrl: htmlUrl, name: repoName, displayName: repoDisplayName, isFork: false, @@ -409,14 +405,14 @@ export const compileBitbucketConfig = async ( metadata: { gitConfig: { 'zoekt.web-url-type': 'bitbucket-cloud', - 'zoekt.web-url': webUrl.toString(), + 'zoekt.web-url': htmlUrl, 'zoekt.name': repoName, 'zoekt.archived': marshalBool(false), 'zoekt.fork': marshalBool(false), 'zoekt.public': marshalBool(cloudRepo.is_private === false), 'zoekt.display-name': repoDisplayName, }, - }, + } satisfies RepoMetadata, } } diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index faaa7d5b..38f714bf 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -181,7 +181,8 @@ export class RepoManager implements IRepoManager { switch (repo.external_codeHostType) { case 'gitlab': return 'oauth2'; - case 'bitbucket': + case 'bitbucket-server': + case 'bitbucket-cloud': return 'x-token-auth'; case 'github': case 'gitea': 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/lib/utils.ts b/packages/web/src/lib/utils.ts index 9d68bfdb..5c66a376 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -4,6 +4,7 @@ import githubLogo from "@/public/github.svg"; import gitlabLogo from "@/public/gitlab.svg"; import giteaLogo from "@/public/gitea.svg"; import gerritLogo from "@/public/gerrit.svg"; +import bitbucketLogo from "@/public/bitbucket.svg"; import { ServiceError } from "./serviceError"; import { Repository, RepositoryQuery } from "./types"; @@ -31,7 +32,7 @@ export const createPathWithQueryParams = (path: string, ...queryParams: [string, return `${path}?${queryString}`; } -export type CodeHostType = "github" | "gitlab" | "gitea" | "gerrit"; +export type CodeHostType = "github" | "gitlab" | "gitea" | "gerrit" | "bitbucket"; type CodeHostInfo = { type: CodeHostType; @@ -110,6 +111,18 @@ const _getCodeHostInfoInternal = (type: string, displayName: string, cloneUrl: s iconClassName: className, } } + case "bitbucket-server": + case "bitbucket-cloud": { + const { src, className } = getCodeHostIcon('bitbucket')!; + return { + type: "bitbucket", + displayName: displayName, + codeHostName: "Bitbucket", + repoLink: cloneUrl, + icon: src, + iconClassName: className, + } + } } } @@ -132,6 +145,10 @@ export const getCodeHostIcon = (codeHostType: CodeHostType): { src: string, clas return { src: gerritLogo, } + case "bitbucket": + return { + src: bitbucketLogo, + } default: return null; } @@ -142,6 +159,7 @@ export const isAuthSupportedForCodeHost = (codeHostType: CodeHostType): boolean case "github": case "gitlab": case "gitea": + case "bitbucket": return true; case "gerrit": return false; diff --git a/vendor/zoekt b/vendor/zoekt index cf456394..57019fee 160000 --- a/vendor/zoekt +++ b/vendor/zoekt @@ -1 +1 @@ -Subproject commit cf456394003dd9bfc9a885fdfcc8cc80230a261d +Subproject commit 57019feee2a396ca79020150d50ebee8d55323c7 From 4f860854eda0ff5c06b06976b8e0cdf707249b8b Mon Sep 17 00:00:00 2001 From: msukkari Date: Wed, 23 Apr 2025 15:04:24 -0700 Subject: [PATCH 04/14] fix bitbucket app password auth case --- packages/backend/src/bitbucket.ts | 8 +++++--- packages/backend/src/repoManager.ts | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index 60b3e7cc..5d8f217a 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -64,7 +64,7 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon const token = await getTokenFromConfig(config.token, orgId, db, logger); //const deploymentType = config.deploymentType; - const client = cloudClient(token); + const client = cloudClient(config.user, token); let allRepos: BitbucketRepository[] = []; let notFound: { @@ -101,12 +101,14 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon }; } -function cloudClient(token: string | undefined): BitbucketClient { +function cloudClient(user: string | undefined, token: string | undefined): BitbucketClient { + + const authorizationString = !user || user == "x-token-auth" ? `Bearer ${token}` : `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}`; const clientOptions: ClientOptions = { baseUrl: BITBUCKET_CLOUD_API, headers: { Accept: "application/json", - Authorization: `Bearer ${token}`, + Authorization: authorizationString, }, }; diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index 38f714bf..b5d5eb7a 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -177,7 +177,7 @@ export class RepoManager implements IRepoManager { return undefined; } - const username = (() => { + let username = (() => { switch (repo.external_codeHostType) { case 'gitlab': return 'oauth2'; @@ -202,6 +202,11 @@ export class RepoManager implements IRepoManager { if (config.token) { password = await getTokenFromConfig(config.token, connection.orgId, db, this.logger); if (password) { + // If we're using a bitbucket connection, check to see if we're provided a username + if (connection.connectionType === 'bitbucket') { + const bitbucketConfig = config as BitbucketConnectionConfig; + username = bitbucketConfig.user ?? "x-token-auth"; + } break; } } From 12e29066b71dff5fc1d81515d9198fd90d8d24f4 Mon Sep 17 00:00:00 2001 From: msukkari Date: Wed, 23 Apr 2025 16:06:57 -0700 Subject: [PATCH 05/14] add back support for multiple workspaces and add exclude logic --- packages/backend/src/bitbucket.ts | 115 +++++++++++++------ packages/backend/src/repoManager.ts | 2 +- packages/schemas/src/v3/bitbucket.schema.ts | 33 +----- packages/schemas/src/v3/bitbucket.type.ts | 10 +- packages/schemas/src/v3/connection.schema.ts | 33 +----- packages/schemas/src/v3/connection.type.ts | 10 +- packages/schemas/src/v3/index.schema.ts | 33 +----- packages/schemas/src/v3/index.type.ts | 10 +- schemas/v3/bitbucket.json | 33 +----- 9 files changed, 101 insertions(+), 178 deletions(-) diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index 5d8f217a..68630dfd 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -33,12 +33,11 @@ interface BitbucketClient { baseUrl: string; gitUrl: string; getPaginated: (path: V, get: (url: V) => Promise>) => Promise; - getReposForWorkspace: (client: BitbucketClient, workspace: string) => Promise<{validRepos: BitbucketRepository[], notFoundWorkspaces: 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; /* - countForks: (client: BitbucketClient, repo: Repo) => Promise; - countWatchers: (client: BitbucketClient, repo: Repo) => Promise; getBranches: (client: BitbucketClient, repo: string) => Promise; getTags: (client: BitbucketClient, repo: string) => Promise; */ @@ -77,8 +76,8 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon repos: [], }; - if (config.workspace) { - const { validRepos, notFoundWorkspaces } = await client.getReposForWorkspace(client, config.workspace); + if (config.workspaces) { + const { validRepos, notFoundWorkspaces } = await client.getReposForWorkspace(client, config.workspaces); allRepos = allRepos.concat(validRepos); notFound.orgs = notFoundWorkspaces; } @@ -95,8 +94,12 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon notFound.repos = notFoundRepos; } + const filteredRepos = allRepos.filter((repo) => { + return !client.shouldExcludeRepo(repo, config); + }); + return { - validRepos: allRepos, + validRepos: filteredRepos, notFound, }; } @@ -123,10 +126,8 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu getReposForWorkspace: cloudGetReposForWorkspace, getReposForProjects: cloudGetReposForProjects, getRepos: cloudGetRepos, + shouldExcludeRepo: cloudShouldExcludeRepo, /* - getRepos: cloudGetRepos, - countForks: cloudCountForks, - countWatchers: cloudCountWatchers, getBranches: cloudGetBranches, getTags: cloudGetTags, */ @@ -163,39 +164,57 @@ const getPaginatedCloud = async (path: V, get: } -async function cloudGetReposForWorkspace(client: BitbucketClient, workspace: string): Promise<{validRepos: CloudRepository[], notFoundWorkspaces: string[]}> { - try { - logger.debug(`Fetching all repos for workspace ${workspace}...`); - - const path = `/repositories/${workspace}` as CloudGetRequestPath; - const { durationMs, data } = await measure(async () => { - const fetchFn = () => client.getPaginated(path, async (url) => { - const response = await client.apiClient.GET(url, { - params: { - path: { - workspace, +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 = () => client.getPaginated(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; }); - 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); }); - return fetchWithRetry(fetchFn, `workspace ${workspace}`, logger); - }); - logger.debug(`Found ${data.length} repos for workspace ${workspace} in ${durationMs}ms.`); - - return { - validRepos: data, - notFoundWorkspaces: [], - }; - } catch (e) { - Sentry.captureException(e); - logger.error(`Failed to get repos for workspace ${workspace}: ${e}`); - throw e; - } + 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[]}> { @@ -295,4 +314,24 @@ async function cloudGetRepos(client: BitbucketClient, repos: string[]): Promise< 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?.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; } \ No newline at end of file diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index b5d5eb7a..4958f018 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -462,7 +462,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 index a1343ca6..e314d012 100644 --- a/packages/schemas/src/v3/bitbucket.schema.ts +++ b/packages/schemas/src/v3/bitbucket.schema.ts @@ -67,8 +67,11 @@ const schema = { "default": "cloud", "description": "The type of Bitbucket deployment" }, - "workspace": { - "type": "string", + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, "description": "List of workspaces to sync. Ignored if deploymentType is server." }, "projects": { @@ -98,32 +101,6 @@ const schema = { "default": false, "description": "Exclude forked repositories from syncing." }, - "workspaces": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "workspace1", - "workspace2" - ] - ], - "description": "List of specific workspaces to exclude from syncing. Ignored if deploymentType is server." - }, - "projects": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "project1", - "project2" - ] - ], - "description": "List of specific projects to exclude from syncing." - }, "repos": { "type": "array", "items": { diff --git a/packages/schemas/src/v3/bitbucket.type.ts b/packages/schemas/src/v3/bitbucket.type.ts index 777474eb..dfbf3900 100644 --- a/packages/schemas/src/v3/bitbucket.type.ts +++ b/packages/schemas/src/v3/bitbucket.type.ts @@ -36,7 +36,7 @@ export interface BitbucketConnectionConfig { /** * List of workspaces to sync. Ignored if deploymentType is server. */ - workspace?: string; + workspaces?: string[]; /** * List of projects to sync */ @@ -54,14 +54,6 @@ export interface BitbucketConnectionConfig { * Exclude forked repositories from syncing. */ forks?: boolean; - /** - * List of specific workspaces to exclude from syncing. Ignored if deploymentType is server. - */ - workspaces?: string[]; - /** - * List of specific projects to exclude from syncing. - */ - projects?: string[]; /** * List of specific repos to exclude from syncing. */ diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index a1cab2fd..03476b34 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -546,8 +546,11 @@ const schema = { "default": "cloud", "description": "The type of Bitbucket deployment" }, - "workspace": { - "type": "string", + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, "description": "List of workspaces to sync. Ignored if deploymentType is server." }, "projects": { @@ -577,32 +580,6 @@ const schema = { "default": false, "description": "Exclude forked repositories from syncing." }, - "workspaces": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "workspace1", - "workspace2" - ] - ], - "description": "List of specific workspaces to exclude from syncing. Ignored if deploymentType is server." - }, - "projects": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "project1", - "project2" - ] - ], - "description": "List of specific projects to exclude from syncing." - }, "repos": { "type": "array", "items": { diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index 4cc7d352..d76bfef8 100644 --- a/packages/schemas/src/v3/connection.type.ts +++ b/packages/schemas/src/v3/connection.type.ts @@ -272,7 +272,7 @@ export interface BitbucketConnectionConfig { /** * List of workspaces to sync. Ignored if deploymentType is server. */ - workspace?: string; + workspaces?: string[]; /** * List of projects to sync */ @@ -290,14 +290,6 @@ export interface BitbucketConnectionConfig { * Exclude forked repositories from syncing. */ forks?: boolean; - /** - * List of specific workspaces to exclude from syncing. Ignored if deploymentType is server. - */ - workspaces?: string[]; - /** - * List of specific projects to exclude from syncing. - */ - projects?: string[]; /** * List of specific repos to exclude from syncing. */ diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index 540380ac..c0e9c3a3 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -625,8 +625,11 @@ const schema = { "default": "cloud", "description": "The type of Bitbucket deployment" }, - "workspace": { - "type": "string", + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, "description": "List of workspaces to sync. Ignored if deploymentType is server." }, "projects": { @@ -656,32 +659,6 @@ const schema = { "default": false, "description": "Exclude forked repositories from syncing." }, - "workspaces": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "workspace1", - "workspace2" - ] - ], - "description": "List of specific workspaces to exclude from syncing. Ignored if deploymentType is server." - }, - "projects": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "project1", - "project2" - ] - ], - "description": "List of specific projects to exclude from syncing." - }, "repos": { "type": "array", "items": { diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index d5619b21..88a7417f 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -338,7 +338,7 @@ export interface BitbucketConnectionConfig { /** * List of workspaces to sync. Ignored if deploymentType is server. */ - workspace?: string; + workspaces?: string[]; /** * List of projects to sync */ @@ -356,14 +356,6 @@ export interface BitbucketConnectionConfig { * Exclude forked repositories from syncing. */ forks?: boolean; - /** - * List of specific workspaces to exclude from syncing. Ignored if deploymentType is server. - */ - workspaces?: string[]; - /** - * List of specific projects to exclude from syncing. - */ - projects?: string[]; /** * List of specific repos to exclude from syncing. */ diff --git a/schemas/v3/bitbucket.json b/schemas/v3/bitbucket.json index b154d0cb..d00b5288 100644 --- a/schemas/v3/bitbucket.json +++ b/schemas/v3/bitbucket.json @@ -36,8 +36,11 @@ "default": "cloud", "description": "The type of Bitbucket deployment" }, - "workspace": { - "type": "string", + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, "description": "List of workspaces to sync. Ignored if deploymentType is server." }, "projects": { @@ -67,32 +70,6 @@ "default": false, "description": "Exclude forked repositories from syncing." }, - "workspaces": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "workspace1", - "workspace2" - ] - ], - "description": "List of specific workspaces to exclude from syncing. Ignored if deploymentType is server." - }, - "projects": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "project1", - "project2" - ] - ], - "description": "List of specific projects to exclude from syncing." - }, "repos": { "type": "array", "items": { From 69fcc831b7ce7352d5b0ff9a4dc9737c39a5f612 Mon Sep 17 00:00:00 2001 From: msukkari Date: Wed, 23 Apr 2025 16:28:03 -0700 Subject: [PATCH 06/14] add branches to bitbucket --- packages/backend/src/repoCompileUtils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index 084e26ae..a5d2eae5 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -373,6 +373,8 @@ export const compileBitbucketConfig = async ( 'zoekt.public': marshalBool(serverRepo.public), 'zoekt.display-name': repoDisplayName, }, + branches: config.revisions?.branches ?? undefined, + tags: config.revisions?.tags ?? undefined, } satisfies RepoMetadata, } } else { @@ -412,6 +414,8 @@ export const compileBitbucketConfig = async ( 'zoekt.public': marshalBool(cloudRepo.is_private === false), 'zoekt.display-name': repoDisplayName, }, + branches: config.revisions?.branches ?? undefined, + tags: config.revisions?.tags ?? undefined, } satisfies RepoMetadata, } } From bcb32cd1705b7daf460090cf7192f4c6a6533225 Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 24 Apr 2025 15:58:30 -0700 Subject: [PATCH 07/14] add bitbucket server support --- packages/backend/src/bitbucket.ts | 229 ++++++++++++++++-- packages/backend/src/repoCompileUtils.ts | 169 +++++++------ packages/backend/src/repoManager.ts | 5 +- packages/schemas/src/v3/bitbucket.schema.ts | 1 - packages/schemas/src/v3/bitbucket.type.ts | 2 +- packages/schemas/src/v3/connection.schema.ts | 1 - packages/schemas/src/v3/connection.type.ts | 2 +- packages/schemas/src/v3/index.schema.ts | 1 - packages/schemas/src/v3/index.type.ts | 2 +- .../components/codePreviewPanel/index.tsx | 6 +- schemas/v3/bitbucket.json | 1 - 11 files changed, 297 insertions(+), 122 deletions(-) diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index 68630dfd..71f2b7c8 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -1,20 +1,15 @@ import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud"; -import { paths } from "@coderabbitai/bitbucket/cloud"; +import { createBitbucketServerClient } from "@coderabbitai/bitbucket/server"; import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; import type { ClientOptions, Client, ClientPathsWithMethod } from "openapi-fetch"; import { createLogger } from "./logger.js"; -import { PrismaClient, Repo } from "@sourcebot/db"; +import { PrismaClient } from "@sourcebot/db"; import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; -import { env } from "./env.js"; import * as Sentry from "@sentry/node"; import { - SchemaBranch as CloudBranch, - SchemaProject as CloudProject, SchemaRepository as CloudRepository, - SchemaTag as CloudTag, - SchemaWorkspace as CloudWorkspace } from "@coderabbitai/bitbucket/cloud/openapi"; -import { SchemaRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi"; +import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi"; import { processPromiseResults } from "./connectionUtils.js"; import { throwIfAnyFailed } from "./connectionUtils.js"; @@ -32,24 +27,19 @@ interface BitbucketClient { apiClient: any; baseUrl: string; gitUrl: string; - getPaginated: (path: V, get: (url: V) => Promise>) => Promise; + getPaginated: (path: V, get: (url: V) => Promise>) => Promise; 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; - /* - getBranches: (client: BitbucketClient, repo: string) => Promise; - getTags: (client: BitbucketClient, repo: string) => Promise; - */ } -// afaik, this is the only way of extracting the client API type type CloudAPI = ReturnType; - -// Defines a type that is a union of all API paths that have a GET method in the -// client api. type CloudGetRequestPath = ClientPathsWithMethod; +type ServerAPI = ReturnType; +type ServerGetRequestPath = ClientPathsWithMethod; + type PaginatedResponse = { readonly next?: string; readonly page?: number; @@ -60,10 +50,17 @@ type PaginatedResponse = { } export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => { - const token = await getTokenFromConfig(config.token, orgId, db, logger); + const token = config.token ? + await getTokenFromConfig(config.token, orgId, db, logger) : + undefined; - //const deploymentType = config.deploymentType; - const client = cloudClient(config.user, token); + 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: { @@ -127,10 +124,6 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu getReposForProjects: cloudGetReposForProjects, getRepos: cloudGetRepos, shouldExcludeRepo: cloudShouldExcludeRepo, - /* - getBranches: cloudGetBranches, - getTags: cloudGetTags, - */ } return client; @@ -140,7 +133,7 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu * 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: V, get: (url: V) => Promise>) => { +const getPaginatedCloud = async (path: V, get: (url: V) => Promise>) => { const results: T[] = []; let url = path; @@ -334,4 +327,190 @@ function cloudShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketConn 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, + getPaginated: getPaginatedServer, + getReposForWorkspace: serverGetReposForWorkspace, + getReposForProjects: serverGetReposForProjects, + getRepos: serverGetRepos, + shouldExcludeRepo: serverShouldExcludeRepo, + } + + return client; +} + +const getPaginatedServer = async (path: V, get: (url: V) => 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; + } + + // cast required here since response.next is a string. + url = response.next as V; + } + 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 = () => client.getPaginated(path, async (url) => { + const response = await client.apiClient.GET(url); + 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 shouldExclude = (() => { + if (config.exclude?.repos && config.exclude.repos.includes(serverRepo.slug!)) { + return true; + } + + // Note: Bitbucket Server doesn't have a direct way to check if a repo is a fork + // We'll need to check the origin property if it exists + if (!!config.exclude?.forks && serverRepo.origin !== undefined) { + return true; + } + })(); + + if (shouldExclude) { + logger.debug(`Excluding repo ${serverRepo.slug} because it matches the exclude pattern`); + return true; + } + return false; } \ No newline at end of file diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index a5d2eae5..52036cbe 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -3,8 +3,8 @@ import { getGitHubReposFromConfig } from "./github.js"; import { getGitLabReposFromConfig } from "./gitlab.js"; import { getGiteaReposFromConfig } from "./gitea.js"; import { getGerritReposFromConfig } from "./gerrit.js"; -import { getBitbucketReposFromConfig } from "./bitbucket.js"; -import { SchemaRepository as BitbucketServerRepository } from "@coderabbitai/bitbucket/server/openapi"; +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" @@ -332,93 +332,90 @@ export const compileBitbucketConfig = async ( .toString() .replace(/^https?:\/\//, ''); - const repos = bitbucketRepos.map((repo) => { - const deploymentType = config.deploymentType; - let record: RepoData; - if (deploymentType === 'server') { - const serverRepo = repo as BitbucketServerRepository; - - const repoDisplayName = serverRepo.name!; - const repoName = path.join(repoNameRoot, repoDisplayName); - const cloneUrl = `${hostUrl}/${serverRepo.slug}`; - const webUrl = `${hostUrl}/${serverRepo.slug}`; - - record = { - external_id: serverRepo.id!.toString(), - external_codeHostType: 'bitbucket-server', - external_codeHostUrl: hostUrl, - cloneUrl: cloneUrl.toString(), - webUrl: webUrl.toString(), - name: repoName, - displayName: repoDisplayName, - isFork: false, - isArchived: false, - org: { - connect: { - id: orgId, - }, - }, - connections: { - create: { - connectionId: connectionId, - } - }, - metadata: { - gitConfig: { - 'zoekt.web-url-type': 'bitbucket-server', - 'zoekt.web-url': webUrl ?? '', - 'zoekt.name': repoName, - 'zoekt.archived': marshalBool(false), - 'zoekt.fork': marshalBool(false), - 'zoekt.public': marshalBool(serverRepo.public), - 'zoekt.display-name': repoDisplayName, - }, - branches: config.revisions?.branches ?? undefined, - tags: config.revisions?.tags ?? undefined, - } satisfies RepoMetadata, + const getCloneUrl = (repo: BitbucketRepository) => { + if (!repo.links) { + throw new Error(`No clone links found for server repo ${repo.name}`); + } + + const cloneLinks = repo.links.clone as { + href: string; + name: string; + }[]; + + // Annoying difference between server and cloud (happens even if server is hosted with https) + const targetCloneType = config.deploymentType === 'cloud' ? 'https' : 'http'; + for (const link of cloneLinks) { + if (link.name === targetCloneType) { + return link.href; } - } else { - const cloudRepo = repo as BitbucketCloudRepository; - - const repoDisplayName = cloudRepo.full_name!; - const repoName = path.join(repoNameRoot, repoDisplayName); - - const htmlUrl = cloudRepo.links!.html?.href!; - record = { - external_id: cloudRepo.uuid!, - external_codeHostType: 'bitbucket-cloud', - external_codeHostUrl: hostUrl, - cloneUrl: htmlUrl, - webUrl: htmlUrl, - name: repoName, - displayName: repoDisplayName, - isFork: false, - isArchived: false, - org: { - connect: { - id: orgId, - }, + } + + 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'; + 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 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: false, + isArchived: false, + org: { + connect: { + id: orgId, }, - connections: { - create: { - connectionId: connectionId, - } + }, + connections: { + create: { + connectionId: connectionId, + } + }, + metadata: { + gitConfig: { + 'zoekt.web-url-type': codeHostType, + 'zoekt.web-url': webUrl, + 'zoekt.name': repoName, + 'zoekt.archived': marshalBool(false), + 'zoekt.fork': marshalBool(false), + 'zoekt.public': marshalBool(isPublic), + 'zoekt.display-name': displayName, }, - metadata: { - gitConfig: { - 'zoekt.web-url-type': 'bitbucket-cloud', - 'zoekt.web-url': htmlUrl, - 'zoekt.name': repoName, - 'zoekt.archived': marshalBool(false), - 'zoekt.fork': marshalBool(false), - 'zoekt.public': marshalBool(cloudRepo.is_private === false), - 'zoekt.display-name': repoDisplayName, - }, - branches: config.revisions?.branches ?? undefined, - tags: config.revisions?.tags ?? undefined, - } satisfies RepoMetadata, - } - } + branches: config.revisions?.branches ?? undefined, + tags: config.revisions?.tags ?? undefined, + } satisfies RepoMetadata, + }; return record; }) diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index 4958f018..32ab639c 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -183,7 +183,6 @@ export class RepoManager implements IRepoManager { return 'oauth2'; case 'bitbucket-server': case 'bitbucket-cloud': - return 'x-token-auth'; case 'github': case 'gitea': default: @@ -327,12 +326,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)); } } diff --git a/packages/schemas/src/v3/bitbucket.schema.ts b/packages/schemas/src/v3/bitbucket.schema.ts index e314d012..79d0a824 100644 --- a/packages/schemas/src/v3/bitbucket.schema.ts +++ b/packages/schemas/src/v3/bitbucket.schema.ts @@ -160,7 +160,6 @@ const schema = { } }, "required": [ - "token", "type" ], "additionalProperties": false diff --git a/packages/schemas/src/v3/bitbucket.type.ts b/packages/schemas/src/v3/bitbucket.type.ts index dfbf3900..260d949d 100644 --- a/packages/schemas/src/v3/bitbucket.type.ts +++ b/packages/schemas/src/v3/bitbucket.type.ts @@ -12,7 +12,7 @@ export interface BitbucketConnectionConfig { /** * An authentication token. */ - token: + token?: | { /** * The name of the secret that contains the token. diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index 03476b34..e2ee2f2e 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -601,7 +601,6 @@ const schema = { } }, "required": [ - "token", "type" ], "additionalProperties": false diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index d76bfef8..355e5104 100644 --- a/packages/schemas/src/v3/connection.type.ts +++ b/packages/schemas/src/v3/connection.type.ts @@ -248,7 +248,7 @@ export interface BitbucketConnectionConfig { /** * An authentication token. */ - token: + token?: | { /** * The name of the secret that contains the token. diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index c0e9c3a3..0222c5e0 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -680,7 +680,6 @@ const schema = { } }, "required": [ - "token", "type" ], "additionalProperties": false diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index 88a7417f..94413a72 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -314,7 +314,7 @@ export interface BitbucketConnectionConfig { /** * An authentication token. */ - token: + token?: | { /** * The name of the secret that contains the token. diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx index 8c4c7ea2..dc55b84d 100644 --- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx @@ -65,7 +65,11 @@ export const CodePreviewPanel = ({ }) .join("/"); - const optionalQueryParams = template.substring(template.indexOf("}}") + 2); + const optionalQueryParams = + template.substring(template.indexOf("}}") + 2) + .replace("{{.Version}}", branch ?? "HEAD") + .replace("{{.Path}}", fileMatch.FileName); + return url + optionalQueryParams; })(); diff --git a/schemas/v3/bitbucket.json b/schemas/v3/bitbucket.json index d00b5288..02008bab 100644 --- a/schemas/v3/bitbucket.json +++ b/schemas/v3/bitbucket.json @@ -91,7 +91,6 @@ } }, "required": [ - "token", "type" ], "additionalProperties": false From 4c6636dd1c6c08232876d207c389185d63d336cf Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 24 Apr 2025 16:54:00 -0700 Subject: [PATCH 08/14] add docs for bitbucket and minor nits --- docs/docs.json | 2 + docs/docs/connections/bitbucket-cloud.mdx | 201 +++++++++++++++++++ docs/docs/connections/bitbucket-server.mdx | 180 +++++++++++++++++ docs/images/bitbucket_app_password_perms.png | Bin 0 -> 143273 bytes docs/snippets/bitbucket-app-password.mdx | 51 +++++ docs/snippets/bitbucket-token.mdx | 47 +++++ packages/backend/src/bitbucket.ts | 7 +- packages/schemas/src/v3/bitbucket.schema.ts | 12 ++ packages/schemas/src/v3/connection.schema.ts | 12 ++ packages/schemas/src/v3/index.schema.ts | 12 ++ schemas/v3/bitbucket.json | 8 + 11 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 docs/docs/connections/bitbucket-cloud.mdx create mode 100644 docs/docs/connections/bitbucket-server.mdx create mode 100644 docs/images/bitbucket_app_password_perms.png create mode 100644 docs/snippets/bitbucket-app-password.mdx create mode 100644 docs/snippets/bitbucket-token.mdx diff --git a/docs/docs.json b/docs/docs.json index 64358876..b3888135 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-server", "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..d10c2e74 --- /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", + "project": [ + "myWorkspace/myRepo" + ] + } + ``` + + + ```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-server.mdx b/docs/docs/connections/bitbucket-server.mdx new file mode 100644 index 00000000..ee1dddd9 --- /dev/null +++ b/docs/docs/connections/bitbucket-server.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/images/bitbucket_app_password_perms.png b/docs/images/bitbucket_app_password_perms.png new file mode 100644 index 0000000000000000000000000000000000000000..1fc619550e81666084bee45d67176ab4e51e455f GIT binary patch literal 143273 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�d^mB*YGUIcsGXq!CN6_2;`UcqGZ78% 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®VXF(%?Upmyc(; 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≪XP7%pw+pc!pTFKnRH#av!x$SVif9F); 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 + + 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/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index 71f2b7c8..bf82d21c 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -495,9 +495,12 @@ async function serverGetRepos(client: BitbucketClient, repos: string[]): Promise 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(serverRepo.slug!)) { + if (config.exclude?.repos && config.exclude.repos.includes(`${projectName}/${repoSlug}`)) { return true; } @@ -509,7 +512,7 @@ function serverShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketCon })(); if (shouldExclude) { - logger.debug(`Excluding repo ${serverRepo.slug} because it matches the exclude pattern`); + logger.debug(`Excluding repo ${projectName}/${repoSlug} because it matches the exclude pattern`); return true; } return false; diff --git a/packages/schemas/src/v3/bitbucket.schema.ts b/packages/schemas/src/v3/bitbucket.schema.ts index 79d0a824..a7c857ce 100644 --- a/packages/schemas/src/v3/bitbucket.schema.ts +++ b/packages/schemas/src/v3/bitbucket.schema.ts @@ -162,6 +162,18 @@ const schema = { "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/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index e2ee2f2e..2b65df48 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -603,6 +603,18 @@ const schema = { "required": [ "type" ], + "if": { + "properties": { + "deploymentType": { + "const": "server" + } + } + }, + "then": { + "required": [ + "url" + ] + }, "additionalProperties": false } ] diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index 0222c5e0..2848467b 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -682,6 +682,18 @@ const schema = { "required": [ "type" ], + "if": { + "properties": { + "deploymentType": { + "const": "server" + } + } + }, + "then": { + "required": [ + "url" + ] + }, "additionalProperties": false } ] diff --git a/schemas/v3/bitbucket.json b/schemas/v3/bitbucket.json index 02008bab..be2fdda9 100644 --- a/schemas/v3/bitbucket.json +++ b/schemas/v3/bitbucket.json @@ -93,5 +93,13 @@ "required": [ "type" ], + "if": { + "properties": { + "deploymentType": { "const": "server" } + } + }, + "then": { + "required": ["url"] + }, "additionalProperties": false } \ No newline at end of file From 108d609f437c1edf4fc1544eda8affed8c8d982e Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 24 Apr 2025 17:11:50 -0700 Subject: [PATCH 09/14] doc nits --- docs/docs.json | 2 +- .../{bitbucket-server.mdx => bitbucket-data-center.mdx} | 0 docs/docs/connections/overview.mdx | 2 ++ docs/docs/overview.mdx | 2 -- docs/self-hosting/overview.mdx | 6 ++++-- docs/snippets/connection-cards.mdx | 4 ---- 6 files changed, 7 insertions(+), 9 deletions(-) rename docs/docs/connections/{bitbucket-server.mdx => bitbucket-data-center.mdx} (100%) delete mode 100644 docs/snippets/connection-cards.mdx diff --git a/docs/docs.json b/docs/docs.json index b3888135..3cba0056 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -32,7 +32,7 @@ "docs/connections/github", "docs/connections/gitlab", "docs/connections/bitbucket-cloud", - "docs/connections/bitbucket-server", + "docs/connections/bitbucket-data-center", "docs/connections/gitea", "docs/connections/gerrit", "docs/connections/request-new" diff --git a/docs/docs/connections/bitbucket-server.mdx b/docs/docs/connections/bitbucket-data-center.mdx similarity index 100% rename from docs/docs/connections/bitbucket-server.mdx rename to docs/docs/connections/bitbucket-data-center.mdx 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/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/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 From 53248d88393e6e0bbf4525f762159ad42c213226 Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 24 Apr 2025 18:42:17 -0700 Subject: [PATCH 10/14] code rabbit fixes --- docs/docs/connections/bitbucket-cloud.mdx | 2 +- .../connections/bitbucket-data-center.mdx | 6 +- packages/backend/src/bitbucket.ts | 83 +++++++++++++------ packages/backend/src/repoCompileUtils.ts | 10 ++- packages/backend/src/repoManager.ts | 8 +- 5 files changed, 74 insertions(+), 35 deletions(-) diff --git a/docs/docs/connections/bitbucket-cloud.mdx b/docs/docs/connections/bitbucket-cloud.mdx index d10c2e74..a02dd5e4 100644 --- a/docs/docs/connections/bitbucket-cloud.mdx +++ b/docs/docs/connections/bitbucket-cloud.mdx @@ -36,7 +36,7 @@ import BitbucketAppPassword from '/snippets/bitbucket-app-password.mdx'; { "type": "bitbucket", "deploymentType": "cloud", - "project": [ + "projects": [ "myWorkspace/myRepo" ] } diff --git a/docs/docs/connections/bitbucket-data-center.mdx b/docs/docs/connections/bitbucket-data-center.mdx index ee1dddd9..451192cb 100644 --- a/docs/docs/connections/bitbucket-data-center.mdx +++ b/docs/docs/connections/bitbucket-data-center.mdx @@ -14,7 +14,7 @@ import BitbucketAppPassword from '/snippets/bitbucket-app-password.mdx'; { "type": "bitbucket", "deploymentType": "server", - "url": "https://mybitbucketdeployment.com" + "url": "https://mybitbucketdeployment.com", "repos": [ "myProject/myRepo" ] @@ -26,7 +26,7 @@ import BitbucketAppPassword from '/snippets/bitbucket-app-password.mdx'; { "type": "bitbucket", "deploymentType": "server", - "url": "https://mybitbucketdeployment.com" + "url": "https://mybitbucketdeployment.com", "projects": [ "myProject" ] @@ -38,7 +38,7 @@ import BitbucketAppPassword from '/snippets/bitbucket-app-password.mdx'; { "type": "bitbucket", "deploymentType": "server", - "url": "https://mybitbucketdeployment.com" + "url": "https://mybitbucketdeployment.com", // Include all repos in myProject... "projects": [ "myProject" diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index bf82d21c..99e8d42a 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -12,6 +12,7 @@ import { import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi"; import { processPromiseResults } from "./connectionUtils.js"; import { throwIfAnyFailed } from "./connectionUtils.js"; +import { PaginatedResponse } from "@gitbeaker/rest"; const logger = createLogger("Bitbucket"); const BITBUCKET_CLOUD_GIT = 'https://bitbucket.org'; @@ -27,7 +28,6 @@ interface BitbucketClient { apiClient: any; baseUrl: string; gitUrl: string; - getPaginated: (path: V, get: (url: V) => Promise>) => Promise; 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[]}>; @@ -40,7 +40,7 @@ type CloudGetRequestPath = ClientPathsWithMethod; type ServerAPI = ReturnType; type ServerGetRequestPath = ClientPathsWithMethod; -type PaginatedResponse = { +type CloudPaginatedResponse = { readonly next?: string; readonly page?: number; readonly pagelen?: number; @@ -49,6 +49,15 @@ type PaginatedResponse = { 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) : @@ -82,7 +91,7 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon if (config.projects) { const { validRepos, notFoundProjects } = await client.getReposForProjects(client, config.projects); allRepos = allRepos.concat(validRepos); - notFound.repos = notFoundProjects; + notFound.orgs = notFoundProjects; } if (config.repos) { @@ -103,12 +112,18 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon function cloudClient(user: string | undefined, token: string | undefined): BitbucketClient { - const authorizationString = !user || user == "x-token-auth" ? `Bearer ${token}` : `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}`; + 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", - Authorization: authorizationString, + ...(authorizationString ? { Authorization: authorizationString } : {}), }, }; @@ -119,7 +134,6 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu apiClient: apiClient, baseUrl: BITBUCKET_CLOUD_API, gitUrl: BITBUCKET_CLOUD_GIT, - getPaginated: getPaginatedCloud, getReposForWorkspace: cloudGetReposForWorkspace, getReposForProjects: cloudGetReposForProjects, getRepos: cloudGetRepos, @@ -133,7 +147,10 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu * 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: V, get: (url: V) => Promise>) => { +const getPaginatedCloud = async ( + path: CloudGetRequestPath, + get: (url: CloudGetRequestPath) => Promise> +): Promise => { const results: T[] = []; let url = path; @@ -150,8 +167,7 @@ const getPaginatedCloud = async { - const fetchFn = () => client.getPaginated(path, async (url) => { + const fetchFn = () => getPaginatedCloud(path, async (url) => { const response = await client.apiClient.GET(url, { params: { path: { @@ -223,9 +239,15 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin logger.debug(`Fetching all repos for project ${project} for workspace ${workspace}...`); try { - const path = `/repositories/${workspace}?q=project.key="${project_name}"` as CloudGetRequestPath; - const repos = await client.getPaginated(path, async (url) => { - const response = await client.apiClient.GET(url); + 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}`); @@ -317,6 +339,10 @@ function cloudShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketConn 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; } @@ -358,7 +384,6 @@ function serverClient(url: string, user: string | undefined, token: string | und apiClient: apiClient, baseUrl: url, gitUrl: url, - getPaginated: getPaginatedServer, getReposForWorkspace: serverGetReposForWorkspace, getReposForProjects: serverGetReposForProjects, getRepos: serverGetRepos, @@ -368,12 +393,15 @@ function serverClient(url: string, user: string | undefined, token: string | und return client; } -const getPaginatedServer = async (path: V, get: (url: V) => Promise>) => { +const getPaginatedServer = async ( + path: ServerGetRequestPath, + get: (url: ServerGetRequestPath, start?: number) => Promise> +): Promise => { const results: T[] = []; - let url = path; + let nextStart: number | undefined; while (true) { - const response = await get(url); + const response = await get(path, nextStart); if (!response.values || response.values.length === 0) { break; @@ -381,12 +409,11 @@ const getPaginatedServer = async { - const fetchFn = () => client.getPaginated(path, async (url) => { - const response = await client.apiClient.GET(url); + 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)}`); @@ -504,8 +537,10 @@ function serverShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketCon return true; } - // Note: Bitbucket Server doesn't have a direct way to check if a repo is a fork - // We'll need to check the origin property if it exists + if (!!config.exclude?.archived && serverRepo.archived) { + return true; + } + if (!!config.exclude?.forks && serverRepo.origin !== undefined) { return true; } diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index 52036cbe..d93cb8d6 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -378,6 +378,8 @@ export const compileBitbucketConfig = async ( 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); @@ -390,8 +392,8 @@ export const compileBitbucketConfig = async ( webUrl: webUrl, name: repoName, displayName: displayName, - isFork: false, - isArchived: false, + isFork: isFork, + isArchived: isArchived, org: { connect: { id: orgId, @@ -407,8 +409,8 @@ export const compileBitbucketConfig = async ( 'zoekt.web-url-type': codeHostType, 'zoekt.web-url': webUrl, 'zoekt.name': repoName, - 'zoekt.archived': marshalBool(false), - 'zoekt.fork': marshalBool(false), + 'zoekt.archived': marshalBool(isArchived), + 'zoekt.fork': marshalBool(isFork), 'zoekt.public': marshalBool(isPublic), 'zoekt.display-name': displayName, }, diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index 32ab639c..5dc16369 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -190,7 +190,7 @@ export class RepoManager implements IRepoManager { } })(); - let password: string = ""; + let password: string | undefined = undefined; for (const repoConnection of repoConnections) { const connection = repoConnection.connection; if (connection.connectionType !== 'github' && connection.connectionType !== 'gitlab' && connection.connectionType !== 'gitea' && connection.connectionType !== 'bitbucket') { @@ -201,7 +201,7 @@ export class RepoManager implements IRepoManager { if (config.token) { password = await getTokenFromConfig(config.token, connection.orgId, db, this.logger); if (password) { - // If we're using a bitbucket connection, check to see if we're provided a username + // 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"; @@ -211,7 +211,9 @@ export class RepoManager implements IRepoManager { } } - return { username, password }; + return password + ? { username, password } + : undefined; } private async syncGitRepository(repo: RepoWithConnections, repoAlreadyInIndexingState: boolean) { From 82c122ce792e6a5d8a1b59c24fa77957b824d453 Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 24 Apr 2025 18:52:01 -0700 Subject: [PATCH 11/14] fix build error --- Makefile | 1 + packages/backend/src/bitbucket.ts | 3 +-- packages/web/src/actions.ts | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index abae628d..00fbbb1e 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,7 @@ clean: soft-reset: rm -rf .sourcebot redis-cli FLUSHALL + yarn dev:prisma:migrate:reset .PHONY: bin diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index 99e8d42a..5ffdf7e0 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -1,7 +1,7 @@ import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud"; import { createBitbucketServerClient } from "@coderabbitai/bitbucket/server"; import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; -import type { ClientOptions, Client, ClientPathsWithMethod } from "openapi-fetch"; +import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch"; import { createLogger } from "./logger.js"; import { PrismaClient } from "@sourcebot/db"; import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; @@ -12,7 +12,6 @@ import { import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi"; import { processPromiseResults } from "./connectionUtils.js"; import { throwIfAnyFailed } from "./connectionUtils.js"; -import { PaginatedResponse } from "@gitbeaker/rest"; const logger = createLogger("Bitbucket"); const BITBUCKET_CLOUD_GIT = 'https://bitbucket.org'; diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 0d98daff..8b11aa89 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1528,7 +1528,8 @@ const parseConnectionConfig = (connectionType: string, config: string) => { const { numRepos, hasToken } = (() => { switch (parsedConfig.type) { case "gitea": - case "github": { + case "github": + case "bitbucket": { return { numRepos: parsedConfig.repos?.length, hasToken: !!parsedConfig.token, From 44ce5bcb03cad6dd359ef704605ddfd292b1f4f3 Mon Sep 17 00:00:00 2001 From: msukkari Date: Thu, 24 Apr 2025 19:36:52 -0700 Subject: [PATCH 12/14] add bitbucket web ui support --- docs/docs/connections/bitbucket-cloud.mdx | 2 +- packages/web/src/actions.ts | 3 + .../components/codeHostIconButton.tsx | 4 +- .../bitbucketCloudConnectionCreationForm.tsx | 49 +++++ ...bucketDataCenterConnectionCreationForm.tsx | 48 +++++ .../connectionCreationForms/index.ts | 2 + .../components/newConnectionCard.tsx | 12 ++ .../[domain]/connections/new/[type]/page.tsx | 13 +- .../app/[domain]/connections/quickActions.tsx | 202 +++++++++++++++++- .../onboard/components/connectCodeHost.tsx | 40 +++- packages/web/src/lib/posthogEvents.ts | 2 + packages/web/src/lib/utils.ts | 6 +- 12 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx create mode 100644 packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx diff --git a/docs/docs/connections/bitbucket-cloud.mdx b/docs/docs/connections/bitbucket-cloud.mdx index a02dd5e4..aa7b47f0 100644 --- a/docs/docs/connections/bitbucket-cloud.mdx +++ b/docs/docs/connections/bitbucket-cloud.mdx @@ -37,7 +37,7 @@ import BitbucketAppPassword from '/snippets/bitbucket-app-password.mdx'; "type": "bitbucket", "deploymentType": "cloud", "projects": [ - "myWorkspace/myRepo" + "myProject" ] } ``` diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 8b11aa89..0ea86280 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -12,6 +12,7 @@ import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema"; import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; +import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { decrypt, encrypt } from "@sourcebot/crypto" import { getConnection } from "./data/connection"; @@ -1497,6 +1498,8 @@ const parseConnectionConfig = (connectionType: string, config: string) => { return giteaSchema; case 'gerrit': return gerritSchema; + case 'bitbucket': + return bitbucketSchema; } })(); 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..01915de1 --- /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" + 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..05b07071 --- /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" + 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..061ae998 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]/connections/components/newConnectionCard.tsx b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx index 30b545a1..84a3e6e3 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-data-center') { + 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..073d581b 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-data-center") { + 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-data-center"); + captureEvent("wa_onboard_bitbucket_data_center_selected", {}); + }} + /> Date: Fri, 25 Apr 2025 11:08:56 -0700 Subject: [PATCH 13/14] misc cleanups and fix ui issues with bitbucket connections --- packages/backend/src/repoCompileUtils.ts | 13 +++++--- packages/backend/src/repoManager.ts | 2 +- packages/web/src/actions.ts | 13 ++++---- .../bitbucketCloudConnectionCreationForm.tsx | 2 +- ...bucketDataCenterConnectionCreationForm.tsx | 2 +- .../components/importSecretDialog.tsx | 30 +++++++++++++++++-- .../[id]/components/configSetting.tsx | 24 +++++++++++++-- .../app/[domain]/connections/[id]/page.tsx | 3 ++ .../components/newConnectionCard.tsx | 2 +- .../[domain]/connections/new/[type]/page.tsx | 2 +- .../onboard/components/connectCodeHost.tsx | 10 +++---- packages/web/src/lib/posthogEvents.ts | 2 +- packages/web/src/lib/utils.ts | 24 ++++++++++----- 13 files changed, 96 insertions(+), 33 deletions(-) diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index d93cb8d6..0544d856 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -337,15 +337,20 @@ export const compileBitbucketConfig = async ( 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; }[]; - // Annoying difference between server and cloud (happens even if server is hosted with https) - const targetCloneType = config.deploymentType === 'cloud' ? 'https' : 'http'; for (const link of cloneLinks) { - if (link.name === targetCloneType) { + if (link.name === 'http') { return link.href; } } @@ -374,7 +379,7 @@ export const compileBitbucketConfig = async ( const repos = bitbucketRepos.map((repo) => { const isServer = config.deploymentType === 'server'; - const codeHostType = isServer ? 'bitbucket-server' : 'bitbucket-cloud'; + 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; diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index 5dc16369..9158a6b4 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -181,8 +181,8 @@ export class RepoManager implements IRepoManager { switch (repo.external_codeHostType) { case 'gitlab': return 'oauth2'; - case 'bitbucket-server': case 'bitbucket-cloud': + case 'bitbucket-server': case 'github': case 'gitea': default: diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 0ea86280..44a22278 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -7,7 +7,7 @@ import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSu import { prisma } from "@/prisma"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; -import { isServiceError } from "@/lib/utils"; +import { CodeHostType, isServiceError } from "@/lib/utils"; import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema"; @@ -444,10 +444,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; } @@ -533,7 +533,7 @@ export const updateConnectionConfigAndScheduleSync = async (connectionId: number return notFound(); } - const parsedConfig = parseConnectionConfig(connection.connectionType, config); + const parsedConfig = parseConnectionConfig(config); if (isServiceError(parsedConfig)) { return parsedConfig; } @@ -1476,7 +1476,7 @@ const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.Transactio return subscriptions.data[0]; } -const parseConnectionConfig = (connectionType: string, config: string) => { +const parseConnectionConfig = (config: string) => { let parsedConfig: ConnectionConfig; try { parsedConfig = JSON.parse(config); @@ -1488,6 +1488,7 @@ const parseConnectionConfig = (connectionType: string, config: string) => { } satisfies ServiceError; } + const connectionType = parsedConfig.type; const schema = (() => { switch (connectionType) { case "github": @@ -1529,7 +1530,7 @@ const parseConnectionConfig = (connectionType: string, config: string) => { } const { numRepos, hasToken } = (() => { - switch (parsedConfig.type) { + switch (connectionType) { case "gitea": case "github": case "bitbucket": { diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx index 01915de1..52c762fc 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx @@ -35,7 +35,7 @@ export const BitbucketCloudConnectionCreationForm = ({ onCreated }: BitbucketClo return ( - type="bitbucket" + type="bitbucket-cloud" title="Create a Bitbucket Cloud connection" defaultValues={{ config: JSON.stringify(defaultConfig, null, 2), diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx index 05b07071..5065de00 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx @@ -34,7 +34,7 @@ export const BitbucketDataCenterConnectionCreationForm = ({ onCreated }: Bitbuck return ( - type="bitbucket" + type="bitbucket-server" title="Create a Bitbucket Data Center connection" defaultValues={{ config: JSON.stringify(defaultConfig, null, 2), 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..0cd6a7e0 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 diff --git a/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx index 84a3e6e3..043e7045 100644 --- a/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx +++ b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx @@ -56,7 +56,7 @@ export const NewConnectionCard = ({ className, role }: NewConnectionCardProps) = disabled={!isOwner} /> ; } - if (type === 'bitbucket-data-center') { + if (type === 'bitbucket-server') { return ; } diff --git a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx index 073d581b..00fecdb0 100644 --- a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx +++ b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx @@ -90,7 +90,7 @@ export const ConnectCodeHost = ({ nextStep, securityCardEnabled }: ConnectCodeHo ) } - if (selectedCodeHost === "bitbucket-data-center") { + if (selectedCodeHost === "bitbucket-server") { return ( <> @@ -129,7 +129,7 @@ const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => { /> { onSelect("bitbucket-cloud"); captureEvent("wa_onboard_bitbucket_cloud_selected", {}); @@ -137,10 +137,10 @@ const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => { /> { - onSelect("bitbucket-data-center"); - captureEvent("wa_onboard_bitbucket_data_center_selected", {}); + onSelect("bitbucket-server"); + captureEvent("wa_onboard_bitbucket_server_selected", {}); }} /> Date: Fri, 25 Apr 2025 11:17:03 -0700 Subject: [PATCH 14/14] add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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