1
1
import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud" ;
2
- import { paths } from "@coderabbitai/bitbucket/cloud " ;
2
+ import { createBitbucketServerClient } from "@coderabbitai/bitbucket/server " ;
3
3
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type" ;
4
4
import type { ClientOptions , Client , ClientPathsWithMethod } from "openapi-fetch" ;
5
5
import { createLogger } from "./logger.js" ;
6
- import { PrismaClient , Repo } from "@sourcebot/db" ;
6
+ import { PrismaClient } from "@sourcebot/db" ;
7
7
import { getTokenFromConfig , measure , fetchWithRetry } from "./utils.js" ;
8
- import { env } from "./env.js" ;
9
8
import * as Sentry from "@sentry/node" ;
10
9
import {
11
- SchemaBranch as CloudBranch ,
12
- SchemaProject as CloudProject ,
13
10
SchemaRepository as CloudRepository ,
14
- SchemaTag as CloudTag ,
15
- SchemaWorkspace as CloudWorkspace
16
11
} 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" ;
18
13
import { processPromiseResults } from "./connectionUtils.js" ;
19
14
import { throwIfAnyFailed } from "./connectionUtils.js" ;
20
15
@@ -32,24 +27,19 @@ interface BitbucketClient {
32
27
apiClient : any ;
33
28
baseUrl : string ;
34
29
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 [ ] > ;
36
31
getReposForWorkspace : ( client : BitbucketClient , workspaces : string [ ] ) => Promise < { validRepos : BitbucketRepository [ ] , notFoundWorkspaces : string [ ] } > ;
37
32
getReposForProjects : ( client : BitbucketClient , projects : string [ ] ) => Promise < { validRepos : BitbucketRepository [ ] , notFoundProjects : string [ ] } > ;
38
33
getRepos : ( client : BitbucketClient , repos : string [ ] ) => Promise < { validRepos : BitbucketRepository [ ] , notFoundRepos : string [ ] } > ;
39
34
shouldExcludeRepo : ( repo : BitbucketRepository , config : BitbucketConnectionConfig ) => boolean ;
40
- /*
41
- getBranches: (client: BitbucketClient, repo: string) => Promise<string[]>;
42
- getTags: (client: BitbucketClient, repo: string) => Promise<string[]>;
43
- */
44
35
}
45
36
46
- // afaik, this is the only way of extracting the client API type
47
37
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.
51
38
type CloudGetRequestPath = ClientPathsWithMethod < CloudAPI , "get" > ;
52
39
40
+ type ServerAPI = ReturnType < typeof createBitbucketServerClient > ;
41
+ type ServerGetRequestPath = ClientPathsWithMethod < ServerAPI , "get" > ;
42
+
53
43
type PaginatedResponse < T > = {
54
44
readonly next ?: string ;
55
45
readonly page ?: number ;
@@ -60,10 +50,17 @@ type PaginatedResponse<T> = {
60
50
}
61
51
62
52
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 ;
64
56
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 ) ;
67
64
68
65
let allRepos : BitbucketRepository [ ] = [ ] ;
69
66
let notFound : {
@@ -127,10 +124,6 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu
127
124
getReposForProjects : cloudGetReposForProjects ,
128
125
getRepos : cloudGetRepos ,
129
126
shouldExcludeRepo : cloudShouldExcludeRepo ,
130
- /*
131
- getBranches: cloudGetBranches,
132
- getTags: cloudGetTags,
133
- */
134
127
}
135
128
136
129
return client ;
@@ -140,7 +133,7 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu
140
133
* We need to do `V extends CloudGetRequestPath` since we will need to call `apiClient.GET(url, ...)`, which
141
134
* expects `url` to be of type `CloudGetRequestPath`. See example.
142
135
**/
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 > > ) => {
144
137
const results : T [ ] = [ ] ;
145
138
let url = path ;
146
139
@@ -334,4 +327,190 @@ function cloudShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketConn
334
327
return true ;
335
328
}
336
329
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 ;
337
516
}
0 commit comments