11import { createBitbucketCloudClient as createBitbucketCloudClientBase } from "@coderabbitai/bitbucket/cloud" ;
22import { createBitbucketServerClient as createBitbucketServerClientBase } from "@coderabbitai/bitbucket/server" ;
33import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type" ;
4- import type { ClientOptions , ClientPathsWithMethod } from "openapi-fetch" ;
4+ import type { ClientOptions , ClientPathsWithMethod , Middleware } from "openapi-fetch" ;
55import { createLogger } from "@sourcebot/shared" ;
66import { measure , fetchWithRetry } from "./utils.js" ;
77import * as Sentry from "@sentry/node" ;
@@ -42,6 +42,25 @@ type CloudGetRequestPath = ClientPathsWithMethod<CloudAPI, "get">;
4242type ServerAPI = ReturnType < typeof createBitbucketServerClientBase > ;
4343type ServerGetRequestPath = ClientPathsWithMethod < ServerAPI , "get" > ;
4444
45+ /**
46+ * openapi-fetch middleware: convert any non-2xx response into a thrown Error
47+ * with `.status` attached. Without this, every call site has to destructure
48+ * `{ data, error }` and re-throw — with it, callers can rely on success
49+ * meaning `data` is defined, and predicates like `isUnauthorized` see `.status`
50+ * directly on the thrown Error.
51+ */
52+ export const throwOnHttpError : Middleware = {
53+ async onResponse ( { response } ) {
54+ if ( ! response . ok ) {
55+ const body = await response . clone ( ) . text ( ) ;
56+ throw Object . assign (
57+ new Error ( `Bitbucket API ${ response . status } : ${ body } ` ) ,
58+ { status : response . status } ,
59+ ) ;
60+ }
61+ } ,
62+ } ;
63+
4564type CloudPaginatedResponse < T > = {
4665 readonly next ?: string ;
4766 readonly page ?: number ;
@@ -133,6 +152,7 @@ export function createBitbucketCloudClient(user: string | undefined, token: stri
133152 } ;
134153
135154 const apiClient = createBitbucketCloudClientBase ( clientOptions ) ;
155+ apiClient . use ( throwOnHttpError ) ;
136156 var client : BitbucketClient = {
137157 deploymentType : BITBUCKET_CLOUD ,
138158 token : token ,
@@ -199,18 +219,14 @@ async function cloudGetReposForWorkspace(client: BitbucketClient, workspaces: st
199219
200220 const { durationMs, data } = await measure ( async ( ) => {
201221 const fetchFn = ( ) => getPaginatedCloud < CloudRepository > ( `/repositories/${ workspace } ` as CloudGetRequestPath , async ( path , query ) => {
202- const response = await client . apiClient . GET ( path , {
222+ const { data } = await client . apiClient . GET ( path , {
203223 params : {
204224 path : {
205225 workspace,
206226 } ,
207227 query : query ,
208228 }
209229 } ) ;
210- const { data, error } = response ;
211- if ( error ) {
212- throw new Error ( `Failed to fetch projects for workspace ${ workspace } : ${ JSON . stringify ( error ) } ` ) ;
213- }
214230 return data ;
215231 } ) ;
216232 return fetchWithRetry ( fetchFn , `workspace ${ workspace } ` , logger ) ;
@@ -225,8 +241,7 @@ async function cloudGetReposForWorkspace(client: BitbucketClient, workspaces: st
225241 Sentry . captureException ( e ) ;
226242 logger . error ( `Failed to get repos for workspace ${ workspace } : ${ e } ` ) ;
227243
228- const status = e ?. cause ?. response ?. status ;
229- if ( status == 404 ) {
244+ if ( e ?. status === 404 ) {
230245 const warning = `Workspace ${ workspace } not found or invalid access` ;
231246 logger . warn ( warning ) ;
232247 return {
@@ -262,7 +277,7 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin
262277 try {
263278 const { durationMs, data : repos } = await measure ( async ( ) => {
264279 const fetchFn = ( ) => getPaginatedCloud < CloudRepository > ( `/repositories/${ workspace } ` as CloudGetRequestPath , async ( path , query ) => {
265- const response = await client . apiClient . GET ( path , {
280+ const { data } = await client . apiClient . GET ( path , {
266281 params : {
267282 path : {
268283 workspace,
@@ -273,10 +288,6 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin
273288 }
274289 }
275290 } ) ;
276- const { data, error } = response ;
277- if ( error ) {
278- throw new Error ( `Failed to fetch projects for workspace ${ workspace } : ${ JSON . stringify ( error ) } ` ) ;
279- }
280291 return data ;
281292 } ) ;
282293 return fetchWithRetry ( fetchFn , `project ${ project_name } in workspace ${ workspace } ` , logger ) ;
@@ -291,8 +302,7 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin
291302 Sentry . captureException ( e ) ;
292303 logger . error ( `Failed to fetch repos for project ${ project_name } : ${ e } ` ) ;
293304
294- const status = e ?. cause ?. response ?. status ;
295- if ( status == 404 ) {
305+ if ( e ?. status === 404 ) {
296306 const warning = `Project ${ project_name } not found in ${ workspace } or invalid access` ;
297307 logger . warn ( warning ) ;
298308 return {
@@ -328,11 +338,7 @@ async function cloudGetRepos(client: BitbucketClient, repoList: string[]): Promi
328338 try {
329339 const path = `/repositories/${ workspace } /${ repo_slug } ` as CloudGetRequestPath ;
330340 const data = await fetchWithRetry ( async ( ) => {
331- const response = await client . apiClient . GET ( path ) ;
332- const { data, error } = response ;
333- if ( error ) {
334- throw new Error ( `Failed to fetch repo ${ repo } : ${ JSON . stringify ( error ) } ` ) ;
335- }
341+ const { data } = await client . apiClient . GET ( path ) ;
336342 return data ;
337343 } , `repo ${ repo } ` , logger ) ;
338344 return {
@@ -343,8 +349,7 @@ async function cloudGetRepos(client: BitbucketClient, repoList: string[]): Promi
343349 Sentry . captureException ( e ) ;
344350 logger . error ( `Failed to fetch repo ${ repo } : ${ e } ` ) ;
345351
346- const status = e ?. cause ?. response ?. status ;
347- if ( status === 404 ) {
352+ if ( e ?. status === 404 ) {
348353 const warning = `Repo ${ repo } not found in ${ workspace } or invalid access` ;
349354 logger . warn ( warning ) ;
350355 return {
@@ -420,6 +425,7 @@ export function createBitbucketServerClient(url: string, user: string | undefine
420425 } ;
421426
422427 const apiClient = createBitbucketServerClientBase ( clientOptions ) ;
428+ apiClient . use ( throwOnHttpError ) ;
423429 var client : BitbucketClient = {
424430 deploymentType : BITBUCKET_SERVER ,
425431 token : token ,
@@ -477,18 +483,14 @@ async function serverGetReposForProjects(client: BitbucketClient, projects: stri
477483 const path = `/rest/api/1.0/projects/${ project } /repos` as ServerGetRequestPath ;
478484 const { durationMs, data } = await measure ( async ( ) => {
479485 const fetchFn = ( ) => getPaginatedServer < ServerRepository > ( path , async ( url , start ) => {
480- const response = await client . apiClient . GET ( url , {
486+ const { data } = await client . apiClient . GET ( url , {
481487 params : {
482488 query : {
483489 limit : 1000 ,
484490 start,
485491 }
486492 }
487493 } ) ;
488- const { data, error } = response ;
489- if ( error ) {
490- throw new Error ( `Failed to fetch repos for project ${ project } : ${ JSON . stringify ( error ) } ` ) ;
491- }
492494 return data ;
493495 } ) ;
494496 return fetchWithRetry ( fetchFn , `project ${ project } ` , logger ) ;
@@ -503,8 +505,7 @@ async function serverGetReposForProjects(client: BitbucketClient, projects: stri
503505 Sentry . captureException ( e ) ;
504506 logger . error ( `Failed to get repos for project ${ project } : ${ e } ` ) ;
505507
506- const status = e ?. cause ?. response ?. status ;
507- if ( status == 404 ) {
508+ if ( e ?. status === 404 ) {
508509 const warning = `Project ${ project } not found or invalid access` ;
509510 logger . warn ( warning ) ;
510511 return {
@@ -540,11 +541,7 @@ async function serverGetRepos(client: BitbucketClient, repoList: string[]): Prom
540541 try {
541542 const path = `/rest/api/1.0/projects/${ project } /repos/${ repo_slug } ` as ServerGetRequestPath ;
542543 const data = await fetchWithRetry ( async ( ) => {
543- const response = await client . apiClient . GET ( path ) ;
544- const { data, error } = response ;
545- if ( error ) {
546- throw new Error ( `Failed to fetch repo ${ repo } : ${ JSON . stringify ( error ) } ` ) ;
547- }
544+ const { data } = await client . apiClient . GET ( path ) ;
548545 return data ;
549546 } , `repo ${ repo } ` , logger ) ;
550547 return {
@@ -555,8 +552,7 @@ async function serverGetRepos(client: BitbucketClient, repoList: string[]): Prom
555552 Sentry . captureException ( e ) ;
556553 logger . error ( `Failed to fetch repo ${ repo } : ${ e } ` ) ;
557554
558- const status = e ?. cause ?. response ?. status ;
559- if ( status === 404 ) {
555+ if ( e ?. status === 404 ) {
560556 const warning = `Repo ${ repo } not found in project ${ project } or invalid access` ;
561557 logger . warn ( warning ) ;
562558 return {
@@ -581,13 +577,9 @@ async function serverGetAllRepos(client: BitbucketClient): Promise<{repos: Serve
581577 const path = `/rest/api/1.0/repos` as ServerGetRequestPath ;
582578 const { durationMs, data } = await measure ( async ( ) => {
583579 const fetchFn = ( ) => getPaginatedServer < ServerRepository > ( path , async ( url , start ) => {
584- const response = await client . apiClient . GET ( url , {
580+ const { data } = await client . apiClient . GET ( url , {
585581 params : { query : { limit : 1000 , start } }
586582 } ) ;
587- const { data, error } = response ;
588- if ( error ) {
589- throw new Error ( `Failed to fetch all repos: ${ JSON . stringify ( error ) } ` ) ;
590- }
591583 return data ;
592584 } ) ;
593585 return fetchWithRetry ( fetchFn , `all repos` , logger ) ;
@@ -652,16 +644,12 @@ export const getExplicitUserPermissionsForCloudRepo = async (
652644 const path = `/repositories/${ workspace } /${ repoSlug } /permissions-config/users` as CloudGetRequestPath ;
653645
654646 const users = await fetchWithRetry ( ( ) => getPaginatedCloud < CloudRepositoryUserPermission > ( path , async ( p , query ) => {
655- const response = await client . apiClient . GET ( p , {
647+ const { data } = await client . apiClient . GET ( p , {
656648 params : {
657649 path : { workspace, repo_slug : repoSlug } ,
658650 query,
659651 } ,
660652 } ) ;
661- const { data, error } = response ;
662- if ( error ) {
663- throw new Error ( `Failed to get explicit user permissions for ${ workspace } /${ repoSlug } : ${ JSON . stringify ( error ) } ` ) ;
664- }
665653 return data ;
666654 } ) , `permissions for ${ workspace } /${ repoSlug } ` , logger ) ;
667655
@@ -682,13 +670,9 @@ export const getReposForAuthenticatedBitbucketCloudUser = async (
682670 const path = `/user/permissions/repositories` as CloudGetRequestPath ;
683671
684672 const permissions = await fetchWithRetry ( ( ) => getPaginatedCloud < CloudRepositoryPermission > ( path , async ( p , query ) => {
685- const response = await client . apiClient . GET ( p , {
673+ const { data } = await client . apiClient . GET ( p , {
686674 params : { query } ,
687675 } ) ;
688- const { data, error } = response ;
689- if ( error ) {
690- throw new Error ( `Failed to get user repository permissions: ${ JSON . stringify ( error ) } ` ) ;
691- }
692676 return data ;
693677 } ) , 'user repository permissions' , logger ) ;
694678
@@ -707,28 +691,21 @@ export const getReposForAuthenticatedBitbucketServerUser = async (
707691 client : BitbucketClient ,
708692) : Promise < Array < { id : string } > > => {
709693
710- /**
711- * @note We need to explicitly check if the user is authenticated here because
712- * /rest/api/1.0/repos?permission=REPO_READ will return an empty list if the
713- * following conditions are met:
714- * 1. Anonymous access is enabled via `feature.public.access`
715- * 2. The token is expired or invalid.
716- *
717- * This check ensures we will not hit this condition and instead fail with a
718- * explicit error.
719- *
720- * @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-api-latest-repos-get
721- * @see https://confluence.atlassian.com/bitbucketserver/configuration-properties-776640155.html
722- */
723- const isAuthenticated = await isBitbucketServerUserAuthenticated ( client ) ;
724- if ( ! isAuthenticated ) {
725- throw new Error ( `Bitbucket Server authentication check failed. The OAuth token may be expired and the server may be treating the request as anonymous. Please re-authenticate with Bitbucket Server.` ) ;
726- }
694+ // Probe an auth-required endpoint first. When `feature.public.access` is
695+ // enabled on the BBS instance and the token is expired/invalid, the call
696+ // to /rest/api/1.0/repos?permission=REPO_READ below returns 200 with an
697+ // empty list instead of 401 — silently masking an unauthorized state.
698+ // /profile/recent/repos does return 401 in that case, so the middleware's
699+ // throw-on-error propagates with a real status code that isUnauthorized()
700+ // can catch downstream.
701+ // @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-api-latest-repos-get
702+ // @see https://confluence.atlassian.com/bitbucketserver/configuration-properties-776640155.html
703+ await client . apiClient . GET ( `/rest/api/1.0/profile/recent/repos` as ServerGetRequestPath , { } ) ;
727704
728705 const repos = await fetchWithRetry ( ( ) => getPaginatedServer < { id : number } > (
729706 `/rest/api/1.0/repos` as ServerGetRequestPath ,
730707 async ( url , start ) => {
731- const response = await client . apiClient . GET ( url , {
708+ const { data } = await client . apiClient . GET ( url , {
732709 params : {
733710 query : {
734711 permission : 'REPO_READ' ,
@@ -737,10 +714,6 @@ export const getReposForAuthenticatedBitbucketServerUser = async (
737714 } ,
738715 } ,
739716 } ) ;
740- const { data, error } = response ;
741- if ( error ) {
742- throw new Error ( `Failed to fetch Bitbucket Server repos for authenticated user: ${ JSON . stringify ( error ) } ` ) ;
743- }
744717 return data ;
745718 }
746719 ) , 'repos for authenticated Bitbucket Server user' , logger ) ;
@@ -766,13 +739,9 @@ export const getUserPermissionsForServerRepo = async (
766739 const repoUsers = await fetchWithRetry ( ( ) => getPaginatedServer < { user : { id : number } } > (
767740 `/rest/api/1.0/projects/${ projectKey } /repos/${ repoSlug } /permissions/users` as ServerGetRequestPath ,
768741 async ( url , start ) => {
769- const response = await client . apiClient . GET ( url , {
742+ const { data } = await client . apiClient . GET ( url , {
770743 params : { query : { limit : 1000 , start } } ,
771744 } ) ;
772- const { data, error } = response ;
773- if ( error ) {
774- throw new Error ( `Failed to fetch repo-level permissions for ${ projectKey } /${ repoSlug } : ${ JSON . stringify ( error ) } ` ) ;
775- }
776745 return data ;
777746 }
778747 ) , `repo-level permissions for ${ projectKey } /${ repoSlug } ` , logger ) ;
@@ -808,28 +777,3 @@ export const isBitbucketServerPublicAccessEnabled = async (
808777 return false ;
809778 }
810779} ;
811-
812- /**
813- * Returns true if the Bitbucket Server client is authenticated as a real user,
814- * false if the token is expired, invalid, or the request is being treated as anonymous.
815- */
816- export const isBitbucketServerUserAuthenticated = async (
817- client : BitbucketClient ,
818- ) : Promise < boolean > => {
819- try {
820- const { error, response } = await client . apiClient . GET ( `/rest/api/1.0/profile/recent/repos` as ServerGetRequestPath , { } ) ;
821- if ( error ) {
822- if ( response . status === 401 || response . status === 403 ) {
823- return false ;
824- }
825- throw new Error ( `Unexpected error when verifying Bitbucket Server authentication status: ${ JSON . stringify ( error ) } ` ) ;
826- }
827- return true ;
828- } catch ( e : any ) {
829- // Handle the case where openapi-fetch throws directly for auth errors
830- if ( e ?. status === 401 || e ?. status === 403 ) {
831- return false ;
832- }
833- throw e ;
834- }
835- } ;
0 commit comments