|
| 1 | +import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud"; |
| 2 | +import { paths } from "@coderabbitai/bitbucket/cloud"; |
| 3 | +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; |
| 4 | +import type { ClientOptions, Client, ClientPathsWithMethod } from "openapi-fetch"; |
| 5 | +import { createLogger } from "./logger.js"; |
| 6 | +import { PrismaClient, Repo } from "@sourcebot/db"; |
| 7 | +import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; |
| 8 | +import { env } from "./env.js"; |
| 9 | +import * as Sentry from "@sentry/node"; |
| 10 | +import { |
| 11 | + SchemaBranch as CloudBranch, |
| 12 | + SchemaProject as CloudProject, |
| 13 | + SchemaRepository as CloudRepository, |
| 14 | + SchemaTag as CloudTag, |
| 15 | + SchemaWorkspace as CloudWorkspace |
| 16 | +} from "@coderabbitai/bitbucket/cloud/openapi"; |
| 17 | +import { SchemaRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi"; |
| 18 | +import { processPromiseResults } from "./connectionUtils.js"; |
| 19 | +import { throwIfAnyFailed } from "./connectionUtils.js"; |
| 20 | + |
| 21 | +const logger = createLogger("Bitbucket"); |
| 22 | +const BITBUCKET_CLOUD_GIT = 'https://bitbucket.org'; |
| 23 | +const BITBUCKET_CLOUD_API = 'https://api.bitbucket.org/2.0'; |
| 24 | +const BITBUCKET_CLOUD = "cloud"; |
| 25 | +const BITBUCKET_SERVER = "server"; |
| 26 | + |
| 27 | +export type BitbucketRepository = CloudRepository | ServerRepository; |
| 28 | + |
| 29 | +interface BitbucketClient { |
| 30 | + deploymentType: string; |
| 31 | + token: string | undefined; |
| 32 | + apiClient: any; |
| 33 | + baseUrl: string; |
| 34 | + gitUrl: string; |
| 35 | + getPaginated: <T, V extends CloudGetRequestPath>(path: V, get: (url: V) => Promise<PaginatedResponse<T>>) => Promise<T[]>; |
| 36 | + getReposForWorkspace: (client: BitbucketClient, workspace: string) => Promise<{validRepos: BitbucketRepository[], notFoundWorkspaces: string[]}>; |
| 37 | + getReposForProjects: (client: BitbucketClient, projects: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundProjects: string[]}>; |
| 38 | + /* |
| 39 | + getRepos: (client: BitbucketClient, workspace: string, project: string) => Promise<Repo[]>; |
| 40 | + countForks: (client: BitbucketClient, repo: Repo) => Promise<number>; |
| 41 | + countWatchers: (client: BitbucketClient, repo: Repo) => Promise<number>; |
| 42 | + getBranches: (client: BitbucketClient, repo: string) => Promise<string[]>; |
| 43 | + getTags: (client: BitbucketClient, repo: string) => Promise<string[]>; |
| 44 | + */ |
| 45 | +} |
| 46 | + |
| 47 | +// afaik, this is the only way of extracting the client API type |
| 48 | +type CloudAPI = ReturnType<typeof createBitbucketCloudClient>; |
| 49 | + |
| 50 | +// Defines a type that is a union of all API paths that have a GET method in the |
| 51 | +// client api. |
| 52 | +type CloudGetRequestPath = ClientPathsWithMethod<CloudAPI, "get">; |
| 53 | + |
| 54 | +type PaginatedResponse<T> = { |
| 55 | + readonly next?: string; |
| 56 | + readonly page?: number; |
| 57 | + readonly pagelen?: number; |
| 58 | + readonly previous?: string; |
| 59 | + readonly size?: number; |
| 60 | + readonly values?: readonly T[]; |
| 61 | +} |
| 62 | + |
| 63 | +export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => { |
| 64 | + const token = await getTokenFromConfig(config.token, orgId, db, logger); |
| 65 | + |
| 66 | + //const deploymentType = config.deploymentType; |
| 67 | + const client = cloudClient(token); |
| 68 | + |
| 69 | + let allRepos: BitbucketRepository[] = []; |
| 70 | + let notFound: { |
| 71 | + orgs: string[], |
| 72 | + users: string[], |
| 73 | + repos: string[], |
| 74 | + } = { |
| 75 | + orgs: [], |
| 76 | + users: [], |
| 77 | + repos: [], |
| 78 | + }; |
| 79 | + |
| 80 | + if (config.workspace) { |
| 81 | + const { validRepos, notFoundWorkspaces } = await client.getReposForWorkspace(client, config.workspace); |
| 82 | + allRepos = allRepos.concat(validRepos); |
| 83 | + notFound.orgs = notFoundWorkspaces; |
| 84 | + } |
| 85 | + |
| 86 | + if (config.projects) { |
| 87 | + const { validRepos, notFoundProjects } = await client.getReposForProjects(client, config.projects); |
| 88 | + allRepos = allRepos.concat(validRepos); |
| 89 | + notFound.repos = notFoundProjects; |
| 90 | + } |
| 91 | + |
| 92 | + return { |
| 93 | + validRepos: allRepos, |
| 94 | + notFound, |
| 95 | + }; |
| 96 | +} |
| 97 | + |
| 98 | +function cloudClient(token: string | undefined): BitbucketClient { |
| 99 | + const clientOptions: ClientOptions = { |
| 100 | + baseUrl: BITBUCKET_CLOUD_API, |
| 101 | + headers: { |
| 102 | + Accept: "application/json", |
| 103 | + Authorization: `Bearer ${token}`, |
| 104 | + }, |
| 105 | + }; |
| 106 | + |
| 107 | + const apiClient = createBitbucketCloudClient(clientOptions); |
| 108 | + var client: BitbucketClient = { |
| 109 | + deploymentType: BITBUCKET_CLOUD, |
| 110 | + token: token, |
| 111 | + apiClient: apiClient, |
| 112 | + baseUrl: BITBUCKET_CLOUD_API, |
| 113 | + gitUrl: BITBUCKET_CLOUD_GIT, |
| 114 | + getPaginated: getPaginatedCloud, |
| 115 | + getReposForWorkspace: cloudGetReposForWorkspace, |
| 116 | + getReposForProjects: cloudGetReposForProjects, |
| 117 | + /* |
| 118 | + getRepos: cloudGetRepos, |
| 119 | + countForks: cloudCountForks, |
| 120 | + countWatchers: cloudCountWatchers, |
| 121 | + getBranches: cloudGetBranches, |
| 122 | + getTags: cloudGetTags, |
| 123 | + */ |
| 124 | + } |
| 125 | + |
| 126 | + return client; |
| 127 | +} |
| 128 | + |
| 129 | +/** |
| 130 | +* We need to do `V extends CloudGetRequestPath` since we will need to call `apiClient.GET(url, ...)`, which |
| 131 | +* expects `url` to be of type `CloudGetRequestPath`. See example. |
| 132 | +**/ |
| 133 | +const getPaginatedCloud = async <T, V extends CloudGetRequestPath>(path: V, get: (url: V) => Promise<PaginatedResponse<T>>) => { |
| 134 | + const results: T[] = []; |
| 135 | + let url = path; |
| 136 | + |
| 137 | + while (true) { |
| 138 | + const response = await get(url); |
| 139 | + |
| 140 | + if (!response.values || response.values.length === 0) { |
| 141 | + break; |
| 142 | + } |
| 143 | + |
| 144 | + results.push(...response.values); |
| 145 | + |
| 146 | + if (!response.next) { |
| 147 | + break; |
| 148 | + } |
| 149 | + |
| 150 | + // cast required here since response.next is a string. |
| 151 | + url = response.next as V; |
| 152 | + } |
| 153 | + return results; |
| 154 | +} |
| 155 | + |
| 156 | + |
| 157 | +async function cloudGetReposForWorkspace(client: BitbucketClient, workspace: string): Promise<{validRepos: CloudRepository[], notFoundWorkspaces: string[]}> { |
| 158 | + try { |
| 159 | + logger.debug(`Fetching all repos for workspace ${workspace}...`); |
| 160 | + |
| 161 | + const path = `/repositories/${workspace}` as CloudGetRequestPath; |
| 162 | + const { durationMs, data } = await measure(async () => { |
| 163 | + const fetchFn = () => client.getPaginated<CloudRepository, typeof path>(path, async (url) => { |
| 164 | + const response = await client.apiClient.GET(url, { |
| 165 | + params: { |
| 166 | + path: { |
| 167 | + workspace, |
| 168 | + } |
| 169 | + } |
| 170 | + }); |
| 171 | + const { data, error } = response; |
| 172 | + if (error) { |
| 173 | + throw new Error (`Failed to fetch projects for workspace ${workspace}: ${error.type}`); |
| 174 | + } |
| 175 | + return data; |
| 176 | + }); |
| 177 | + return fetchWithRetry(fetchFn, `workspace ${workspace}`, logger); |
| 178 | + }); |
| 179 | + logger.debug(`Found ${data.length} repos for workspace ${workspace} in ${durationMs}ms.`); |
| 180 | + |
| 181 | + return { |
| 182 | + validRepos: data, |
| 183 | + notFoundWorkspaces: [], |
| 184 | + }; |
| 185 | + } catch (e) { |
| 186 | + Sentry.captureException(e); |
| 187 | + logger.error(`Failed to get repos for workspace ${workspace}: ${e}`); |
| 188 | + throw e; |
| 189 | + } |
| 190 | +} |
| 191 | + |
| 192 | +async function cloudGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{validRepos: CloudRepository[], notFoundProjects: string[]}> { |
| 193 | + const results = await Promise.allSettled(projects.map(async (project) => { |
| 194 | + const [workspace, project_name] = project.split('/'); |
| 195 | + logger.debug(`Fetching all repos for project ${project} for workspace ${workspace}...`); |
| 196 | + |
| 197 | + try { |
| 198 | + const path = `/repositories/${workspace}?q=project.key="${project_name}"` as CloudGetRequestPath; |
| 199 | + const repos = await client.getPaginated<CloudRepository, typeof path>(path, async (url) => { |
| 200 | + const response = await client.apiClient.GET(url); |
| 201 | + const { data, error } = response; |
| 202 | + if (error) { |
| 203 | + throw new Error (`Failed to fetch projects for workspace ${workspace}: ${error.type}`); |
| 204 | + } |
| 205 | + return data; |
| 206 | + }); |
| 207 | + |
| 208 | + logger.debug(`Found ${repos.length} repos for project ${project_name} for workspace ${workspace}.`); |
| 209 | + return { |
| 210 | + type: 'valid' as const, |
| 211 | + data: repos |
| 212 | + } |
| 213 | + } catch (e: any) { |
| 214 | + Sentry.captureException(e); |
| 215 | + logger.error(`Failed to fetch repos for project ${project_name}: ${e}`); |
| 216 | + |
| 217 | + const status = e?.cause?.response?.status; |
| 218 | + if (status == 404) { |
| 219 | + logger.error(`Project ${project_name} not found in ${workspace} or invalid access`) |
| 220 | + return { |
| 221 | + type: 'notFound' as const, |
| 222 | + value: project |
| 223 | + } |
| 224 | + } |
| 225 | + throw e; |
| 226 | + } |
| 227 | + })); |
| 228 | + |
| 229 | + throwIfAnyFailed(results); |
| 230 | + const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results); |
| 231 | + return { |
| 232 | + validRepos, |
| 233 | + notFoundProjects |
| 234 | + } |
| 235 | +} |
0 commit comments