Skip to content

Commit 26f4adc

Browse files
committed
feat(bitbucket): integrate Bitbucket support with repository search and team management
1 parent bb5747c commit 26f4adc

File tree

6 files changed

+217
-28
lines changed

6 files changed

+217
-28
lines changed

backend/analytics_server/mhq/api/request_utils.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from stringcase import snakecase
88
from voluptuous import Invalid
99
from werkzeug.exceptions import BadRequest
10+
from mhq.utils.log import LOG
1011
from mhq.store.models.code.repository import TeamRepos
1112
from mhq.service.code.models.org_repo import RawTeamOrgRepo
1213
from mhq.store.models.code import WorkflowFilter, CodeProvider
@@ -82,20 +83,24 @@ def coerce_workflow_filter(filter_data: str) -> WorkflowFilter:
8283

8384

8485
def coerce_org_repo(repo: Dict[str, str]) -> RawTeamOrgRepo:
85-
return RawTeamOrgRepo(
86-
team_id=repo.get("team_id"),
87-
provider=CodeProvider(repo.get("provider")),
88-
name=repo.get("name"),
89-
org_name=repo.get("org"),
90-
slug=repo.get("slug"),
91-
idempotency_key=repo.get("idempotency_key"),
92-
default_branch=repo.get("default_branch"),
93-
deployment_type=(
94-
TeamReposDeploymentType(repo.get("deployment_type"))
95-
if repo.get("deployment_type")
96-
else TeamReposDeploymentType.PR_MERGE
97-
),
98-
)
86+
try:
87+
return RawTeamOrgRepo(
88+
team_id=repo.get("team_id"),
89+
provider=CodeProvider(repo.get("provider")),
90+
name=repo.get("name"),
91+
org_name=repo.get("org"),
92+
slug=repo.get("slug"),
93+
idempotency_key=repo.get("idempotency_key"),
94+
default_branch=repo.get("default_branch"),
95+
deployment_type=(
96+
TeamReposDeploymentType(repo.get("deployment_type"))
97+
if repo.get("deployment_type")
98+
else TeamReposDeploymentType.PR_MERGE
99+
),
100+
)
101+
except Exception as e:
102+
LOG.error(f"Error creating RawTeamOrgRepo with data: {repo}. Error: {str(e)}")
103+
raise
99104

100105

101106
def coerce_org_repos(repos: List[Dict[str, str]]) -> List[RawTeamOrgRepo]:

backend/analytics_server/mhq/store/models/code/enums.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
class CodeProvider(Enum):
55
GITHUB = "github"
66
GITLAB = "gitlab"
7+
BITBUCKET = "bitbucket"
78

89

910
class CodeBookmarkType(Enum):

web-server/pages/api/internal/[org_id]/git_provider_org.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import * as yup from 'yup';
22

3-
import { gitlabSearch, searchGithubRepos, getGithubToken, getGitlabToken } from '@/api/internal/[org_id]/utils';
3+
import {
4+
gitlabSearch,
5+
searchGithubRepos,
6+
bitbucketSearch,
7+
getGithubToken,
8+
getGitlabToken
9+
} from '@/api/internal/[org_id]/utils';
410
import { Endpoint } from '@/api-helpers/global';
511
import { Integration } from '@/constants/integrations';
612

@@ -42,6 +48,42 @@ endpoint.handle.GET(getSchema, async (req, res) => {
4248

4349
export default endpoint.serve();
4450

51+
const getGithubToken = async (org_id: ID) => {
52+
return await db('Integration')
53+
.select()
54+
.where({
55+
org_id,
56+
name: Integration.GITHUB
57+
})
58+
.returning('*')
59+
.then(getFirstRow)
60+
.then((r) => dec(r.access_token_enc_chunks));
61+
};
62+
63+
const getGitlabToken = async (org_id: ID) => {
64+
return await db('Integration')
65+
.select()
66+
.where({
67+
org_id,
68+
name: Integration.GITLAB
69+
})
70+
.returning('*')
71+
.then(getFirstRow)
72+
.then((r) => dec(r.access_token_enc_chunks));
73+
};
74+
75+
const getBitbucketToken = async (org_id: ID) => {
76+
return await db('Integration')
77+
.select()
78+
.where({
79+
org_id,
80+
name: Integration.BITBUCKET
81+
})
82+
.returning('*')
83+
.then(getFirstRow)
84+
.then((r) => dec(r.access_token_enc_chunks));
85+
};
86+
4587
const fetchMap = [
4688
{
4789
provider: Integration.GITHUB,
@@ -52,5 +94,10 @@ const fetchMap = [
5294
provider: Integration.GITLAB,
5395
search: gitlabSearch,
5496
getToken: getGitlabToken
97+
},
98+
{
99+
provider: Integration.BITBUCKET,
100+
search: bitbucketSearch,
101+
getToken: getBitbucketToken
55102
}
56103
];

web-server/pages/api/internal/[org_id]/utils.ts

Lines changed: 140 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { head } from 'ramda';
33

44
import { Row } from '@/constants/db';
55
import { Integration } from '@/constants/integrations';
6-
import { BaseRepo } from '@/types/resources';
7-
import { db, getFirstRow } from '@/utils/db';
86
import { DEFAULT_GH_URL } from '@/constants/urls';
7+
import { BaseRepo } from '@/types/resources';
98
import { dec } from '@/utils/auth-supplementary';
9+
import { db, getFirstRow } from '@/utils/db';
1010

1111
type GithubRepo = {
1212
name: string;
@@ -271,6 +271,134 @@ export const gitlabSearch = async (pat: string, searchString: string) => {
271271
return searchGitlabRepos(pat, search);
272272
};
273273

274+
// Bitbucket functions
275+
276+
type BitbucketRepo = {
277+
uuid: string;
278+
name: string;
279+
full_name: string;
280+
description?: string;
281+
language?: string;
282+
mainbranch?: {
283+
name: string;
284+
};
285+
links: {
286+
html: {
287+
href: string;
288+
};
289+
};
290+
owner: {
291+
username: string;
292+
};
293+
};
294+
295+
type BitbucketResponse = {
296+
values: BitbucketRepo[];
297+
next?: string;
298+
};
299+
300+
const BITBUCKET_API_URL = 'https://api.bitbucket.org/2.0';
301+
302+
export const searchBitbucketRepos = async (
303+
credentials: string,
304+
searchString: string
305+
): Promise<BaseRepo[]> => {
306+
let urlString = convertUrlToQuery(searchString);
307+
if (urlString !== searchString && urlString.includes('/')) {
308+
try {
309+
return await searchBitbucketRepoWithURL(credentials, urlString);
310+
} catch (e) {
311+
return await searchBitbucketReposWithNames(credentials, urlString);
312+
}
313+
}
314+
return await searchBitbucketReposWithNames(credentials, urlString);
315+
};
316+
317+
const searchBitbucketRepoWithURL = async (
318+
credentials: string,
319+
searchString: string
320+
): Promise<BaseRepo[]> => {
321+
const apiUrl = `${BITBUCKET_API_URL}/repositories/${searchString}`;
322+
323+
const response = await fetch(apiUrl, {
324+
method: 'GET',
325+
headers: {
326+
Authorization: `Basic ${credentials}`,
327+
'Content-Type': 'application/json'
328+
}
329+
});
330+
331+
if (!response.ok) {
332+
throw new Error(`Bitbucket API error: ${response.statusText}`);
333+
}
334+
335+
const repo = (await response.json()) as BitbucketRepo;
336+
337+
return [
338+
{
339+
id: repo.uuid.replace(/[{}]/g, ''),
340+
name: repo.name,
341+
desc: repo.description,
342+
slug: repo.name,
343+
parent: repo.owner.username,
344+
web_url: repo.links.html.href,
345+
branch: repo.mainbranch?.name,
346+
language: repo.language,
347+
provider: Integration.BITBUCKET
348+
}
349+
] as BaseRepo[];
350+
};
351+
352+
const searchBitbucketReposWithNames = async (
353+
credentials: string,
354+
searchString: string
355+
): Promise<BaseRepo[]> => {
356+
const apiUrl = `${BITBUCKET_API_URL}/repositories`;
357+
const params = new URLSearchParams({
358+
q: `name~"${searchString}"`,
359+
role: 'member',
360+
pagelen: '50'
361+
});
362+
363+
const response = await fetch(`${apiUrl}?${params}`, {
364+
method: 'GET',
365+
headers: {
366+
Authorization: `Basic ${credentials}`,
367+
'Content-Type': 'application/json'
368+
}
369+
});
370+
371+
if (!response.ok) {
372+
throw new Error(`Bitbucket API error: ${response.statusText}`);
373+
}
374+
375+
const responseBody = (await response.json()) as BitbucketResponse;
376+
const repositories = responseBody.values || [];
377+
378+
return repositories.map(
379+
(repo) =>
380+
({
381+
id: repo.uuid.replace(/[{}]/g, ''),
382+
name: repo.name,
383+
desc: repo.description,
384+
slug: repo.name,
385+
parent: repo.owner.username,
386+
web_url: repo.links.html.href,
387+
branch: repo.mainbranch?.name,
388+
language: repo.language || null,
389+
provider: Integration.BITBUCKET
390+
}) as BaseRepo
391+
);
392+
};
393+
394+
export const bitbucketSearch = async (
395+
credentials: string,
396+
searchString: string
397+
): Promise<BaseRepo[]> => {
398+
let search = convertUrlToQuery(searchString);
399+
return searchBitbucketRepos(credentials, search);
400+
};
401+
274402
const convertUrlToQuery = (url: string) => {
275403
let query = url;
276404
try {
@@ -282,6 +410,7 @@ const convertUrlToQuery = (url: string) => {
282410
query = query.replace('http://', '');
283411
query = query.replace('github.com/', '');
284412
query = query.replace('gitlab.com/', '');
413+
query = query.replace('bitbucket.org/', '');
285414
query = query.startsWith('www.') ? query.slice(4) : query;
286415
query = query.endsWith('/') ? query.slice(0, -1) : query;
287416
}
@@ -315,26 +444,27 @@ export const getGitHubCustomDomain = async (): Promise<string | null> => {
315444

316445
return head(provider_meta || [])?.custom_domain || null;
317446
} catch (error) {
318-
console.error('Error occured while getting custom domain from database:', error);
447+
console.error(
448+
'Error occured while getting custom domain from database:',
449+
error
450+
);
319451
return null;
320452
}
321453
};
322454

323-
const normalizeSlashes = (url: string) =>
324-
url.replace(/(?<!:)\/{2,}/g, '/');
455+
const normalizeSlashes = (url: string) => url.replace(/(?<!:)\/{2,}/g, '/');
325456

326457
export const getGitHubRestApiUrl = async (path: string) => {
327458
const customDomain = await getGitHubCustomDomain();
328-
const base = customDomain
329-
? `${customDomain}/api/v3`
330-
: DEFAULT_GH_URL;
459+
const base = customDomain ? `${customDomain}/api/v3` : DEFAULT_GH_URL;
331460
return normalizeSlashes(`${base}/${path}`);
332461
};
333462

334-
335463
export const getGitHubGraphQLUrl = async (): Promise<string> => {
336464
const customDomain = await getGitHubCustomDomain();
337-
return customDomain ? `${customDomain}/api/graphql` : `${DEFAULT_GH_URL}/graphql`;
465+
return customDomain
466+
? `${customDomain}/api/graphql`
467+
: `${DEFAULT_GH_URL}/graphql`;
338468
};
339469

340470
export const getGithubToken = async (org_id: ID) => {

web-server/pages/api/resources/orgs/[org_id]/teams/v2.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ endpoint.handle.GET(getSchema, async (req, res) => {
9191
org_id,
9292
providers?.length
9393
? (providers as Integration[])
94-
: [Integration.GITHUB, Integration.GITLAB]
94+
: [Integration.GITHUB, Integration.GITLAB,Integration.BITBUCKET]
9595
);
9696

9797
res.send({
@@ -116,6 +116,7 @@ endpoint.handle.POST(postSchema, async (req, res) => {
116116
} as any as ReqRepoWithProvider);
117117
});
118118
}, org_repos);
119+
console.log('orgReposList in POST', orgReposList);
119120
const [team, onboardingState] = await Promise.all([
120121
createTeam(org_id, name, []),
121122
getOnBoardingState(org_id)
@@ -165,7 +166,7 @@ endpoint.handle.PATCH(patchSchema, async (req, res) => {
165166
} as any as ReqRepoWithProvider);
166167
});
167168
}, org_repos);
168-
169+
console.log('orgReposList in Patch', orgReposList);
169170
const [team] = await Promise.all([
170171
updateTeam(id, name, []),
171172
handleRequest<(Row<'TeamRepos'> & Row<'OrgRepo'>)[]>(`/teams/${id}/repos`, {
@@ -301,7 +302,7 @@ const updateReposWorkflows = async (
301302
.whereIn('name', reposForWorkflows)
302303
.where('org_id', org_id)
303304
.andWhere('is_active', true)
304-
.and.whereIn('provider', [Integration.GITHUB, Integration.GITLAB]);
305+
.and.whereIn('provider', [Integration.GITHUB, Integration.GITLAB, Integration.BITBUCKET]);
305306

306307
const groupedRepos = groupBy(dbReposForWorkflows, 'name');
307308

web-server/src/components/Teams/CreateTeams.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { DeploymentWorkflowSelector } from '@/components/WorkflowSelector';
3434
import { Integration } from '@/constants/integrations';
3535
import { useModal } from '@/contexts/ModalContext';
3636
import { useBoolState, useEasyState } from '@/hooks/useEasyState';
37+
import BitbucketIcon from '@/mocks/icons/bitbucket.svg';
3738
import GitlabIcon from '@/mocks/icons/gitlab.svg';
3839
import { BaseRepo, DeploymentSources } from '@/types/resources';
3940
import { trimWithEllipsis } from '@/utils/stringFormatting';
@@ -311,6 +312,8 @@ const TeamRepos: FC = () => {
311312
<FlexBox gap={1 / 2} alignCenter>
312313
{option.provider === Integration.GITHUB ? (
313314
<GitHub sx={{ fontSize: '14px' }} />
315+
) : option.provider === Integration.BITBUCKET ? (
316+
<BitbucketIcon height={12} width={12} />
314317
) : (
315318
<GitlabIcon height={12} width={12} />
316319
)}
@@ -447,6 +450,8 @@ const DisplayRepos: FC = () => {
447450
>
448451
{repo.provider === Integration.GITHUB ? (
449452
<GitHub />
453+
) : repo.provider === Integration.BITBUCKET ? (
454+
<BitbucketIcon height={14} width={14} />
450455
) : (
451456
<GitlabIcon height={14} width={14} />
452457
)}

0 commit comments

Comments
 (0)