Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export const registerBitbucketConnectionRouter = async (server: FastifyZodProvid
params: z.object({
connectionId: z.string().uuid()
}),
querystring: z.object({
search: z.string().trim().optional()
}),
response: {
200: z.object({
workspaces: z.object({ slug: z.string() }).array()
Expand All @@ -43,10 +46,15 @@ export const registerBitbucketConnectionRouter = async (server: FastifyZodProvid
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const {
params: { connectionId }
params: { connectionId },
query: { search }
} = req;

const workspaces = await server.services.appConnection.bitbucket.listWorkspaces(connectionId, req.permission);
const workspaces = await server.services.appConnection.bitbucket.listWorkspaces(
connectionId,
req.permission,
search
);

return { workspaces };
}
Expand All @@ -64,7 +72,8 @@ export const registerBitbucketConnectionRouter = async (server: FastifyZodProvid
connectionId: z.string().uuid()
}),
querystring: z.object({
workspaceSlug: z.string().min(1).max(255)
workspaceSlug: z.string().min(1).max(255),
search: z.string().trim().optional()
}),
response: {
200: z.object({
Expand All @@ -76,12 +85,13 @@ export const registerBitbucketConnectionRouter = async (server: FastifyZodProvid
handler: async (req) => {
const {
params: { connectionId },
query: { workspaceSlug }
query: { workspaceSlug, search }
} = req;

const repositories = await server.services.appConnection.bitbucket.listRepositories(
{ connectionId, workspaceSlug },
req.permission
req.permission,
search
);

return { repositories };
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AxiosError } from "axios";
import { AxiosError, HttpStatusCode } from "axios";

import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
Expand All @@ -14,6 +14,19 @@ import {
TBitbucketWorkspace
} from "./bitbucket-connection-types";

const BITBUCKET_MAX_PAGES = 10;
const BITBUCKET_PAGE_SIZE = 100;

const ensureBitbucketRateLimitNotExceeded = (error: unknown): never => {
if (error instanceof AxiosError && error.response?.status === HttpStatusCode.TooManyRequests) {
throw new BadRequestError({
message:
"Request to Bitbucket was blocked due to rate limiting. Bitbucket's rate limit window is 1 hour. Please try again later."
});
}
throw error;
};

export const getBitbucketConnectionListItem = () => {
return {
name: "Bitbucket" as const,
Expand Down Expand Up @@ -62,7 +75,7 @@ interface BitbucketWorkspacesResponse {
next?: string;
}

export const listBitbucketWorkspaces = async (appConnection: TBitbucketConnection) => {
export const listBitbucketWorkspaces = async (appConnection: TBitbucketConnection, search?: string) => {
const { email, apiToken } = appConnection.credentials;

const headers = {
Expand All @@ -71,19 +84,22 @@ export const listBitbucketWorkspaces = async (appConnection: TBitbucketConnectio
};

let allWorkspaces: TBitbucketWorkspace[] = [];
let nextUrl: string | undefined = `${IntegrationUrls.BITBUCKET_API_URL}/2.0/user/workspaces?pagelen=100`;
let iterationCount = 0;

// Limit to 10 iterations, fetching at most 10 * 100 = 1000 workspaces
while (nextUrl && iterationCount < 10) {
// eslint-disable-next-line no-await-in-loop
const { data }: { data: BitbucketWorkspacesResponse } = await request.get<BitbucketWorkspacesResponse>(nextUrl, {
const baseUrl = new URL(`${IntegrationUrls.BITBUCKET_API_URL}/2.0/user/workspaces`);
baseUrl.searchParams.set("pagelen", BITBUCKET_PAGE_SIZE.toString());
if (search) {
baseUrl.searchParams.set("q", `slug ~ "${search.replace(/"/g, "")}"`);
}

const endpoint = baseUrl.toString();
try {
const { data }: { data: BitbucketWorkspacesResponse } = await request.get<BitbucketWorkspacesResponse>(endpoint, {
headers
});

allWorkspaces = allWorkspaces.concat(data.values.map((membership) => ({ slug: membership.workspace.slug })));
nextUrl = data.next;
iterationCount += 1;
} catch (error) {
ensureBitbucketRateLimitNotExceeded(error);
}

return allWorkspaces;
Expand All @@ -94,27 +110,32 @@ interface BitbucketPaginatedResponse<T> {
next?: string;
}

const BITBUCKET_MAX_PAGES = 10;
const BITBUCKET_PAGE_SIZE = 100;

const paginateBitbucketRequest = async <T>(url: string, headers: Record<string, string>): Promise<T[]> => {
let allItems: T[] = [];
let nextUrl: string | undefined = url;
let iterationCount = 0;

while (nextUrl && iterationCount < BITBUCKET_MAX_PAGES) {
// eslint-disable-next-line no-await-in-loop
const { data }: { data: BitbucketPaginatedResponse<T> } = await request.get(nextUrl, { headers });
try {
while (nextUrl && iterationCount < BITBUCKET_MAX_PAGES) {
// eslint-disable-next-line no-await-in-loop
const { data }: { data: BitbucketPaginatedResponse<T> } = await request.get(nextUrl, { headers });

allItems = allItems.concat(data.values);
nextUrl = data.next;
iterationCount += 1;
allItems = allItems.concat(data.values);
nextUrl = data.next;
iterationCount += 1;
}
} catch (error) {
ensureBitbucketRateLimitNotExceeded(error);
}

return allItems;
};

export const listBitbucketRepositories = async (appConnection: TBitbucketConnection, workspaceSlug: string) => {
export const listBitbucketRepositories = async (
appConnection: TBitbucketConnection,
workspaceSlug: string,
search?: string
) => {
const { email, apiToken } = appConnection.credentials;

const headers = {
Expand All @@ -124,22 +145,36 @@ export const listBitbucketRepositories = async (appConnection: TBitbucketConnect

const encodedSlug = encodeURIComponent(workspaceSlug);

// Fetch repos per-project to avoid Bitbucket's 1,000-result pagination cap
const projects = await paginateBitbucketRequest<{ key: string }>(
`${IntegrationUrls.BITBUCKET_API_URL}/2.0/workspaces/${encodedSlug}/projects?pagelen=${BITBUCKET_PAGE_SIZE}`,
headers
);
try {
if (search) {
const baseUrl = new URL(`${IntegrationUrls.BITBUCKET_API_URL}/2.0/repositories/${encodedSlug}`);
baseUrl.searchParams.set("pagelen", String(BITBUCKET_PAGE_SIZE));
baseUrl.searchParams.set("sort", "slug");
baseUrl.searchParams.set("q", `name ~ "${search.replace(/"/g, "")}"`);

const { data } = await request.get<BitbucketPaginatedResponse<TBitbucketRepo>>(baseUrl.toString(), { headers });
return data.values;
}

const reposByProject = await Promise.all(
projects.map((project) =>
paginateBitbucketRequest<TBitbucketRepo>(
`${IntegrationUrls.BITBUCKET_API_URL}/2.0/repositories/${encodedSlug}?pagelen=${BITBUCKET_PAGE_SIZE}&sort=slug&q=project.key="${encodeURIComponent(project.key)}"`,
headers
// Fetch repos per-project to avoid Bitbucket's 1,000-result pagination cap
const projects = await paginateBitbucketRequest<{ key: string }>(
`${IntegrationUrls.BITBUCKET_API_URL}/2.0/workspaces/${encodedSlug}/projects?pagelen=${BITBUCKET_PAGE_SIZE}`,
headers
);

const reposByProject = await Promise.all(
projects.map((project) =>
paginateBitbucketRequest<TBitbucketRepo>(
`${IntegrationUrls.BITBUCKET_API_URL}/2.0/repositories/${encodedSlug}?pagelen=${BITBUCKET_PAGE_SIZE}&sort=slug&q=project.key="${encodeURIComponent(project.key)}"`,
headers
)
)
)
);
);

return reposByProject.flat();
return reposByProject.flat();
} catch (error) {
return ensureBitbucketRateLimitNotExceeded(error);
}
Comment thread
mathnogueira marked this conversation as resolved.
};

export const listBitbucketEnvironments = async (
Expand All @@ -154,30 +189,8 @@ export const listBitbucketEnvironments = async (
Accept: "application/json"
};

const environments: TBitbucketEnvironment[] = [];
let hasNextPage = true;

let environmentsUrl = `${IntegrationUrls.BITBUCKET_API_URL}/2.0/repositories/${encodeURIComponent(workspaceSlug)}/${encodeURIComponent(repositorySlug)}/environments?pagelen=100`;

let iterationCount = 0;
// Limit to 10 iterations, fetching at most 10 * 100 = 1000 environments
while (hasNextPage && iterationCount < 10) {
// eslint-disable-next-line no-await-in-loop
const { data }: { data: { values: TBitbucketEnvironment[]; next: string } } = await request.get(environmentsUrl, {
headers
});

if (data?.values.length > 0) {
environments.push(...data.values);
}

if (data.next) {
environmentsUrl = data.next;
} else {
hasNextPage = false;
}
iterationCount += 1;
}

return environments;
return paginateBitbucketRequest<TBitbucketEnvironment>(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

environments don't support searching by a term, so it's the only endpoint we still need to run this logic.

`${IntegrationUrls.BITBUCKET_API_URL}/2.0/repositories/${encodeURIComponent(workspaceSlug)}/${encodeURIComponent(repositorySlug)}/environments?pagelen=${BITBUCKET_PAGE_SIZE}`,
headers
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,19 @@ type TGetAppConnectionFunc = (
) => Promise<TBitbucketConnection>;

export const bitbucketConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listWorkspaces = async (connectionId: string, actor: OrgServiceActor) => {
const listWorkspaces = async (connectionId: string, actor: OrgServiceActor, search?: string) => {
const appConnection = await getAppConnection(AppConnection.Bitbucket, connectionId, actor);
const workspaces = await listBitbucketWorkspaces(appConnection);
const workspaces = await listBitbucketWorkspaces(appConnection, search);
return workspaces;
};

const listRepositories = async (
{ connectionId, workspaceSlug }: TGetBitbucketRepositoriesDTO,
actor: OrgServiceActor
actor: OrgServiceActor,
search?: string
) => {
const appConnection = await getAppConnection(AppConnection.Bitbucket, connectionId, actor);
const repositories = await listBitbucketRepositories(appConnection, workspaceSlug);
const repositories = await listBitbucketRepositories(appConnection, workspaceSlug, search);
return repositories;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { MultiValue, SingleValue } from "react-select";
import { faCircleInfo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";

import { FilterableSelect, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2";
import { useDebounce } from "@app/hooks";
import {
TBitbucketRepo,
TBitbucketWorkspace,
Expand All @@ -28,16 +29,21 @@ export const BitbucketDataSourceConfigFields = () => {
}
>();

const [workspaceSearch, setWorkspaceSearch] = useState("");
const [debouncedWorkspaceSearch] = useDebounce(workspaceSearch, 300);

const connectionId = useWatch({ control, name: "connection.id" });
const isUpdate = Boolean(watch("id"));

const selectedWorkspaceSlug = useWatch({ control, name: "config.workspaceSlug" });

const { data: workspaces, isPending: areWorkspacesLoading } =
useBitbucketConnectionListWorkspaces(connectionId, { enabled: Boolean(connectionId) });
useBitbucketConnectionListWorkspaces(connectionId, debouncedWorkspaceSearch || undefined, {
enabled: Boolean(connectionId)
});

const { data: repositories, isPending: areRepositoriesLoading } =
useBitbucketConnectionListRepositories(connectionId, selectedWorkspaceSlug, {
useBitbucketConnectionListRepositories(connectionId, selectedWorkspaceSlug, undefined, {
enabled: Boolean(connectionId) && Boolean(selectedWorkspaceSlug)
});

Expand Down Expand Up @@ -96,10 +102,15 @@ export const BitbucketDataSourceConfigFields = () => {
setValue("config.includeRepos", []);
}
}}
onInputChange={(newValue) => setWorkspaceSearch(newValue)}
filterOption={null}
options={workspaces}
placeholder="Select workspace..."
placeholder="Search for a workspace..."
getOptionLabel={(option) => option.slug}
getOptionValue={(option) => option.slug}
noOptionsMessage={({ inputValue }) =>
inputValue ? "No workspaces found matching your search." : "No workspaces found."
}
/>
</FormControl>
)}
Expand Down
Loading
Loading