Skip to content

Commit f7c31be

Browse files
fix
1 parent 79b3767 commit f7c31be

4 files changed

Lines changed: 306 additions & 106 deletions

File tree

packages/backend/src/bitbucket.ts

Lines changed: 47 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createBitbucketCloudClient as createBitbucketCloudClientBase } from "@coderabbitai/bitbucket/cloud";
22
import { createBitbucketServerClient as createBitbucketServerClientBase } from "@coderabbitai/bitbucket/server";
33
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type";
4-
import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch";
4+
import type { ClientOptions, ClientPathsWithMethod, Middleware } from "openapi-fetch";
55
import { createLogger } from "@sourcebot/shared";
66
import { measure, fetchWithRetry } from "./utils.js";
77
import * as Sentry from "@sentry/node";
@@ -42,6 +42,25 @@ type CloudGetRequestPath = ClientPathsWithMethod<CloudAPI, "get">;
4242
type ServerAPI = ReturnType<typeof createBitbucketServerClientBase>;
4343
type 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+
4564
type 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

Comments
 (0)