Add Bitbucket support (#275)

* [wip] add bitbucket schema

* wip bitbucket support

* add support for pulling bitbucket repos and UI support for bitbucket

* fix bitbucket app password auth case

* add back support for multiple workspaces and add exclude logic

* add branches to bitbucket

* add bitbucket server support

* add docs for bitbucket and minor nits

* doc nits

* code rabbit fixes

* fix build error

* add bitbucket web ui support

* misc cleanups and fix ui issues with bitbucket connections

* add changelog entry
This commit is contained in:
Michael Sukkarieh 2025-04-25 11:22:40 -07:00 committed by GitHub
parent 2acb1e558f
commit b6dedc78ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2415 additions and 61 deletions

View file

@ -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

View file

@ -31,6 +31,8 @@
"docs/connections/overview",
"docs/connections/github",
"docs/connections/gitlab",
"docs/connections/bitbucket-cloud",
"docs/connections/bitbucket-data-center",
"docs/connections/gitea",
"docs/connections/gerrit",
"docs/connections/request-new"

View file

@ -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
<AccordionGroup>
<Accordion title="Sync individual repos">
```json
{
"type": "bitbucket",
"deploymentType": "cloud",
"repos": [
"myWorkspace/myRepo"
]
}
```
</Accordion>
<Accordion title="Sync all repos in a workspace">
```json
{
"type": "bitbucket",
"deploymentType": "cloud",
"workspaces": [
"myWorkspace"
]
}
```
</Accordion>
<Accordion title="Sync all repos in a project">
```json
{
"type": "bitbucket",
"deploymentType": "cloud",
"projects": [
"myProject"
]
}
```
</Accordion>
<Accordion title="Exclude repos from syncing">
```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/*"
]
}
}
```
</Accordion>
</AccordionGroup>
## 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`
<Tabs>
<Tab title="App Password">
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:
<BitbucketAppPassword />
</Tab>
<Tab title="Access Token">
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:
<BitbucketToken />
</Tab>
</Tabs>
## Schema reference
<Accordion title="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
}
```
</Accordion>

View file

@ -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
<AccordionGroup>
<Accordion title="Sync individual repos">
```json
{
"type": "bitbucket",
"deploymentType": "server",
"url": "https://mybitbucketdeployment.com",
"repos": [
"myProject/myRepo"
]
}
```
</Accordion>
<Accordion title="Sync all repos in a project">
```json
{
"type": "bitbucket",
"deploymentType": "server",
"url": "https://mybitbucketdeployment.com",
"projects": [
"myProject"
]
}
```
</Accordion>
<Accordion title="Exclude repos from syncing">
```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/*"
]
}
}
```
</Accordion>
</AccordionGroup>
## 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:
<BitbucketToken />
## Schema reference
<Accordion title="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
}
```
</Accordion>

View file

@ -26,6 +26,8 @@ There are two ways to define connections:
<CardGroup cols={2}>
<Card horizontal title="GitHub" icon="github" href="/docs/connections/github" />
<Card horizontal title="GitLab" icon="gitlab" href="/docs/connections/gitlab" />
<Card horizontal title="Bitbucket Cloud" icon="bitbucket" href="/docs/connections/bitbucket-cloud" />
<Card horizontal title="Bitbucket Data Center" icon="bitbucket" href="/docs/connections/bitbucket-data-center" />
<Card horizontal title="Gitea" href="/docs/connections/gitea" />
<Card horizontal title="Gerrit" href="/docs/connections/gerrit" />
</CardGroup>

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View file

@ -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:
<CardGroup cols={2}>
<Card horizontal title="GitHub" href="/docs/connections/github" />
<Card horizontal title="GitLab" href="/docs/connections/gitlab" />
<Card horizontal title="GitHub" icon="github" href="/docs/connections/github" />
<Card horizontal title="GitLab" icon="gitlab" href="/docs/connections/gitlab" />
<Card horizontal title="Bitbucket Cloud" icon="bitbucket" href="/docs/connections/bitbucket-cloud" />
<Card horizontal title="Bitbucket Data Center" icon="bitbucket" href="/docs/connections/bitbucket-data-center" />
<Card horizontal title="Gitea" href="/docs/connections/gitea" />
<Card horizontal title="Gerrit" href="/docs/connections/gerrit" />
</CardGroup>

View file

@ -0,0 +1,51 @@
<Tabs>
<Tab title="Environment Variable">
<Note>Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI.</Note>
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=<PAT> \
/* additional args */ \
ghcr.io/sourcebot-dev/sourcebot:latest
```
</Tab>
<Tab title="Secret">
<Note>Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled.</Note>
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 ..
}
```
</Tab>
</Tabs>

View file

@ -0,0 +1,47 @@
<Tabs>
<Tab title="Environment Variable">
<Note>Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI.</Note>
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=<PAT> \
/* additional args */ \
ghcr.io/sourcebot-dev/sourcebot:latest
```
</Tab>
<Tab title="Secret">
<Note>Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled.</Note>
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 ..
}
```
</Tab>
</Tabs>

View file

@ -1,4 +0,0 @@
<CardGroup cols={2}>
<Card title="GitHub" icon="github" href="/docs/connections/github"></Card>
<Card title="GitLab" icon="gitlab" href="/docs/connections/gitlab"></Card>
</CardGroup>

View file

@ -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"
}
}

View file

@ -0,0 +1,553 @@
import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud";
import { createBitbucketServerClient } from "@coderabbitai/bitbucket/server";
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type";
import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch";
import { createLogger } from "./logger.js";
import { PrismaClient } from "@sourcebot/db";
import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
import * as Sentry from "@sentry/node";
import {
SchemaRepository as CloudRepository,
} from "@coderabbitai/bitbucket/cloud/openapi";
import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi";
import { processPromiseResults } from "./connectionUtils.js";
import { throwIfAnyFailed } from "./connectionUtils.js";
const logger = createLogger("Bitbucket");
const BITBUCKET_CLOUD_GIT = 'https://bitbucket.org';
const BITBUCKET_CLOUD_API = 'https://api.bitbucket.org/2.0';
const BITBUCKET_CLOUD = "cloud";
const BITBUCKET_SERVER = "server";
export type BitbucketRepository = CloudRepository | ServerRepository;
interface BitbucketClient {
deploymentType: string;
token: string | undefined;
apiClient: any;
baseUrl: string;
gitUrl: string;
getReposForWorkspace: (client: BitbucketClient, workspaces: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundWorkspaces: string[]}>;
getReposForProjects: (client: BitbucketClient, projects: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundProjects: string[]}>;
getRepos: (client: BitbucketClient, repos: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundRepos: string[]}>;
shouldExcludeRepo: (repo: BitbucketRepository, config: BitbucketConnectionConfig) => boolean;
}
type CloudAPI = ReturnType<typeof createBitbucketCloudClient>;
type CloudGetRequestPath = ClientPathsWithMethod<CloudAPI, "get">;
type ServerAPI = ReturnType<typeof createBitbucketServerClient>;
type ServerGetRequestPath = ClientPathsWithMethod<ServerAPI, "get">;
type CloudPaginatedResponse<T> = {
readonly next?: string;
readonly page?: number;
readonly pagelen?: number;
readonly previous?: string;
readonly size?: number;
readonly values?: readonly T[];
}
type ServerPaginatedResponse<T> = {
readonly size: number;
readonly limit: number;
readonly isLastPage: boolean;
readonly values: readonly T[];
readonly start: number;
readonly nextPageStart: number;
}
export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => {
const token = config.token ?
await getTokenFromConfig(config.token, orgId, db, logger) :
undefined;
if (config.deploymentType === 'server' && !config.url) {
throw new Error('URL is required for Bitbucket Server');
}
const client = config.deploymentType === 'server' ?
serverClient(config.url!, config.user, token) :
cloudClient(config.user, token);
let allRepos: BitbucketRepository[] = [];
let notFound: {
orgs: string[],
users: string[],
repos: string[],
} = {
orgs: [],
users: [],
repos: [],
};
if (config.workspaces) {
const { validRepos, notFoundWorkspaces } = await client.getReposForWorkspace(client, config.workspaces);
allRepos = allRepos.concat(validRepos);
notFound.orgs = notFoundWorkspaces;
}
if (config.projects) {
const { validRepos, notFoundProjects } = await client.getReposForProjects(client, config.projects);
allRepos = allRepos.concat(validRepos);
notFound.orgs = notFoundProjects;
}
if (config.repos) {
const { validRepos, notFoundRepos } = await client.getRepos(client, config.repos);
allRepos = allRepos.concat(validRepos);
notFound.repos = notFoundRepos;
}
const filteredRepos = allRepos.filter((repo) => {
return !client.shouldExcludeRepo(repo, config);
});
return {
validRepos: filteredRepos,
notFound,
};
}
function cloudClient(user: string | undefined, token: string | undefined): BitbucketClient {
const authorizationString =
token
? !user || user == "x-token-auth"
? `Bearer ${token}`
: `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}`
: undefined;
const clientOptions: ClientOptions = {
baseUrl: BITBUCKET_CLOUD_API,
headers: {
Accept: "application/json",
...(authorizationString ? { Authorization: authorizationString } : {}),
},
};
const apiClient = createBitbucketCloudClient(clientOptions);
var client: BitbucketClient = {
deploymentType: BITBUCKET_CLOUD,
token: token,
apiClient: apiClient,
baseUrl: BITBUCKET_CLOUD_API,
gitUrl: BITBUCKET_CLOUD_GIT,
getReposForWorkspace: cloudGetReposForWorkspace,
getReposForProjects: cloudGetReposForProjects,
getRepos: cloudGetRepos,
shouldExcludeRepo: cloudShouldExcludeRepo,
}
return client;
}
/**
* We need to do `V extends CloudGetRequestPath` since we will need to call `apiClient.GET(url, ...)`, which
* expects `url` to be of type `CloudGetRequestPath`. See example.
**/
const getPaginatedCloud = async <T>(
path: CloudGetRequestPath,
get: (url: CloudGetRequestPath) => Promise<CloudPaginatedResponse<T>>
): Promise<T[]> => {
const results: T[] = [];
let url = path;
while (true) {
const response = await get(url);
if (!response.values || response.values.length === 0) {
break;
}
results.push(...response.values);
if (!response.next) {
break;
}
url = response.next as CloudGetRequestPath;
}
return results;
}
async function cloudGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{validRepos: CloudRepository[], notFoundWorkspaces: string[]}> {
const results = await Promise.allSettled(workspaces.map(async (workspace) => {
try {
logger.debug(`Fetching all repos for workspace ${workspace}...`);
const path = `/repositories/${workspace}` as CloudGetRequestPath;
const { durationMs, data } = await measure(async () => {
const fetchFn = () => getPaginatedCloud<CloudRepository>(path, async (url) => {
const response = await client.apiClient.GET(url, {
params: {
path: {
workspace,
}
}
});
const { data, error } = response;
if (error) {
throw new Error(`Failed to fetch projects for workspace ${workspace}: ${JSON.stringify(error)}`);
}
return data;
});
return fetchWithRetry(fetchFn, `workspace ${workspace}`, logger);
});
logger.debug(`Found ${data.length} repos for workspace ${workspace} in ${durationMs}ms.`);
return {
type: 'valid' as const,
data: data,
};
} catch (e: any) {
Sentry.captureException(e);
logger.error(`Failed to get repos for workspace ${workspace}: ${e}`);
const status = e?.cause?.response?.status;
if (status == 404) {
logger.error(`Workspace ${workspace} not found or invalid access`)
return {
type: 'notFound' as const,
value: workspace
}
}
throw e;
}
}));
throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundWorkspaces } = processPromiseResults(results);
return {
validRepos,
notFoundWorkspaces,
};
}
async function cloudGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{validRepos: CloudRepository[], notFoundProjects: string[]}> {
const results = await Promise.allSettled(projects.map(async (project) => {
const [workspace, project_name] = project.split('/');
if (!workspace || !project_name) {
logger.error(`Invalid project ${project}`);
return {
type: 'notFound' as const,
value: project
}
}
logger.debug(`Fetching all repos for project ${project} for workspace ${workspace}...`);
try {
const path = `/repositories/${workspace}` as CloudGetRequestPath;
const repos = await getPaginatedCloud<CloudRepository>(path, async (url) => {
const response = await client.apiClient.GET(url, {
params: {
query: {
q: `project.key="${project_name}"`
}
}
});
const { data, error } = response;
if (error) {
throw new Error (`Failed to fetch projects for workspace ${workspace}: ${error.type}`);
}
return data;
});
logger.debug(`Found ${repos.length} repos for project ${project_name} for workspace ${workspace}.`);
return {
type: 'valid' as const,
data: repos
}
} catch (e: any) {
Sentry.captureException(e);
logger.error(`Failed to fetch repos for project ${project_name}: ${e}`);
const status = e?.cause?.response?.status;
if (status == 404) {
logger.error(`Project ${project_name} not found in ${workspace} or invalid access`)
return {
type: 'notFound' as const,
value: project
}
}
throw e;
}
}));
throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results);
return {
validRepos,
notFoundProjects
}
}
async function cloudGetRepos(client: BitbucketClient, repos: string[]): Promise<{validRepos: CloudRepository[], notFoundRepos: string[]}> {
const results = await Promise.allSettled(repos.map(async (repo) => {
const [workspace, repo_slug] = repo.split('/');
if (!workspace || !repo_slug) {
logger.error(`Invalid repo ${repo}`);
return {
type: 'notFound' as const,
value: repo
};
}
logger.debug(`Fetching repo ${repo_slug} for workspace ${workspace}...`);
try {
const path = `/repositories/${workspace}/${repo_slug}` as CloudGetRequestPath;
const response = await client.apiClient.GET(path);
const { data, error } = response;
if (error) {
throw new Error(`Failed to fetch repo ${repo}: ${error.type}`);
}
return {
type: 'valid' as const,
data: [data]
};
} catch (e: any) {
Sentry.captureException(e);
logger.error(`Failed to fetch repo ${repo}: ${e}`);
const status = e?.cause?.response?.status;
if (status === 404) {
logger.error(`Repo ${repo} not found in ${workspace} or invalid access`);
return {
type: 'notFound' as const,
value: repo
};
}
throw e;
}
}));
throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results);
return {
validRepos,
notFoundRepos
};
}
function cloudShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketConnectionConfig): boolean {
const cloudRepo = repo as CloudRepository;
const shouldExclude = (() => {
if (config.exclude?.repos && config.exclude.repos.includes(cloudRepo.full_name!)) {
return true;
}
if (!!config.exclude?.archived) {
logger.warn(`Exclude archived repos flag provided in config but Bitbucket Cloud does not support archived repos. Ignoring...`);
}
if (!!config.exclude?.forks && cloudRepo.parent !== undefined) {
return true;
}
})();
if (shouldExclude) {
logger.debug(`Excluding repo ${cloudRepo.full_name} because it matches the exclude pattern`);
return true;
}
return false;
}
function serverClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient {
const authorizationString = (() => {
// If we're not given any credentials we return an empty auth string. This will only work if the project/repos are public
if(!user && !token) {
return "";
}
// A user must be provided when using basic auth
// https://developer.atlassian.com/server/bitbucket/rest/v906/intro/#authentication
if (!user || user == "x-token-auth") {
return `Bearer ${token}`;
}
return `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}`;
})();
const clientOptions: ClientOptions = {
baseUrl: url,
headers: {
Accept: "application/json",
Authorization: authorizationString,
},
};
const apiClient = createBitbucketServerClient(clientOptions);
var client: BitbucketClient = {
deploymentType: BITBUCKET_SERVER,
token: token,
apiClient: apiClient,
baseUrl: url,
gitUrl: url,
getReposForWorkspace: serverGetReposForWorkspace,
getReposForProjects: serverGetReposForProjects,
getRepos: serverGetRepos,
shouldExcludeRepo: serverShouldExcludeRepo,
}
return client;
}
const getPaginatedServer = async <T>(
path: ServerGetRequestPath,
get: (url: ServerGetRequestPath, start?: number) => Promise<ServerPaginatedResponse<T>>
): Promise<T[]> => {
const results: T[] = [];
let nextStart: number | undefined;
while (true) {
const response = await get(path, nextStart);
if (!response.values || response.values.length === 0) {
break;
}
results.push(...response.values);
if (response.isLastPage) {
break;
}
nextStart = response.nextPageStart;
}
return results;
}
async function serverGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{validRepos: ServerRepository[], notFoundWorkspaces: string[]}> {
logger.debug('Workspaces are not supported in Bitbucket Server');
return {
validRepos: [],
notFoundWorkspaces: workspaces
};
}
async function serverGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{validRepos: ServerRepository[], notFoundProjects: string[]}> {
const results = await Promise.allSettled(projects.map(async (project) => {
try {
logger.debug(`Fetching all repos for project ${project}...`);
const path = `/rest/api/1.0/projects/${project}/repos` as ServerGetRequestPath;
const { durationMs, data } = await measure(async () => {
const fetchFn = () => getPaginatedServer<ServerRepository>(path, async (url, start) => {
const response = await client.apiClient.GET(url, {
params: {
query: {
start,
}
}
});
const { data, error } = response;
if (error) {
throw new Error(`Failed to fetch repos for project ${project}: ${JSON.stringify(error)}`);
}
return data;
});
return fetchWithRetry(fetchFn, `project ${project}`, logger);
});
logger.debug(`Found ${data.length} repos for project ${project} in ${durationMs}ms.`);
return {
type: 'valid' as const,
data: data,
};
} catch (e: any) {
Sentry.captureException(e);
logger.error(`Failed to get repos for project ${project}: ${e}`);
const status = e?.cause?.response?.status;
if (status == 404) {
logger.error(`Project ${project} not found or invalid access`);
return {
type: 'notFound' as const,
value: project
};
}
throw e;
}
}));
throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results);
return {
validRepos,
notFoundProjects
};
}
async function serverGetRepos(client: BitbucketClient, repos: string[]): Promise<{validRepos: ServerRepository[], notFoundRepos: string[]}> {
const results = await Promise.allSettled(repos.map(async (repo) => {
const [project, repo_slug] = repo.split('/');
if (!project || !repo_slug) {
logger.error(`Invalid repo ${repo}`);
return {
type: 'notFound' as const,
value: repo
};
}
logger.debug(`Fetching repo ${repo_slug} for project ${project}...`);
try {
const path = `/rest/api/1.0/projects/${project}/repos/${repo_slug}` as ServerGetRequestPath;
const response = await client.apiClient.GET(path);
const { data, error } = response;
if (error) {
throw new Error(`Failed to fetch repo ${repo}: ${error.type}`);
}
return {
type: 'valid' as const,
data: [data]
};
} catch (e: any) {
Sentry.captureException(e);
logger.error(`Failed to fetch repo ${repo}: ${e}`);
const status = e?.cause?.response?.status;
if (status === 404) {
logger.error(`Repo ${repo} not found in project ${project} or invalid access`);
return {
type: 'notFound' as const,
value: repo
};
}
throw e;
}
}));
throwIfAnyFailed(results);
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results);
return {
validRepos,
notFoundRepos
};
}
function serverShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketConnectionConfig): boolean {
const serverRepo = repo as ServerRepository;
const projectName = serverRepo.project!.key;
const repoSlug = serverRepo.slug!;
const shouldExclude = (() => {
if (config.exclude?.repos && config.exclude.repos.includes(`${projectName}/${repoSlug}`)) {
return true;
}
if (!!config.exclude?.archived && serverRepo.archived) {
return true;
}
if (!!config.exclude?.forks && serverRepo.origin !== undefined) {
return true;
}
})();
if (shouldExclude) {
logger.debug(`Excluding repo ${projectName}/${repoSlug} because it matches the exclude pattern`);
return true;
}
return false;
}

View file

@ -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) {

View file

@ -3,11 +3,14 @@ import { getGitHubReposFromConfig } from "./github.js";
import { getGitLabReposFromConfig } from "./gitlab.js";
import { getGiteaReposFromConfig } from "./gitea.js";
import { getGerritReposFromConfig } from "./gerrit.js";
import { BitbucketRepository, getBitbucketReposFromConfig } from "./bitbucket.js";
import { SchemaRestRepository as BitbucketServerRepository } from "@coderabbitai/bitbucket/server/openapi";
import { SchemaRepository as BitbucketCloudRepository } from "@coderabbitai/bitbucket/cloud/openapi";
import { Prisma, PrismaClient } from '@sourcebot/db';
import { WithRequired } from "./types.js"
import { marshalBool } from "./utils.js";
import { createLogger } from './logger.js';
import { GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
import { RepoMetadata } from './types.js';
import path from 'path';
@ -313,3 +316,119 @@ export const compileGerritConfig = async (
}
};
}
export const compileBitbucketConfig = async (
config: BitbucketConnectionConfig,
connectionId: number,
orgId: number,
db: PrismaClient) => {
const bitbucketReposResult = await getBitbucketReposFromConfig(config, orgId, db);
const bitbucketRepos = bitbucketReposResult.validRepos;
const notFound = bitbucketReposResult.notFound;
const hostUrl = config.url ?? 'https://bitbucket.org';
const repoNameRoot = new URL(hostUrl)
.toString()
.replace(/^https?:\/\//, '');
const getCloneUrl = (repo: BitbucketRepository) => {
if (!repo.links) {
throw new Error(`No clone links found for server repo ${repo.name}`);
}
// In the cloud case we simply fetch the html link and use that as the clone url. For server we
// need to fetch the actual clone url
if (config.deploymentType === 'cloud') {
const htmlLink = repo.links.html as { href: string };
return htmlLink.href;
}
const cloneLinks = repo.links.clone as {
href: string;
name: string;
}[];
for (const link of cloneLinks) {
if (link.name === 'http') {
return link.href;
}
}
throw new Error(`No clone links found for repo ${repo.name}`);
}
const getWebUrl = (repo: BitbucketRepository) => {
const isServer = config.deploymentType === 'server';
const repoLinks = (repo as BitbucketServerRepository | BitbucketCloudRepository).links;
const repoName = isServer ? (repo as BitbucketServerRepository).name : (repo as BitbucketCloudRepository).full_name;
if (!repoLinks) {
throw new Error(`No links found for ${isServer ? 'server' : 'cloud'} repo ${repoName}`);
}
// In server case we get an array of lenth == 1 links in the self field, while in cloud case we get a single
// link object in the html field
const link = isServer ? (repoLinks.self as { name: string, href: string }[])?.[0] : repoLinks.html as { href: string };
if (!link || !link.href) {
throw new Error(`No ${isServer ? 'self' : 'html'} link found for ${isServer ? 'server' : 'cloud'} repo ${repoName}`);
}
return link.href;
}
const repos = bitbucketRepos.map((repo) => {
const isServer = config.deploymentType === 'server';
const codeHostType = isServer ? 'bitbucket-server' : 'bitbucket-cloud'; // zoekt expects bitbucket-server
const displayName = isServer ? (repo as BitbucketServerRepository).name! : (repo as BitbucketCloudRepository).full_name!;
const externalId = isServer ? (repo as BitbucketServerRepository).id!.toString() : (repo as BitbucketCloudRepository).uuid!;
const isPublic = isServer ? (repo as BitbucketServerRepository).public : (repo as BitbucketCloudRepository).is_private === false;
const isArchived = isServer ? (repo as BitbucketServerRepository).archived === true : false;
const isFork = isServer ? (repo as BitbucketServerRepository).origin !== undefined : (repo as BitbucketCloudRepository).parent !== undefined;
const repoName = path.join(repoNameRoot, displayName);
const cloneUrl = getCloneUrl(repo);
const webUrl = getWebUrl(repo);
const record: RepoData = {
external_id: externalId,
external_codeHostType: codeHostType,
external_codeHostUrl: hostUrl,
cloneUrl: cloneUrl,
webUrl: webUrl,
name: repoName,
displayName: displayName,
isFork: isFork,
isArchived: isArchived,
org: {
connect: {
id: orgId,
},
},
connections: {
create: {
connectionId: connectionId,
}
},
metadata: {
gitConfig: {
'zoekt.web-url-type': codeHostType,
'zoekt.web-url': webUrl,
'zoekt.name': repoName,
'zoekt.archived': marshalBool(isArchived),
'zoekt.fork': marshalBool(isFork),
'zoekt.public': marshalBool(isPublic),
'zoekt.display-name': displayName,
},
branches: config.revisions?.branches ?? undefined,
tags: config.revisions?.tags ?? undefined,
} satisfies RepoMetadata,
};
return record;
})
return {
repoData: repos,
notFound,
};
}

View file

@ -2,7 +2,7 @@ import { Job, Queue, Worker } from 'bullmq';
import { Redis } from 'ioredis';
import { createLogger } from "./logger.js";
import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
import { AppContext, Settings, repoMetadataSchema } from "./types.js";
import { getRepoPath, getTokenFromConfig, measure, getShardPrefix } from "./utils.js";
import { cloneRepository, fetchRepository, upsertGitConfig } from "./git.js";
@ -170,31 +170,50 @@ export class RepoManager implements IRepoManager {
// fetch the token here using the connections from the repo. Multiple connections could be referencing this repo, and each
// may have their own token. This method will just pick the first connection that has a token (if one exists) and uses that. This
// may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referrencing.
private async getTokenForRepo(repo: RepoWithConnections, db: PrismaClient) {
private async getAuthForRepo(repo: RepoWithConnections, db: PrismaClient): Promise<{ username: string, password: string } | undefined> {
const repoConnections = repo.connections;
if (repoConnections.length === 0) {
this.logger.error(`Repo ${repo.id} has no connections`);
return;
return undefined;
}
let username = (() => {
switch (repo.external_codeHostType) {
case 'gitlab':
return 'oauth2';
case 'bitbucket-cloud':
case 'bitbucket-server':
case 'github':
case 'gitea':
default:
return '';
}
})();
let token: string | undefined;
let password: string | undefined = undefined;
for (const repoConnection of repoConnections) {
const connection = repoConnection.connection;
if (connection.connectionType !== 'github' && connection.connectionType !== 'gitlab' && connection.connectionType !== 'gitea') {
if (connection.connectionType !== 'github' && connection.connectionType !== 'gitlab' && connection.connectionType !== 'gitea' && connection.connectionType !== 'bitbucket') {
continue;
}
const config = connection.config as unknown as GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig;
const config = connection.config as unknown as GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig | BitbucketConnectionConfig;
if (config.token) {
token = await getTokenFromConfig(config.token, connection.orgId, db, this.logger);
if (token) {
password = await getTokenFromConfig(config.token, connection.orgId, db, this.logger);
if (password) {
// If we're using a bitbucket connection we need to set the username to be able to clone the repo
if (connection.connectionType === 'bitbucket') {
const bitbucketConfig = config as BitbucketConnectionConfig;
username = bitbucketConfig.user ?? "x-token-auth";
}
break;
}
}
}
return token;
return password
? { username, password }
: undefined;
}
private async syncGitRepository(repo: RepoWithConnections, repoAlreadyInIndexingState: boolean) {
@ -225,20 +244,11 @@ export class RepoManager implements IRepoManager {
} else {
this.logger.info(`Cloning ${repo.displayName}...`);
const token = await this.getTokenForRepo(repo, this.db);
const auth = await this.getAuthForRepo(repo, this.db);
const cloneUrl = new URL(repo.cloneUrl);
if (token) {
switch (repo.external_codeHostType) {
case 'gitlab':
cloneUrl.username = 'oauth2';
cloneUrl.password = token;
break;
case 'gitea':
case 'github':
default:
cloneUrl.username = token;
break;
}
if (auth) {
cloneUrl.username = auth.username;
cloneUrl.password = auth.password;
}
const { durationMs } = await measure(() => cloneRepository(cloneUrl.toString(), repoPath, ({ method, stage, progress }) => {
@ -318,12 +328,12 @@ export class RepoManager implements IRepoManager {
attempts++;
this.promClient.repoIndexingReattemptsTotal.inc();
if (attempts === maxAttempts) {
this.logger.error(`Failed to sync repository ${repo.id} after ${maxAttempts} attempts. Error: ${error}`);
this.logger.error(`Failed to sync repository ${repo.name} (id: ${repo.id}) after ${maxAttempts} attempts. Error: ${error}`);
throw error;
}
const sleepDuration = 5000 * Math.pow(2, attempts - 1);
this.logger.error(`Failed to sync repository ${repo.id}, attempt ${attempts}/${maxAttempts}. Sleeping for ${sleepDuration / 1000}s... Error: ${error}`);
this.logger.error(`Failed to sync repository ${repo.name} (id: ${repo.id}), attempt ${attempts}/${maxAttempts}. Sleeping for ${sleepDuration / 1000}s... Error: ${error}`);
await new Promise(resolve => setTimeout(resolve, sleepDuration));
}
}
@ -453,7 +463,7 @@ export class RepoManager implements IRepoManager {
}
private async runGarbageCollectionJob(job: Job<RepoGarbageCollectionPayload>) {
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;

View file

@ -0,0 +1,179 @@
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
const schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "BitbucketConnectionConfig",
"properties": {
"type": {
"const": "bitbucket",
"description": "Bitbucket configuration"
},
"user": {
"type": "string",
"description": "The username to use for authentication. Only needed if token is an app password."
},
"token": {
"description": "An authentication token.",
"examples": [
{
"secret": "SECRET_KEY"
}
],
"anyOf": [
{
"type": "object",
"properties": {
"secret": {
"type": "string",
"description": "The name of the secret that contains the token."
}
},
"required": [
"secret"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"env": {
"type": "string",
"description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
}
},
"required": [
"env"
],
"additionalProperties": false
}
]
},
"url": {
"type": "string",
"format": "url",
"default": "https://api.bitbucket.org/2.0",
"description": "Bitbucket URL",
"examples": [
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
"deploymentType": {
"type": "string",
"enum": [
"cloud",
"server"
],
"default": "cloud",
"description": "The type of Bitbucket deployment"
},
"workspaces": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of workspaces to sync. Ignored if deploymentType is server."
},
"projects": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of projects to sync"
},
"repos": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of repos to sync"
},
"exclude": {
"type": "object",
"properties": {
"archived": {
"type": "boolean",
"default": false,
"description": "Exclude archived repositories from syncing."
},
"forks": {
"type": "boolean",
"default": false,
"description": "Exclude forked repositories from syncing."
},
"repos": {
"type": "array",
"items": {
"type": "string"
},
"examples": [
[
"cloud_workspace/repo1",
"server_project/repo2"
]
],
"description": "List of specific repos to exclude from syncing."
}
},
"additionalProperties": false
},
"revisions": {
"type": "object",
"description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.",
"properties": {
"branches": {
"type": "array",
"description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.",
"items": {
"type": "string"
},
"examples": [
[
"main",
"release/*"
],
[
"**"
]
],
"default": []
},
"tags": {
"type": "array",
"description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.",
"items": {
"type": "string"
},
"examples": [
[
"latest",
"v2.*.*"
],
[
"**"
]
],
"default": []
}
},
"additionalProperties": false
}
},
"required": [
"type"
],
"if": {
"properties": {
"deploymentType": {
"const": "server"
}
}
},
"then": {
"required": [
"url"
]
},
"additionalProperties": false
} as const;
export { schema as bitbucketSchema };

View file

@ -0,0 +1,76 @@
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
export interface BitbucketConnectionConfig {
/**
* Bitbucket configuration
*/
type: "bitbucket";
/**
* The username to use for authentication. Only needed if token is an app password.
*/
user?: string;
/**
* An authentication token.
*/
token?:
| {
/**
* The name of the secret that contains the token.
*/
secret: string;
}
| {
/**
* The name of the environment variable that contains the token. Only supported in declarative connection configs.
*/
env: string;
};
/**
* Bitbucket URL
*/
url?: string;
/**
* The type of Bitbucket deployment
*/
deploymentType?: "cloud" | "server";
/**
* List of workspaces to sync. Ignored if deploymentType is server.
*/
workspaces?: string[];
/**
* List of projects to sync
*/
projects?: string[];
/**
* List of repos to sync
*/
repos?: string[];
exclude?: {
/**
* Exclude archived repositories from syncing.
*/
archived?: boolean;
/**
* Exclude forked repositories from syncing.
*/
forks?: boolean;
/**
* List of specific repos to exclude from syncing.
*/
repos?: string[];
};
revisions?: GitRevisions;
}
/**
* The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.
*/
export interface GitRevisions {
/**
* List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.
*/
branches?: string[];
/**
* List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.
*/
tags?: string[];
}

View file

@ -504,6 +504,118 @@ const schema = {
"url"
],
"additionalProperties": false
},
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "BitbucketConnectionConfig",
"properties": {
"type": {
"const": "bitbucket",
"description": "Bitbucket configuration"
},
"user": {
"type": "string",
"description": "The username to use for authentication. Only needed if token is an app password."
},
"token": {
"$ref": "#/oneOf/0/properties/token",
"description": "An authentication token.",
"examples": [
{
"secret": "SECRET_KEY"
}
]
},
"url": {
"type": "string",
"format": "url",
"default": "https://api.bitbucket.org/2.0",
"description": "Bitbucket URL",
"examples": [
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
"deploymentType": {
"type": "string",
"enum": [
"cloud",
"server"
],
"default": "cloud",
"description": "The type of Bitbucket deployment"
},
"workspaces": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of workspaces to sync. Ignored if deploymentType is server."
},
"projects": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of projects to sync"
},
"repos": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of repos to sync"
},
"exclude": {
"type": "object",
"properties": {
"archived": {
"type": "boolean",
"default": false,
"description": "Exclude archived repositories from syncing."
},
"forks": {
"type": "boolean",
"default": false,
"description": "Exclude forked repositories from syncing."
},
"repos": {
"type": "array",
"items": {
"type": "string"
},
"examples": [
[
"cloud_workspace/repo1",
"server_project/repo2"
]
],
"description": "List of specific repos to exclude from syncing."
}
},
"additionalProperties": false
},
"revisions": {
"$ref": "#/oneOf/0/properties/revisions"
}
},
"required": [
"type"
],
"if": {
"properties": {
"deploymentType": {
"const": "server"
}
}
},
"then": {
"required": [
"url"
]
},
"additionalProperties": false
}
]
} as const;

View file

@ -4,7 +4,8 @@ export type ConnectionConfig =
| GithubConnectionConfig
| GitlabConnectionConfig
| GiteaConnectionConfig
| GerritConnectionConfig;
| GerritConnectionConfig
| BitbucketConnectionConfig;
export interface GithubConnectionConfig {
/**
@ -235,3 +236,64 @@ export interface GerritConnectionConfig {
projects?: string[];
};
}
export interface BitbucketConnectionConfig {
/**
* Bitbucket configuration
*/
type: "bitbucket";
/**
* The username to use for authentication. Only needed if token is an app password.
*/
user?: string;
/**
* An authentication token.
*/
token?:
| {
/**
* The name of the secret that contains the token.
*/
secret: string;
}
| {
/**
* The name of the environment variable that contains the token. Only supported in declarative connection configs.
*/
env: string;
};
/**
* Bitbucket URL
*/
url?: string;
/**
* The type of Bitbucket deployment
*/
deploymentType?: "cloud" | "server";
/**
* List of workspaces to sync. Ignored if deploymentType is server.
*/
workspaces?: string[];
/**
* List of projects to sync
*/
projects?: string[];
/**
* List of repos to sync
*/
repos?: string[];
exclude?: {
/**
* Exclude archived repositories from syncing.
*/
archived?: boolean;
/**
* Exclude forked repositories from syncing.
*/
forks?: boolean;
/**
* List of specific repos to exclude from syncing.
*/
repos?: string[];
};
revisions?: GitRevisions;
}

View file

@ -633,6 +633,118 @@ const schema = {
"url"
],
"additionalProperties": false
},
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "BitbucketConnectionConfig",
"properties": {
"type": {
"const": "bitbucket",
"description": "Bitbucket configuration"
},
"user": {
"type": "string",
"description": "The username to use for authentication. Only needed if token is an app password."
},
"token": {
"$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/token",
"description": "An authentication token.",
"examples": [
{
"secret": "SECRET_KEY"
}
]
},
"url": {
"type": "string",
"format": "url",
"default": "https://api.bitbucket.org/2.0",
"description": "Bitbucket URL",
"examples": [
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
"deploymentType": {
"type": "string",
"enum": [
"cloud",
"server"
],
"default": "cloud",
"description": "The type of Bitbucket deployment"
},
"workspaces": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of workspaces to sync. Ignored if deploymentType is server."
},
"projects": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of projects to sync"
},
"repos": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of repos to sync"
},
"exclude": {
"type": "object",
"properties": {
"archived": {
"type": "boolean",
"default": false,
"description": "Exclude archived repositories from syncing."
},
"forks": {
"type": "boolean",
"default": false,
"description": "Exclude forked repositories from syncing."
},
"repos": {
"type": "array",
"items": {
"type": "string"
},
"examples": [
[
"cloud_workspace/repo1",
"server_project/repo2"
]
],
"description": "List of specific repos to exclude from syncing."
}
},
"additionalProperties": false
},
"revisions": {
"$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/revisions"
}
},
"required": [
"type"
],
"if": {
"properties": {
"deploymentType": {
"const": "server"
}
}
},
"then": {
"required": [
"url"
]
},
"additionalProperties": false
}
]
}

View file

@ -8,7 +8,8 @@ export type ConnectionConfig =
| GithubConnectionConfig
| GitlabConnectionConfig
| GiteaConnectionConfig
| GerritConnectionConfig;
| GerritConnectionConfig
| BitbucketConnectionConfig;
export interface SourcebotConfig {
$schema?: string;
@ -330,3 +331,64 @@ export interface GerritConnectionConfig {
projects?: string[];
};
}
export interface BitbucketConnectionConfig {
/**
* Bitbucket configuration
*/
type: "bitbucket";
/**
* The username to use for authentication. Only needed if token is an app password.
*/
user?: string;
/**
* An authentication token.
*/
token?:
| {
/**
* The name of the secret that contains the token.
*/
secret: string;
}
| {
/**
* The name of the environment variable that contains the token. Only supported in declarative connection configs.
*/
env: string;
};
/**
* Bitbucket URL
*/
url?: string;
/**
* The type of Bitbucket deployment
*/
deploymentType?: "cloud" | "server";
/**
* List of workspaces to sync. Ignored if deploymentType is server.
*/
workspaces?: string[];
/**
* List of projects to sync
*/
projects?: string[];
/**
* List of repos to sync
*/
repos?: string[];
exclude?: {
/**
* Exclude archived repositories from syncing.
*/
archived?: boolean;
/**
* Exclude forked repositories from syncing.
*/
forks?: boolean;
/**
* List of specific repos to exclude from syncing.
*/
repos?: string[];
};
revisions?: GitRevisions;
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg xmlns="http://www.w3.org/2000/svg"
aria-label="Bitbucket" role="img"
viewBox="0 0 512 512">
<path fill="#2684ff" d="M422 130a10 10 0 00-9.9-11.7H100.5a10 10 0 00-10 11.7L136 409a10 10 0 009.9 8.4h221c5 0 9.2-3.5 10 -8.4L422 130zM291 316.8h-69.3l-18.7-98h104.8z"/><path fill="url(#a)" d="M59.632 25.2H40.94l-3.1 18.3h-13v18.9H52c1 0 1.7-.7 1.8-1.6l5.8-35.6z" transform="translate(89.8 85) scale(5.3285)"/><linearGradient id="a" x2="1" gradientTransform="rotate(141 22.239 22.239) scale(31.4)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0052cc"/><stop offset="1" stop-color="#2684ff"/></linearGradient></svg>

After

Width:  |  Height:  |  Size: 748 B

View file

@ -3,7 +3,7 @@
import { env } from "@/env.mjs";
import { ErrorCode } from "@/lib/errorCodes";
import { notAuthenticated, notFound, secretAlreadyExists, ServiceError, unexpectedError } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { CodeHostType, isServiceError } from "@/lib/utils";
import { prisma } from "@/prisma";
import { render } from "@react-email/components";
import * as Sentry from '@sentry/nextjs';
@ -27,6 +27,7 @@ import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_U
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
import { TenancyMode } from "./lib/types";
import { decrementOrgSeatCount, getSubscriptionForOrg, incrementOrgSeatCount } from "./ee/features/billing/serverUtils";
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
const ajv = new Ajv({
validateFormats: false,
@ -442,10 +443,10 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt
}
), /* allowSingleTenantUnauthedAccess = */ true));
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const parsedConfig = parseConnectionConfig(type, connectionConfig);
const parsedConfig = parseConnectionConfig(connectionConfig);
if (isServiceError(parsedConfig)) {
return parsedConfig;
}
@ -531,7 +532,7 @@ export const updateConnectionConfigAndScheduleSync = async (connectionId: number
return notFound();
}
const parsedConfig = parseConnectionConfig(connection.connectionType, config);
const parsedConfig = parseConnectionConfig(config);
if (isServiceError(parsedConfig)) {
return parsedConfig;
}
@ -1154,7 +1155,7 @@ export const getSearchContexts = async (domain: string) => sew(() =>
////// Helpers ///////
const parseConnectionConfig = (connectionType: string, config: string) => {
const parseConnectionConfig = (config: string) => {
let parsedConfig: ConnectionConfig;
try {
parsedConfig = JSON.parse(config);
@ -1166,6 +1167,7 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
} satisfies ServiceError;
}
const connectionType = parsedConfig.type;
const schema = (() => {
switch (connectionType) {
case "github":
@ -1176,6 +1178,8 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
return giteaSchema;
case 'gerrit':
return gerritSchema;
case 'bitbucket':
return bitbucketSchema;
}
})();
@ -1205,9 +1209,10 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
}
const { numRepos, hasToken } = (() => {
switch (parsedConfig.type) {
switch (connectionType) {
case "gitea":
case "github": {
case "github":
case "bitbucket": {
return {
numRepos: parsedConfig.repos?.length,
hasToken: !!parsedConfig.token,

View file

@ -19,7 +19,7 @@ export const CodeHostIconButton = ({
const captureEvent = useCaptureEvent();
return (
<Button
className="flex flex-col items-center justify-center p-4 w-24 h-24 cursor-pointer gap-2"
className="flex flex-col items-center justify-center p-4 w-36 h-36 cursor-pointer gap-2"
variant="outline"
onClick={() => {
captureEvent('wa_connect_code_host_button_pressed', {
@ -29,7 +29,7 @@ export const CodeHostIconButton = ({
}}
>
<Image src={logo.src} alt={name} className={cn("w-8 h-8", logo.className)} />
<p className="text-sm font-medium">{name}</p>
<p className="text-sm font-medium text-center">{name}</p>
</Button>
)
}

View file

@ -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 (
<SharedConnectionCreationForm<BitbucketConnectionConfig>
type="bitbucket-cloud"
title="Create a Bitbucket Cloud connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
}}
schema={bitbucketSchema}
additionalConfigValidation={additionalConfigValidation}
quickActions={bitbucketCloudQuickActions}
onCreated={onCreated}
/>
)
}

View file

@ -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 (
<SharedConnectionCreationForm<BitbucketConnectionConfig>
type="bitbucket-server"
title="Create a Bitbucket Data Center connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
}}
schema={bitbucketSchema}
additionalConfigValidation={additionalConfigValidation}
quickActions={bitbucketDataCenterQuickActions}
onCreated={onCreated}
/>
)
}

View file

@ -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";

View file

@ -88,6 +88,10 @@ export const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHo
return <GitHubPATCreationStep step={1} />;
case 'gitlab':
return <GitLabPATCreationStep step={1} />;
case 'bitbucket-cloud':
return <BitbucketCloudPATCreationStep step={1} />;
case 'bitbucket-server':
return <BitbucketServerPATCreationStep step={1} />;
case 'gitea':
return <GiteaPATCreationStep step={1} />;
case 'gerrit':
@ -179,7 +183,7 @@ export const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHo
<FormLabel>Key</FormLabel>
<FormControl>
<Input
placeholder="my-github-token"
placeholder="my-access-token"
{...field}
/>
</FormControl>
@ -262,11 +266,33 @@ const GiteaPATCreationStep = ({ step }: { step: number }) => {
)
}
const BitbucketCloudPATCreationStep = ({ step }: { step: number }) => {
return (
<SecretCreationStep
step={step}
title="Create an Access Token"
description=<span>Please check out our <Link href="https://docs.sourcebot.dev/docs/connections/bitbucket-cloud#authenticating-with-bitbucket-cloud" target="_blank" className="underline">docs</Link> for more information on how to create auth credentials for Bitbucket Cloud.</span>
>
</SecretCreationStep>
)
}
const BitbucketServerPATCreationStep = ({ step }: { step: number }) => {
return (
<SecretCreationStep
step={step}
title="Create an Access Token"
description=<span>Please check out our <Link href="https://docs.sourcebot.dev/docs/connections/bitbucket-data-center#authenticating-with-bitbucket-data-center" target="_blank" className="underline">docs</Link> for more information on how to create auth credentials for Bitbucket Data Center.</span>
>
</SecretCreationStep>
)
}
interface SecretCreationStepProps {
step: number;
title: string;
description: string | React.ReactNode;
children: React.ReactNode;
children?: React.ReactNode;
}
const SecretCreationStep = ({ step, title, description, children }: SecretCreationStepProps) => {

View file

@ -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 <ConfigSettingInternal<BitbucketConnectionConfig>
{...props}
type="bitbucket-cloud"
quickActions={bitbucketCloudQuickActions}
schema={bitbucketSchema}
/>;
}
if (type === 'bitbucket-server') {
return <ConfigSettingInternal<BitbucketConnectionConfig>
{...props}
type="bitbucket-server"
quickActions={bitbucketDataCenterQuickActions}
schema={bitbucketSchema}
/>;
}
if (type === 'gitea') {
return <ConfigSettingInternal<GiteaConnectionConfig>
{...props}

View file

@ -21,6 +21,9 @@ import { getOrgMembership } from "@/actions"
import { isServiceError } from "@/lib/utils"
import { notFound } from "next/navigation"
import { OrgRole } from "@sourcebot/db"
import { CodeHostType } from "@/lib/utils"
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"
interface ConnectionManagementPageProps {
params: {
domain: string
@ -93,7 +96,7 @@ export default async function ConnectionManagementPage({ params, searchParams }:
<DisplayNameSetting connectionId={connection.id} name={connection.name} disabled={!isOwner} />
<ConfigSetting
connectionId={connection.id}
type={connection.connectionType}
type={connection.connectionType as CodeHostType}
config={JSON.stringify(connection.config, null, 2)}
disabled={!isOwner}
/>

View file

@ -49,6 +49,18 @@ export const NewConnectionCard = ({ className, role }: NewConnectionCardProps) =
subtitle="Cloud and Self-Hosted supported."
disabled={!isOwner}
/>
<Card
type="bitbucket-cloud"
title="Bitbucket Cloud"
subtitle="Fetch repos from Bitbucket Cloud."
disabled={!isOwner}
/>
<Card
type="bitbucket-server"
title="Bitbucket Data Center"
subtitle="Fetch repos from a Bitbucket DC instance."
disabled={!isOwner}
/>
<Card
type="gitea"
title="Gitea"

View file

@ -5,7 +5,9 @@ import {
GitHubConnectionCreationForm,
GitLabConnectionCreationForm,
GiteaConnectionCreationForm,
GerritConnectionCreationForm
GerritConnectionCreationForm,
BitbucketCloudConnectionCreationForm,
BitbucketDataCenterConnectionCreationForm
} from "@/app/[domain]/components/connectionCreationForms";
import { useCallback } from "react";
import { useDomain } from "@/hooks/useDomain";
@ -37,5 +39,14 @@ export default function NewConnectionPage({
return <GerritConnectionCreationForm onCreated={onCreated} />;
}
if (type === 'bitbucket-cloud') {
return <BitbucketCloudConnectionCreationForm onCreated={onCreated} />;
}
if (type === 'bitbucket-server') {
return <BitbucketDataCenterConnectionCreationForm onCreated={onCreated} />;
}
router.push(`/${domain}/connections`);
}

View file

@ -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<GithubConnectionConfig>[] = [
...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: <span>Set a custom GitHub host. Defaults to <Code>https://github.com</Code>.</span>
},
@ -290,7 +291,7 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
...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: <span>Set a custom GitLab host. Defaults to <Code>https://gitlab.com</Code>.</span>
},
@ -360,7 +361,7 @@ export const giteaQuickActions: QuickAction<GiteaConnectionConfig>[] = [
...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<GerritConnectionConfig>[] = [
name: "Exclude a project",
}
]
export const bitbucketCloudQuickActions: QuickAction<BitbucketConnectionConfig>[] = [
{
// add user
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
user: previous.user ?? "username"
}),
name: "Add username",
selectionText: "username",
description: (
<div className="flex flex-col">
<span>Username to use for authentication. This is only required if you're using an App Password (stored in <Code>token</Code>) for authentication.</span>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
workspaces: [
...(previous.workspaces ?? []),
"myWorkspace"
]
}),
name: "Add a workspace",
selectionText: "myWorkspace",
description: (
<div className="flex flex-col">
<span>Add a workspace to sync with. Ensure the workspace is visible to the provided <Code>token</Code> (if any).</span>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
repos: [
...(previous.repos ?? []),
"myWorkspace/myRepo"
]
}),
name: "Add a repo",
selectionText: "myWorkspace/myRepo",
description: (
<div className="flex flex-col">
<span>Add an individual repository to sync with. Ensure the repository is visible to the provided <Code>token</Code> (if any).</span>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
projects: [
...(previous.projects ?? []),
"myProject"
]
}),
name: "Add a project",
selectionText: "myProject",
description: (
<div className="flex flex-col">
<span>Add a project to sync with. Ensure the project is visible to the provided <Code>token</Code> (if any).</span>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
repos: [...(previous.exclude?.repos ?? []), "myWorkspace/myExcludedRepo"]
}
}),
name: "Exclude a repo",
selectionText: "myWorkspace/myExcludedRepo",
description: (
<div className="flex flex-col">
<span>Exclude a repository from syncing. Glob patterns are supported.</span>
</div>
)
},
// exclude forked
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
forks: true
}
}),
name: "Exclude forked repos",
description: <span>Exclude forked repositories from syncing.</span>
}
]
export const bitbucketDataCenterQuickActions: QuickAction<BitbucketConnectionConfig>[] = [
{
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: (
<div className="flex flex-col">
<span>Add a individual repository to sync with. Ensure the repository is visible to the provided <Code>token</Code> (if any).</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"PROJ/repo-name",
"MYPROJ/api"
].map((repo) => (
<Code key={repo}>{repo}</Code>
))}
</div>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
projects: [
...(previous.projects ?? []),
"myProject"
]
}),
name: "Add a project",
selectionText: "myProject",
description: (
<div className="flex flex-col">
<span>Add a project to sync with. Ensure the project is visible to the provided <Code>token</Code> (if any).</span>
</div>
)
},
{
fn: (previous: BitbucketConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
repos: [...(previous.exclude?.repos ?? []), "myProject/myExcludedRepo"]
}
}),
name: "Exclude a repo",
selectionText: "myProject/myExcludedRepo",
description: (
<div className="flex flex-col">
<span>Exclude a repository from syncing. Glob patterns are supported.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"myProject/myExcludedRepo",
"myProject2/*"
].map((repo) => (
<Code key={repo}>{repo}</Code>
))}
</div>
</div>
)
},
// 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",
}
]

View file

@ -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 (
<>
<BackButton onClick={onBack} />
<BitbucketCloudConnectionCreationForm onCreated={onCreated} />
</>
)
}
if (selectedCodeHost === "bitbucket-server") {
return (
<>
<BackButton onClick={onBack} />
<BitbucketDataCenterConnectionCreationForm onCreated={onCreated} />
</>
)
}
return null;
}
@ -90,7 +110,7 @@ const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => {
const captureEvent = useCaptureEvent();
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 max-w-3xl mx-auto">
<CodeHostIconButton
name="GitHub"
logo={getCodeHostIcon("github")!}
@ -107,6 +127,22 @@ const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => {
captureEvent("wa_onboard_gitlab_selected", {});
}}
/>
<CodeHostIconButton
name="Bitbucket Cloud"
logo={getCodeHostIcon("bitbucket-cloud")!}
onClick={() => {
onSelect("bitbucket-cloud");
captureEvent("wa_onboard_bitbucket_cloud_selected", {});
}}
/>
<CodeHostIconButton
name="Bitbucket DC"
logo={getCodeHostIcon("bitbucket-server")!}
onClick={() => {
onSelect("bitbucket-server");
captureEvent("wa_onboard_bitbucket_server_selected", {});
}}
/>
<CodeHostIconButton
name="Gitea"
logo={getCodeHostIcon("gitea")!}

View file

@ -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;
})();

View file

@ -238,6 +238,8 @@ export type PosthogEventMap = {
wa_onboard_gitlab_selected: {},
wa_onboard_gitea_selected: {},
wa_onboard_gerrit_selected: {},
wa_onboard_bitbucket_cloud_selected: {},
wa_onboard_bitbucket_server_selected: {},
//////////////////////////////////////////////////////////////////
wa_security_page_click: {},
//////////////////////////////////////////////////////////////////

View file

@ -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-cloud" | "bitbucket-server";
type CodeHostInfo = {
type: CodeHostType;
@ -110,6 +111,28 @@ const _getCodeHostInfoInternal = (type: string, displayName: string, cloneUrl: s
iconClassName: className,
}
}
case "bitbucket-server": {
const { src, className } = getCodeHostIcon('bitbucket-server')!;
return {
type: "bitbucket-server",
displayName: displayName,
codeHostName: "Bitbucket",
repoLink: cloneUrl,
icon: src,
iconClassName: className,
}
}
case "bitbucket-cloud": {
const { src, className } = getCodeHostIcon('bitbucket-cloud')!;
return {
type: "bitbucket-cloud",
displayName: displayName,
codeHostName: "Bitbucket",
repoLink: cloneUrl,
icon: src,
iconClassName: className,
}
}
}
}
@ -132,6 +155,11 @@ export const getCodeHostIcon = (codeHostType: CodeHostType): { src: string, clas
return {
src: gerritLogo,
}
case "bitbucket-cloud":
case "bitbucket-server":
return {
src: bitbucketLogo,
}
default:
return null;
}
@ -142,6 +170,8 @@ export const isAuthSupportedForCodeHost = (codeHostType: CodeHostType): boolean
case "github":
case "gitlab":
case "gitea":
case "bitbucket-cloud":
case "bitbucket-server":
return true;
case "gerrit":
return false;

105
schemas/v3/bitbucket.json Normal file
View file

@ -0,0 +1,105 @@
{
"$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"
],
"if": {
"properties": {
"deploymentType": { "const": "server" }
}
},
"then": {
"required": ["url"]
},
"additionalProperties": false
}

View file

@ -13,6 +13,9 @@
},
{
"$ref": "./gerrit.json"
},
{
"$ref": "./bitbucket.json"
}
]
}

View file

@ -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"
@ -11611,6 +11622,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"
@ -13047,6 +13074,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"