Skip to content

Commit bcb32cd

Browse files
committed
add bitbucket server support
1 parent 69fcc83 commit bcb32cd

File tree

11 files changed

+297
-122
lines changed

11 files changed

+297
-122
lines changed

packages/backend/src/bitbucket.ts

Lines changed: 204 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
11
import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud";
2-
import { paths } from "@coderabbitai/bitbucket/cloud";
2+
import { createBitbucketServerClient } from "@coderabbitai/bitbucket/server";
33
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type";
44
import type { ClientOptions, Client, ClientPathsWithMethod } from "openapi-fetch";
55
import { createLogger } from "./logger.js";
6-
import { PrismaClient, Repo } from "@sourcebot/db";
6+
import { PrismaClient } from "@sourcebot/db";
77
import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
8-
import { env } from "./env.js";
98
import * as Sentry from "@sentry/node";
109
import {
11-
SchemaBranch as CloudBranch,
12-
SchemaProject as CloudProject,
1310
SchemaRepository as CloudRepository,
14-
SchemaTag as CloudTag,
15-
SchemaWorkspace as CloudWorkspace
1611
} from "@coderabbitai/bitbucket/cloud/openapi";
17-
import { SchemaRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi";
12+
import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi";
1813
import { processPromiseResults } from "./connectionUtils.js";
1914
import { throwIfAnyFailed } from "./connectionUtils.js";
2015

@@ -32,24 +27,19 @@ interface BitbucketClient {
3227
apiClient: any;
3328
baseUrl: string;
3429
gitUrl: string;
35-
getPaginated: <T, V extends CloudGetRequestPath>(path: V, get: (url: V) => Promise<PaginatedResponse<T>>) => Promise<T[]>;
30+
getPaginated: <T, V extends CloudGetRequestPath | ServerGetRequestPath>(path: V, get: (url: V) => Promise<PaginatedResponse<T>>) => Promise<T[]>;
3631
getReposForWorkspace: (client: BitbucketClient, workspaces: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundWorkspaces: string[]}>;
3732
getReposForProjects: (client: BitbucketClient, projects: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundProjects: string[]}>;
3833
getRepos: (client: BitbucketClient, repos: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundRepos: string[]}>;
3934
shouldExcludeRepo: (repo: BitbucketRepository, config: BitbucketConnectionConfig) => boolean;
40-
/*
41-
getBranches: (client: BitbucketClient, repo: string) => Promise<string[]>;
42-
getTags: (client: BitbucketClient, repo: string) => Promise<string[]>;
43-
*/
4435
}
4536

46-
// afaik, this is the only way of extracting the client API type
4737
type CloudAPI = ReturnType<typeof createBitbucketCloudClient>;
48-
49-
// Defines a type that is a union of all API paths that have a GET method in the
50-
// client api.
5138
type CloudGetRequestPath = ClientPathsWithMethod<CloudAPI, "get">;
5239

40+
type ServerAPI = ReturnType<typeof createBitbucketServerClient>;
41+
type ServerGetRequestPath = ClientPathsWithMethod<ServerAPI, "get">;
42+
5343
type PaginatedResponse<T> = {
5444
readonly next?: string;
5545
readonly page?: number;
@@ -60,10 +50,17 @@ type PaginatedResponse<T> = {
6050
}
6151

6252
export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => {
63-
const token = await getTokenFromConfig(config.token, orgId, db, logger);
53+
const token = config.token ?
54+
await getTokenFromConfig(config.token, orgId, db, logger) :
55+
undefined;
6456

65-
//const deploymentType = config.deploymentType;
66-
const client = cloudClient(config.user, token);
57+
if (config.deploymentType === 'server' && !config.url) {
58+
throw new Error('URL is required for Bitbucket Server');
59+
}
60+
61+
const client = config.deploymentType === 'server' ?
62+
serverClient(config.url!, config.user, token) :
63+
cloudClient(config.user, token);
6764

6865
let allRepos: BitbucketRepository[] = [];
6966
let notFound: {
@@ -127,10 +124,6 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu
127124
getReposForProjects: cloudGetReposForProjects,
128125
getRepos: cloudGetRepos,
129126
shouldExcludeRepo: cloudShouldExcludeRepo,
130-
/*
131-
getBranches: cloudGetBranches,
132-
getTags: cloudGetTags,
133-
*/
134127
}
135128

136129
return client;
@@ -140,7 +133,7 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu
140133
* We need to do `V extends CloudGetRequestPath` since we will need to call `apiClient.GET(url, ...)`, which
141134
* expects `url` to be of type `CloudGetRequestPath`. See example.
142135
**/
143-
const getPaginatedCloud = async <T, V extends CloudGetRequestPath>(path: V, get: (url: V) => Promise<PaginatedResponse<T>>) => {
136+
const getPaginatedCloud = async <T, V extends CloudGetRequestPath | ServerGetRequestPath>(path: V, get: (url: V) => Promise<PaginatedResponse<T>>) => {
144137
const results: T[] = [];
145138
let url = path;
146139

@@ -334,4 +327,190 @@ function cloudShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketConn
334327
return true;
335328
}
336329
return false;
330+
}
331+
332+
function serverClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient {
333+
const authorizationString = (() => {
334+
// If we're not given any credentials we return an empty auth string. This will only work if the project/repos are public
335+
if(!user && !token) {
336+
return "";
337+
}
338+
339+
// A user must be provided when using basic auth
340+
// https://developer.atlassian.com/server/bitbucket/rest/v906/intro/#authentication
341+
if (!user || user == "x-token-auth") {
342+
return `Bearer ${token}`;
343+
}
344+
return `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}`;
345+
})();
346+
const clientOptions: ClientOptions = {
347+
baseUrl: url,
348+
headers: {
349+
Accept: "application/json",
350+
Authorization: authorizationString,
351+
},
352+
};
353+
354+
const apiClient = createBitbucketServerClient(clientOptions);
355+
var client: BitbucketClient = {
356+
deploymentType: BITBUCKET_SERVER,
357+
token: token,
358+
apiClient: apiClient,
359+
baseUrl: url,
360+
gitUrl: url,
361+
getPaginated: getPaginatedServer,
362+
getReposForWorkspace: serverGetReposForWorkspace,
363+
getReposForProjects: serverGetReposForProjects,
364+
getRepos: serverGetRepos,
365+
shouldExcludeRepo: serverShouldExcludeRepo,
366+
}
367+
368+
return client;
369+
}
370+
371+
const getPaginatedServer = async <T, V extends CloudGetRequestPath | ServerGetRequestPath>(path: V, get: (url: V) => Promise<PaginatedResponse<T>>) => {
372+
const results: T[] = [];
373+
let url = path;
374+
375+
while (true) {
376+
const response = await get(url);
377+
378+
if (!response.values || response.values.length === 0) {
379+
break;
380+
}
381+
382+
results.push(...response.values);
383+
384+
if (!response.next) {
385+
break;
386+
}
387+
388+
// cast required here since response.next is a string.
389+
url = response.next as V;
390+
}
391+
return results;
392+
}
393+
394+
async function serverGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{validRepos: ServerRepository[], notFoundWorkspaces: string[]}> {
395+
logger.debug('Workspaces are not supported in Bitbucket Server');
396+
return {
397+
validRepos: [],
398+
notFoundWorkspaces: workspaces
399+
};
400+
}
401+
402+
async function serverGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{validRepos: ServerRepository[], notFoundProjects: string[]}> {
403+
const results = await Promise.allSettled(projects.map(async (project) => {
404+
try {
405+
logger.debug(`Fetching all repos for project ${project}...`);
406+
407+
const path = `/rest/api/1.0/projects/${project}/repos` as ServerGetRequestPath;
408+
const { durationMs, data } = await measure(async () => {
409+
const fetchFn = () => client.getPaginated<ServerRepository, typeof path>(path, async (url) => {
410+
const response = await client.apiClient.GET(url);
411+
const { data, error } = response;
412+
if (error) {
413+
throw new Error(`Failed to fetch repos for project ${project}: ${JSON.stringify(error)}`);
414+
}
415+
return data;
416+
});
417+
return fetchWithRetry(fetchFn, `project ${project}`, logger);
418+
});
419+
logger.debug(`Found ${data.length} repos for project ${project} in ${durationMs}ms.`);
420+
421+
return {
422+
type: 'valid' as const,
423+
data: data,
424+
};
425+
} catch (e: any) {
426+
Sentry.captureException(e);
427+
logger.error(`Failed to get repos for project ${project}: ${e}`);
428+
429+
const status = e?.cause?.response?.status;
430+
if (status == 404) {
431+
logger.error(`Project ${project} not found or invalid access`);
432+
return {
433+
type: 'notFound' as const,
434+
value: project
435+
};
436+
}
437+
throw e;
438+
}
439+
}));
440+
441+
throwIfAnyFailed(results);
442+
const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results);
443+
return {
444+
validRepos,
445+
notFoundProjects
446+
};
447+
}
448+
449+
async function serverGetRepos(client: BitbucketClient, repos: string[]): Promise<{validRepos: ServerRepository[], notFoundRepos: string[]}> {
450+
const results = await Promise.allSettled(repos.map(async (repo) => {
451+
const [project, repo_slug] = repo.split('/');
452+
if (!project || !repo_slug) {
453+
logger.error(`Invalid repo ${repo}`);
454+
return {
455+
type: 'notFound' as const,
456+
value: repo
457+
};
458+
}
459+
460+
logger.debug(`Fetching repo ${repo_slug} for project ${project}...`);
461+
try {
462+
const path = `/rest/api/1.0/projects/${project}/repos/${repo_slug}` as ServerGetRequestPath;
463+
const response = await client.apiClient.GET(path);
464+
const { data, error } = response;
465+
if (error) {
466+
throw new Error(`Failed to fetch repo ${repo}: ${error.type}`);
467+
}
468+
return {
469+
type: 'valid' as const,
470+
data: [data]
471+
};
472+
} catch (e: any) {
473+
Sentry.captureException(e);
474+
logger.error(`Failed to fetch repo ${repo}: ${e}`);
475+
476+
const status = e?.cause?.response?.status;
477+
if (status === 404) {
478+
logger.error(`Repo ${repo} not found in project ${project} or invalid access`);
479+
return {
480+
type: 'notFound' as const,
481+
value: repo
482+
};
483+
}
484+
throw e;
485+
}
486+
}));
487+
488+
throwIfAnyFailed(results);
489+
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results);
490+
return {
491+
validRepos,
492+
notFoundRepos
493+
};
494+
}
495+
496+
function serverShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketConnectionConfig): boolean {
497+
const serverRepo = repo as ServerRepository;
498+
499+
const shouldExclude = (() => {
500+
if (config.exclude?.repos && config.exclude.repos.includes(serverRepo.slug!)) {
501+
return true;
502+
}
503+
504+
// Note: Bitbucket Server doesn't have a direct way to check if a repo is a fork
505+
// We'll need to check the origin property if it exists
506+
if (!!config.exclude?.forks && serverRepo.origin !== undefined) {
507+
return true;
508+
}
509+
})();
510+
511+
if (shouldExclude) {
512+
logger.debug(`Excluding repo ${serverRepo.slug} because it matches the exclude pattern`);
513+
return true;
514+
}
515+
return false;
337516
}

0 commit comments

Comments
 (0)