From adf5a16863ff019528051bbf5dc672bdc112411a Mon Sep 17 00:00:00 2001 From: Ayyan Shaikh Date: Tue, 6 May 2025 17:11:10 +0530 Subject: [PATCH 1/5] feat: Add Bitbucket integration card and configuration modal - Implemented BitbucketIntegrationCard component for displaying integration status and actions. - Added integration actions for linking and unlinking Bitbucket accounts with appropriate loading states and notifications. - Created ConfigureBitbucketModalBody component for user input to link Bitbucket accounts, including validation for username, password, and custom domain. - Integrated error handling and user feedback through snackbar notifications. - Included visual representation of required permissions for Bitbucket App Passwords. --- .../api/integrations/bitbucket/scopes.ts | 35 +++ web-server/pages/integrations.tsx | 2 + web-server/public/assets/bitbucketPAT.png | Bin 0 -> 60907 bytes web-server/src/api-helpers/axios.ts | 16 +- .../Dashboards/BitbucketIntegrationCard.tsx | 248 +++++++++++++++ .../ConfigureBitbucketModalBody.tsx | 285 ++++++++++++++++++ .../content/Dashboards/githubIntegration.tsx | 11 + .../Dashboards/useIntegrationHandlers.tsx | 12 +- web-server/src/utils/auth.ts | 35 +++ 9 files changed, 637 insertions(+), 7 deletions(-) create mode 100644 web-server/pages/api/integrations/bitbucket/scopes.ts create mode 100644 web-server/public/assets/bitbucketPAT.png create mode 100644 web-server/src/content/Dashboards/BitbucketIntegrationCard.tsx create mode 100644 web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx diff --git a/web-server/pages/api/integrations/bitbucket/scopes.ts b/web-server/pages/api/integrations/bitbucket/scopes.ts new file mode 100644 index 000000000..414cac7f8 --- /dev/null +++ b/web-server/pages/api/integrations/bitbucket/scopes.ts @@ -0,0 +1,35 @@ +import { Endpoint, nullSchema } from '@/api-helpers/global'; +import { handleRequest } from '@/api-helpers/axios'; +import * as yup from 'yup'; + +const payloadSchema = yup.object({ + username: yup.string().required('Username is required'), + appPassword: yup.string().required('App password is required'), + customDomain: yup.string().url('Custom domain must be a valid URL'), +}); + +const endpoint = new Endpoint(nullSchema); + +endpoint.handle.POST(payloadSchema, async (req, res) => { + try { + const { username, appPassword, customDomain } = req.payload; + const baseUrl = customDomain || 'https://api.bitbucket.org/2.0'; + const url = `${baseUrl}/user`; + const response = await handleRequest(url, { + method: 'GET', + auth: { + username, + password: appPassword, + }, + },true); + + res.status(200).json(response); + } catch (error: any) { + console.error('Error fetching Bitbucket user:', error.message); + res.status(error.response?.status || 500).json({ + message: error.response?.data?.error?.message || 'Internal Server Error', + }); + } +}); + +export default endpoint.serve(); \ No newline at end of file diff --git a/web-server/pages/integrations.tsx b/web-server/pages/integrations.tsx index 2ec60be91..be03e2539 100644 --- a/web-server/pages/integrations.tsx +++ b/web-server/pages/integrations.tsx @@ -13,6 +13,7 @@ import { ROUTES } from '@/constants/routes'; import { FetchState } from '@/constants/ui-states'; import { GithubIntegrationCard } from '@/content/Dashboards/GithubIntegrationCard'; import { GitlabIntegrationCard } from '@/content/Dashboards/GitlabIntegrationCard'; +import { BitbucketIntegrationCard } from '@/content/Dashboards/BitbucketIntegrationCard'; import { PageWrapper } from '@/content/PullRequests/PageWrapper'; import { useAuth } from '@/hooks/useAuth'; import { useBoolState, useEasyState } from '@/hooks/useEasyState'; @@ -163,6 +164,7 @@ const Content = () => { + {showCreationCTA && ( diff --git a/web-server/public/assets/bitbucketPAT.png b/web-server/public/assets/bitbucketPAT.png new file mode 100644 index 0000000000000000000000000000000000000000..ae85487f7db565ab78f741a336f4969d27f6edd3 GIT binary patch literal 60907 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 Q&BZi3$3z-ols~`3-vv$AV5&T<#py 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 P>hDojP?xWgGY( +export const handleRequest = ( url: string, - params: AxiosRequestConfig = { method: 'get' } -): Promise => + params: AxiosRequestConfig = { method: 'get' }, + includeHeaders: B = false as B +): Promise => internal({ url, ...params, headers: { 'Content-Type': 'application/json' } }) - .then(handleThen) + .then((r: any) => handleThen(r, includeHeaders)) .catch(handleCatch); -export const handleThen = (r: AxiosResponse) => r.data; +export const handleThen = ( + r: AxiosResponse, + includeHeaders: B = false as B +): B extends true ? { data: T; headers: any } : T => + (includeHeaders ? { data: r.data, headers: r.headers } : r.data) as any; + export const handleCatch = (r: { response: AxiosResponse }) => { throw r.response; }; diff --git a/web-server/src/content/Dashboards/BitbucketIntegrationCard.tsx b/web-server/src/content/Dashboards/BitbucketIntegrationCard.tsx new file mode 100644 index 000000000..fe5e5a4eb --- /dev/null +++ b/web-server/src/content/Dashboards/BitbucketIntegrationCard.tsx @@ -0,0 +1,248 @@ +import { + ArrowForwardIosRounded, + ChevronRightRounded, + SettingsRounded + } from '@mui/icons-material'; + import { Button, useTheme } from '@mui/material'; + import CircularProgress from '@mui/material/CircularProgress'; + import { useSnackbar } from 'notistack'; + import { FC, ReactNode, useEffect } from 'react'; + + import { FlexBox } from '@/components/FlexBox'; + import { Line } from '@/components/Text'; + import { track } from '@/constants/events'; + import { FetchState } from '@/constants/ui-states'; + import { bitBucketIntegrationDisplay } from '@/content/Dashboards/githubIntegration'; + import { useIntegrationHandlers } from '@/content/Dashboards/useIntegrationHandlers'; + import { useAuth } from '@/hooks/useAuth'; + import { useBoolState } from '@/hooks/useEasyState'; + import { fetchCurrentOrg } from '@/slices/auth'; + import { useDispatch, useSelector } from '@/store'; + + const cardRadius = 10.5; + const cardBorder = 1.5; + const getRadiusWithPadding = (radius: number, padding: number) => + `${radius + padding}px`; + + export const BitbucketIntegrationCard = () => { + const theme = useTheme(); + const { integrations } = useAuth(); + const isBitbucketIntegrated = integrations.bitbucket; + const sliceLoading = useSelector( + (s: { auth: { requests: { org: FetchState; }; }; }) => s.auth.requests.org === FetchState.REQUEST + ); + const { link, unlink } = useIntegrationHandlers(); + + const localLoading = useBoolState(false); + + const isLoading = sliceLoading || localLoading.value; + + const dispatch = useDispatch(); + + const { enqueueSnackbar } = useSnackbar(); + + return ( + + {isBitbucketIntegrated && ( + + + + )} + + + + + + {bitBucketIntegrationDisplay.icon} + + + {bitBucketIntegrationDisplay.name} + + + + { + track( + isBitbucketIntegrated + ? 'INTEGRATION_UNLINK_TRIGGERED' + : 'INTEGRATION_LINK_TRIGGERED', + { integration_name: bitBucketIntegrationDisplay.name } + ); + if (!isBitbucketIntegrated) { + link.bitbucket(); + return; + } + const shouldExecute = window.confirm( + 'Are you sure you want to unlink?' + ); + if (shouldExecute) { + localLoading.true(); + await unlink + .bitbucket() + .then(() => { + enqueueSnackbar('Bitbucket unlinked successfully', { + variant: 'success' + }); + }) + .then(async () => dispatch(fetchCurrentOrg())) + .catch((e: any) => { + console.error('Failed to unlink Bitbucket', e); + enqueueSnackbar('Failed to unlink Bitbucket', { + variant: 'error' + }); + }) + .finally(localLoading.false); + } + }} + label={!isBitbucketIntegrated ? 'Link' : 'Unlink'} + bgOpacity={!isBitbucketIntegrated ? 0.45 : 0.25} + endIcon={ + isLoading ? ( + + ) : ( + + ) + } + minWidth="72px" + /> + + + + + ); + }; + + const IntegrationActionsButton: FC<{ + onClick: AnyFunction; + label: ReactNode; + bgOpacity?: number; + startIcon?: ReactNode; + endIcon?: ReactNode; + minWidth?: string; + }> = ({ + label, + onClick, + bgOpacity = 0.45, + endIcon = ( + + ), + startIcon = , + minWidth = '80px' + }) => { + const theme = useTheme(); + + return ( + + ); + }; + + const LinkedIcon = () => { + const isVisible = useBoolState(false); + useEffect(() => { + setTimeout(isVisible.true, 200); + }, [isVisible.true]); + return ( + + + + + + + + + + + ); + }; + \ No newline at end of file diff --git a/web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx b/web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx new file mode 100644 index 000000000..3edd4fb58 --- /dev/null +++ b/web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx @@ -0,0 +1,285 @@ +import { LoadingButton } from '@mui/lab'; +import { Divider, Link, TextField, alpha } from '@mui/material'; +import Image from 'next/image'; +import { useSnackbar } from 'notistack'; +import { FC, useCallback, useMemo } from 'react'; + +import { FlexBox } from '@/components/FlexBox'; +import { Line } from '@/components/Text'; +import { Integration } from '@/constants/integrations'; +import { useAuth } from '@/hooks/useAuth'; +import { useBoolState, useEasyState } from '@/hooks/useEasyState'; +import { fetchCurrentOrg } from '@/slices/auth'; +import { fetchTeams } from '@/slices/team'; +import { useDispatch } from '@/store'; +import { + linkProvider, + checkBitBucketValidity, + getMissingBitBucketScopes +} from '@/utils/auth'; +import { depFn } from '@/utils/fn'; + +export const ConfigureBitbucketModalBody: FC<{ onClose: () => void }> = ({ onClose }) => { + const username = useEasyState(''); + const password = useEasyState(''); + const customDomain = useEasyState(''); + const { orgId } = useAuth(); + const { enqueueSnackbar } = useSnackbar(); + const dispatch = useDispatch(); + const isLoading = useBoolState(); + + const showUsernameError = useEasyState(''); + const showPasswordError = useEasyState(''); + const showDomainError = useEasyState(''); + + const setUsernameError = useCallback( + (err: string) => depFn(showUsernameError.set, err), + [showUsernameError.set] + ); + const setPasswordError = useCallback( + (err: string) => depFn(showPasswordError.set, err), + [showPasswordError.set] + ); + const setDomainError = useCallback( + (err: string) => depFn(showDomainError.set, err), + [showDomainError.set] + ); + + const checkDomainWithRegex = (domain: string) => { + const regex = /^(https?:\/\/)[\w\-]+(\.[\w\-]+)+(\:\d{1,5})?(\/.*)?$/; + return regex.test(domain); + }; + + const handleUsernameChange = (val: string) => { + username.set(val); + showUsernameError.set(''); + }; + const handlePasswordChange = (val: string) => { + password.set(val); + showPasswordError.set(''); + }; + const handleDomainChange = (val: string) => { + customDomain.set(val); + showDomainError.set(''); + }; + + const handleSubmission = useCallback(async () => { + try { + if (!username.value) { + setUsernameError('Please enter your Bitbucket username'); + throw new Error('Empty Username'); + } + if (!password.value) { + setPasswordError('Please enter your App Password'); + throw new Error('Empty Password'); + } + if (customDomain.value && !checkDomainWithRegex(customDomain.value)) { + setDomainError('Please enter a valid domain URL'); + throw new Error('Invalid Domain'); + } + } catch (err) { + console.error(err); + return; + } + + depFn(isLoading.true); + try { + const res = await checkBitBucketValidity( + username.value, + password.value, + customDomain.value + ); + console.log(res.headers) + const scopeHeader = res.headers["X-Oauth-Scopes"] || res.headers["x-oauth-scopes"]; + console.log(scopeHeader) + const scopes = scopeHeader.split(',').map((s: string) => s.trim()); + console.log(scopes) + const missing = getMissingBitBucketScopes(scopes); + + if (missing.length) { + throw new Error(`App Password is missing scopes: ${missing.join(', ')}`); + } + + await linkProvider(username.value, orgId, Integration.BITBUCKET, { + password: password.value, + custom_domain: customDomain.value || undefined + }); + + dispatch(fetchCurrentOrg()); + dispatch(fetchTeams({ org_id: orgId })); + enqueueSnackbar('Bitbucket linked successfully', { + variant: 'success', + autoHideDuration: 2000 + }); + onClose(); + } catch (err: any) { + console.error('Error linking Bitbucket:', err); + const msg = err.message || 'Failed to link Bitbucket'; + if (msg.includes('Username')) setUsernameError(msg); + else if (msg.includes('Password') || msg.includes('scopes')) setPasswordError(msg); + else setDomainError(msg); + } finally { + depFn(isLoading.false); + } + }, [ + username.value, + password.value, + customDomain.value, + dispatch, + enqueueSnackbar, + orgId, + onClose, + setUsernameError, + setPasswordError, + setDomainError, + isLoading + ]); + + const isDomainFocus = useBoolState(false); + + return ( + + + + handleUsernameChange(e.currentTarget.value)} + onKeyDown={(e: { key: string; preventDefault: () => void; }) => { + if (e.key === 'Enter') { + e.preventDefault(); + document.getElementById('bitbucket-password')?.focus(); + } + }} + fullWidth + /> + handlePasswordChange(e.currentTarget.value)} + onKeyDown={(e: { key: string; preventDefault: () => void; }) => { + if (e.key === 'Enter') { + e.preventDefault(); + document.getElementById('bitbucket-custom-domain')?.focus(); + } + }} + fullWidth + /> + handleDomainChange(e.currentTarget.value)} + onFocus={isDomainFocus.true} + onBlur={isDomainFocus.false} + onKeyDown={(e: { key: string; preventDefault: () => void; }) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSubmission(); + } + }} + fullWidth + /> + + + + + Generate an App Password{' '} + + here + + + + + Link Bitbucket + + + + + + + ); +}; + +const TokenPermissions = () => { + const imageLoaded = useBoolState(false); + const expandedStyles = useMemo(() => { + const base = { + border: `2px solid ${alpha('#2684FF', 0.6)}`, + transition: 'all 0.8s ease', + borderRadius: '8px', + opacity: 1, + width: '126px', + position: 'absolute', + maxWidth: 'calc(100% - 48px)', + left: '12px' + }; + return [ + { top: '300px', height: '32px' }, + { top: '360px', height: '32px' }, + { top: '420px', height: '32px' } + ].map(cfg => ({ ...cfg, ...base })); + }, []); + + return ( + +
+ App Password permissions + + {imageLoaded.value && expandedStyles.map((style: any, i: any) => )} + + {!imageLoaded.value && ( + + Loading... + + )} +
+ + Scroll to see all required permissions + +
+ ); +}; diff --git a/web-server/src/content/Dashboards/githubIntegration.tsx b/web-server/src/content/Dashboards/githubIntegration.tsx index 4a6cad422..90b95e54e 100644 --- a/web-server/src/content/Dashboards/githubIntegration.tsx +++ b/web-server/src/content/Dashboards/githubIntegration.tsx @@ -2,6 +2,7 @@ import faker from '@faker-js/faker'; import { GitHub } from '@mui/icons-material'; import GitlabIcon from '@/mocks/icons/gitlab.svg'; +import BitbucketIcon from '@/mocks/icons/bitbucket.svg' export const githubIntegrationsDisplay = { id: faker.datatype.uuid(), @@ -23,4 +24,14 @@ export const gitLabIntegrationDisplay = { icon: } as IntegrationItem; +export const bitBucketIntegrationDisplay = { + id: faker.datatype.uuid(), + type: 'bitbucket', + name: 'BitBucket', + description: 'Code insights & blockers', + color: '#fff', + bg: `linear-gradient(-45deg, rgba(69, 110, 232, 0.6) 0%, rgba(24, 176, 236, 0.6) 100%);`, + icon: + +} export type IntegrationItem = typeof githubIntegrationsDisplay; diff --git a/web-server/src/content/Dashboards/useIntegrationHandlers.tsx b/web-server/src/content/Dashboards/useIntegrationHandlers.tsx index 0ded2063c..e24db0126 100644 --- a/web-server/src/content/Dashboards/useIntegrationHandlers.tsx +++ b/web-server/src/content/Dashboards/useIntegrationHandlers.tsx @@ -7,6 +7,7 @@ import { useAuth } from '@/hooks/useAuth'; import { unlinkProvider } from '@/utils/auth'; import { ConfigureGithubModalBody } from './ConfigureGithubModalBody'; +import { ConfigureBitbucketModalBody } from './ConfigureBitbucketModalBody'; export const useIntegrationHandlers = () => { const { orgId } = useAuth(); @@ -27,11 +28,18 @@ export const useIntegrationHandlers = () => { title: 'Configure Gitlab', body: , showCloseIcon: true - }) + }), + bitbucket: () => + addModal({ + title: 'Configure Gitlab', + body: , + showCloseIcon: true + }) }, unlink: { github: () => unlinkProvider(orgId, Integration.GITHUB), - gitlab: () => unlinkProvider(orgId, Integration.GITLAB) + gitlab: () => unlinkProvider(orgId, Integration.GITLAB), + bitbucket: () => unlinkProvider(orgId,Integration.BITBUCKET) } }; diff --git a/web-server/src/utils/auth.ts b/web-server/src/utils/auth.ts index 22852b373..c28059fce 100644 --- a/web-server/src/utils/auth.ts +++ b/web-server/src/utils/auth.ts @@ -99,3 +99,38 @@ export const getMissingGitLabScopes = (scopes: string[]): string[] => { ); return missingScopes; }; + +//BitBucket Functions +export const checkBitBucketValidity = async ( + username: string, + password: string, + customDomain?: string +) => { + try { + const response = await axios.post( + "/api/integrations/bitbucket/scopes", + { + username, + appPassword: password, + customDomain + }, + { + headers: { + 'Content-Type': 'application/json' + } + } + ); + return response.data; + } catch (error) { + throw new Error('Invalid BitBucket credentials', { cause: error }); + } +}; + +const BITBUCKET_SCOPES = ['issue', 'pullrequest', 'project', 'account']; + +export const getMissingBitBucketScopes = (scopes: string[]): string[] => { + const missingScopes = BITBUCKET_SCOPES.filter( + (scope) => !scopes.includes(scope) + ); + return missingScopes; +}; \ No newline at end of file From bb5747c465473e08e09c1dd4ff1242b9a369bc19 Mon Sep 17 00:00:00 2001 From: Ayyan Shaikh Date: Sat, 31 May 2025 21:00:35 +0530 Subject: [PATCH 2/5] feat(bitbucket): enhance Bitbucket integration with improved validation and error handling --- .../api/integrations/bitbucket/scopes.ts | 77 ++++-- web-server/src/api-helpers/axios.ts | 5 +- .../Dashboards/BitbucketIntegrationCard.tsx | 3 +- .../ConfigureBitbucketModalBody.tsx | 236 ++++++++++-------- .../content/Dashboards/githubIntegration.tsx | 4 +- .../Dashboards/useIntegrationHandlers.tsx | 16 +- web-server/src/utils/auth.ts | 73 ++++-- 7 files changed, 263 insertions(+), 151 deletions(-) diff --git a/web-server/pages/api/integrations/bitbucket/scopes.ts b/web-server/pages/api/integrations/bitbucket/scopes.ts index 414cac7f8..0a9750741 100644 --- a/web-server/pages/api/integrations/bitbucket/scopes.ts +++ b/web-server/pages/api/integrations/bitbucket/scopes.ts @@ -3,32 +3,81 @@ import { handleRequest } from '@/api-helpers/axios'; import * as yup from 'yup'; const payloadSchema = yup.object({ - username: yup.string().required('Username is required'), - appPassword: yup.string().required('App password is required'), - customDomain: yup.string().url('Custom domain must be a valid URL'), + username: yup + .string() + .required('Username is required') + .trim() + .min(1, 'Username cannot be empty') + .max(100, 'Username too long'), + appPassword: yup + .string() + .required('App password is required') + .min(1, 'App password cannot be empty') + .max(500, 'App password too long') }); const endpoint = new Endpoint(nullSchema); endpoint.handle.POST(payloadSchema, async (req, res) => { try { - const { username, appPassword, customDomain } = req.payload; - const baseUrl = customDomain || 'https://api.bitbucket.org/2.0'; - const url = `${baseUrl}/user`; + const { username, appPassword } = req.payload; + const sanitizedUsername = username.replace(/[^\w.-]/g, ''); + + if (sanitizedUsername !== username) { + return res.status(400).json({ + message: 'Invalid username format. Only alphanumeric characters, dots, and hyphens are allowed.' + }); + } + + const url = 'https://api.bitbucket.org/2.0/user'; + const response = await handleRequest(url, { method: 'GET', - auth: { - username, - password: appPassword, + headers: { + Authorization: `Basic ${Buffer.from(`${sanitizedUsername}:${appPassword}`).toString('base64')}`, + 'User-Agent': 'MiddlewareApp/1.0' }, - },true); + timeout: 10000 + }, true); - res.status(200).json(response); + if (!response.headers) { + return res.status(400).json({ + message: 'Unable to retrieve permission information from BitBucket' + }); + } + + res.status(200).json({ + ...response, + headers: response.headers + }); } catch (error: any) { - console.error('Error fetching Bitbucket user:', error.message); - res.status(error.response?.status || 500).json({ - message: error.response?.data?.error?.message || 'Internal Server Error', + console.error('Error fetching Bitbucket user:', { + message: error.message, + status: error.response?.status, + hasCredentials: !!(req.payload?.username && req.payload?.appPassword) }); + + const status = error.response?.status || 500; + let message = 'Internal Server Error'; + + switch (status) { + case 401: + message = 'Invalid BitBucket credentials'; + break; + case 403: + message = 'Access forbidden. Check your App Password permissions'; + break; + case 404: + message = 'BitBucket user not found'; + break; + case 429: + message = 'Rate limit exceeded. Please try again later'; + break; + default: + message = error.response?.data?.error?.message || message; + } + + res.status(status).json({ message }); } }); diff --git a/web-server/src/api-helpers/axios.ts b/web-server/src/api-helpers/axios.ts index c5dcab560..7836dcace 100644 --- a/web-server/src/api-helpers/axios.ts +++ b/web-server/src/api-helpers/axios.ts @@ -91,7 +91,10 @@ export const handleRequest = ( internal({ url, ...params, - headers: { 'Content-Type': 'application/json' } + headers: { + 'Content-Type': 'application/json', + ...params.headers + } }) .then((r: any) => handleThen(r, includeHeaders)) .catch(handleCatch); diff --git a/web-server/src/content/Dashboards/BitbucketIntegrationCard.tsx b/web-server/src/content/Dashboards/BitbucketIntegrationCard.tsx index fe5e5a4eb..798579adf 100644 --- a/web-server/src/content/Dashboards/BitbucketIntegrationCard.tsx +++ b/web-server/src/content/Dashboards/BitbucketIntegrationCard.tsx @@ -147,7 +147,7 @@ import { }; const IntegrationActionsButton: FC<{ - onClick: AnyFunction; + onClick: () => void | Promise; label: ReactNode; bgOpacity?: number; startIcon?: ReactNode; @@ -245,4 +245,3 @@ import { ); }; - \ No newline at end of file diff --git a/web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx b/web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx index 3edd4fb58..aad67a580 100644 --- a/web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx +++ b/web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx @@ -19,10 +19,18 @@ import { } from '@/utils/auth'; import { depFn } from '@/utils/fn'; -export const ConfigureBitbucketModalBody: FC<{ onClose: () => void }> = ({ onClose }) => { +interface ConfigureBitbucketModalBodyProps { + onClose: () => void; +} + +interface FormErrors { + username: string; + password: string; +} + +export const ConfigureBitbucketModalBody: FC = ({ onClose }) => { const username = useEasyState(''); const password = useEasyState(''); - const customDomain = useEasyState(''); const { orgId } = useAuth(); const { enqueueSnackbar } = useSnackbar(); const dispatch = useDispatch(); @@ -30,7 +38,6 @@ export const ConfigureBitbucketModalBody: FC<{ onClose: () => void }> = ({ onClo const showUsernameError = useEasyState(''); const showPasswordError = useEasyState(''); - const showDomainError = useEasyState(''); const setUsernameError = useCallback( (err: string) => depFn(showUsernameError.set, err), @@ -40,102 +47,129 @@ export const ConfigureBitbucketModalBody: FC<{ onClose: () => void }> = ({ onClo (err: string) => depFn(showPasswordError.set, err), [showPasswordError.set] ); - const setDomainError = useCallback( - (err: string) => depFn(showDomainError.set, err), - [showDomainError.set] - ); - const checkDomainWithRegex = (domain: string) => { - const regex = /^(https?:\/\/)[\w\-]+(\.[\w\-]+)+(\:\d{1,5})?(\/.*)?$/; - return regex.test(domain); - }; + const clearErrors = useCallback(() => { + showUsernameError.set(''); + showPasswordError.set(''); + }, [showUsernameError.set, showPasswordError.set]); - const handleUsernameChange = (val: string) => { + const validateForm = useCallback((): FormErrors => { + const errors: FormErrors = { username: '', password: '' }; + + if (!username.value.trim()) { + errors.username = 'Please enter your Bitbucket username'; + } + + if (!password.value.trim()) { + errors.password = 'Please enter your App Password'; + } + + return errors; + }, [username.value, password.value]); + + const handleUsernameChange = useCallback((val: string) => { username.set(val); - showUsernameError.set(''); - }; - const handlePasswordChange = (val: string) => { + if (showUsernameError.value) { + showUsernameError.set(''); + } + }, [username.set, showUsernameError.value, showUsernameError.set]); + + const handlePasswordChange = useCallback((val: string) => { password.set(val); - showPasswordError.set(''); - }; - const handleDomainChange = (val: string) => { - customDomain.set(val); - showDomainError.set(''); - }; + if (showPasswordError.value) { + showPasswordError.set(''); + } + }, [password.set, showPasswordError.value, showPasswordError.set]); const handleSubmission = useCallback(async () => { - try { - if (!username.value) { - setUsernameError('Please enter your Bitbucket username'); - throw new Error('Empty Username'); - } - if (!password.value) { - setPasswordError('Please enter your App Password'); - throw new Error('Empty Password'); - } - if (customDomain.value && !checkDomainWithRegex(customDomain.value)) { - setDomainError('Please enter a valid domain URL'); - throw new Error('Invalid Domain'); - } - } catch (err) { - console.error(err); + clearErrors(); + + const errors = validateForm(); + if (errors.username || errors.password) { + if (errors.username) setUsernameError(errors.username); + if (errors.password) setPasswordError(errors.password); return; } depFn(isLoading.true); + try { - const res = await checkBitBucketValidity( - username.value, - password.value, - customDomain.value - ); - console.log(res.headers) - const scopeHeader = res.headers["X-Oauth-Scopes"] || res.headers["x-oauth-scopes"]; - console.log(scopeHeader) - const scopes = scopeHeader.split(',').map((s: string) => s.trim()); - console.log(scopes) + const res = await checkBitBucketValidity(username.value.trim(), password.value); + + const scopeHeader = res.headers?.["X-Oauth-Scopes"] || res.headers?.["x-oauth-scopes"]; + + if (!scopeHeader) { + throw new Error('Unable to verify App Password permissions. Please ensure your App Password has the required scopes.'); + } + + const scopes = scopeHeader.split(',').map((s: string) => s.trim()).filter(Boolean); const missing = getMissingBitBucketScopes(scopes); - if (missing.length) { - throw new Error(`App Password is missing scopes: ${missing.join(', ')}`); + if (missing.length > 0) { + throw new Error(`App Password is missing required scopes: ${missing.join(', ')}. Please regenerate with all required permissions.`); } - await linkProvider(username.value, orgId, Integration.BITBUCKET, { - password: password.value, - custom_domain: customDomain.value || undefined + const encodedCredentials = btoa(`${username.value.trim()}:${password.value}`); + await linkProvider(encodedCredentials, orgId, Integration.BITBUCKET, { + username: username.value.trim() }); - dispatch(fetchCurrentOrg()); - dispatch(fetchTeams({ org_id: orgId })); + await Promise.all([ + dispatch(fetchCurrentOrg()), + dispatch(fetchTeams({ org_id: orgId })) + ]); + enqueueSnackbar('Bitbucket linked successfully', { variant: 'success', - autoHideDuration: 2000 + autoHideDuration: 3000 }); + onClose(); } catch (err: any) { console.error('Error linking Bitbucket:', err); - const msg = err.message || 'Failed to link Bitbucket'; - if (msg.includes('Username')) setUsernameError(msg); - else if (msg.includes('Password') || msg.includes('scopes')) setPasswordError(msg); - else setDomainError(msg); + + const errorMessage = err.message || 'Failed to link Bitbucket. Please try again.'; + + // Categorize errors for better UX + if (errorMessage.toLowerCase().includes('username') || + errorMessage.toLowerCase().includes('user not found')) { + setUsernameError(errorMessage); + } else if (errorMessage.toLowerCase().includes('password') || + errorMessage.toLowerCase().includes('unauthorized') || + errorMessage.toLowerCase().includes('authentication')) { + setPasswordError('Invalid App Password. Please check your credentials.'); + } else if (errorMessage.toLowerCase().includes('scope')) { + setPasswordError(errorMessage); + } else { + setPasswordError(errorMessage); + } } finally { depFn(isLoading.false); } }, [ + clearErrors, + validateForm, username.value, password.value, - customDomain.value, - dispatch, - enqueueSnackbar, - orgId, - onClose, + isLoading, setUsernameError, setPasswordError, - setDomainError, - isLoading + orgId, + dispatch, + enqueueSnackbar, + onClose ]); - const isDomainFocus = useBoolState(false); + const handleKeyDown = useCallback((e: React.KeyboardEvent, action: 'focus-password' | 'submit') => { + if (e.key === 'Enter') { + e.preventDefault(); + if (action === 'focus-password') { + document.getElementById('bitbucket-password')?.focus(); + } else { + handleSubmission(); + } + } + }, [handleSubmission]); return ( @@ -147,14 +181,11 @@ export const ConfigureBitbucketModalBody: FC<{ onClose: () => void }> = ({ onClo error={!!showUsernameError.value} helperText={showUsernameError.value} value={username.value} - onChange={(e: { currentTarget: { value: string; }; }) => handleUsernameChange(e.currentTarget.value)} - onKeyDown={(e: { key: string; preventDefault: () => void; }) => { - if (e.key === 'Enter') { - e.preventDefault(); - document.getElementById('bitbucket-password')?.focus(); - } - }} + onChange={(e) => handleUsernameChange(e.currentTarget.value)} + onKeyDown={(e) => handleKeyDown(e, 'focus-password')} + disabled={isLoading.value} fullWidth + autoComplete="username" /> void }> = ({ onClo error={!!showPasswordError.value} helperText={showPasswordError.value} value={password.value} - onChange={(e: { currentTarget: { value: string; }; }) => handlePasswordChange(e.currentTarget.value)} - onKeyDown={(e: { key: string; preventDefault: () => void; }) => { - if (e.key === 'Enter') { - e.preventDefault(); - document.getElementById('bitbucket-custom-domain')?.focus(); - } - }} - fullWidth - /> - handleDomainChange(e.currentTarget.value)} - onFocus={isDomainFocus.true} - onBlur={isDomainFocus.false} - onKeyDown={(e: { key: string; preventDefault: () => void; }) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSubmission(); - } - }} + onChange={(e) => handlePasswordChange(e.currentTarget.value)} + onKeyDown={(e) => handleKeyDown(e, 'submit')} + disabled={isLoading.value} fullWidth + autoComplete="current-password" /> @@ -200,6 +209,7 @@ export const ConfigureBitbucketModalBody: FC<{ onClose: () => void }> = ({ onClo href="https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/" target="_blank" rel="noopener noreferrer" + aria-label="Learn how to generate a Bitbucket App Password" > here @@ -209,6 +219,7 @@ export const ConfigureBitbucketModalBody: FC<{ onClose: () => void }> = ({ onClo loading={isLoading.value} variant="contained" onClick={handleSubmission} + disabled={!username.value.trim() || !password.value.trim()} > Link Bitbucket @@ -220,8 +231,9 @@ export const ConfigureBitbucketModalBody: FC<{ onClose: () => void }> = ({ onClo ); }; -const TokenPermissions = () => { +const TokenPermissions: FC = () => { const imageLoaded = useBoolState(false); + const expandedStyles = useMemo(() => { const base = { border: `2px solid ${alpha('#2684FF', 0.6)}`, @@ -229,15 +241,18 @@ const TokenPermissions = () => { borderRadius: '8px', opacity: 1, width: '126px', - position: 'absolute', + position: 'absolute' as const, maxWidth: 'calc(100% - 48px)', left: '12px' }; - return [ + + const positions = [ { top: '300px', height: '32px' }, { top: '360px', height: '32px' }, { top: '420px', height: '32px' } - ].map(cfg => ({ ...cfg, ...base })); + ]; + + return positions.map(cfg => ({ ...cfg, ...base })); }, []); return ( @@ -257,12 +272,19 @@ const TokenPermissions = () => { src="/assets/bitbucketPAT.png" width={816} height={976} - alt="App Password permissions" + alt="Bitbucket App Password required permissions setup" onLoadingComplete={imageLoaded.true} - style={{ opacity: imageLoaded.value ? 1 : 0, transition: 'opacity 0.8s ease', filter: 'invert(1)' }} + style={{ + opacity: imageLoaded.value ? 1 : 0, + transition: 'opacity 0.8s ease', + filter: 'invert(1)' + }} + priority /> - {imageLoaded.value && expandedStyles.map((style: any, i: any) => )} + {imageLoaded.value && expandedStyles.map((style, index) => ( + + ))} {!imageLoaded.value && ( { transform: 'translate(-50%, -50%)' }} > - Loading... + Loading permissions guide... )} diff --git a/web-server/src/content/Dashboards/githubIntegration.tsx b/web-server/src/content/Dashboards/githubIntegration.tsx index 90b95e54e..e5c584a20 100644 --- a/web-server/src/content/Dashboards/githubIntegration.tsx +++ b/web-server/src/content/Dashboards/githubIntegration.tsx @@ -30,8 +30,8 @@ export const bitBucketIntegrationDisplay = { name: 'BitBucket', description: 'Code insights & blockers', color: '#fff', - bg: `linear-gradient(-45deg, rgba(69, 110, 232, 0.6) 0%, rgba(24, 176, 236, 0.6) 100%);`, + bg: `linear-gradient(-45deg, rgba(69, 110, 232, 0.6) 0%, rgba(24, 176, 236, 0.6) 100%)`, icon: +} as IntegrationItem; -} export type IntegrationItem = typeof githubIntegrationsDisplay; diff --git a/web-server/src/content/Dashboards/useIntegrationHandlers.tsx b/web-server/src/content/Dashboards/useIntegrationHandlers.tsx index e24db0126..e5f52e46a 100644 --- a/web-server/src/content/Dashboards/useIntegrationHandlers.tsx +++ b/web-server/src/content/Dashboards/useIntegrationHandlers.tsx @@ -29,17 +29,17 @@ export const useIntegrationHandlers = () => { body: , showCloseIcon: true }), - bitbucket: () => - addModal({ - title: 'Configure Gitlab', - body: , - showCloseIcon: true - }) + bitbucket: () => + addModal({ + title: 'Configure Bitbucket', + body: , + showCloseIcon: true + }) }, unlink: { github: () => unlinkProvider(orgId, Integration.GITHUB), - gitlab: () => unlinkProvider(orgId, Integration.GITLAB), - bitbucket: () => unlinkProvider(orgId,Integration.BITBUCKET) + gitlab: () => unlinkProvider(orgId, Integration.GITLAB), + bitbucket: () => unlinkProvider(orgId, Integration.BITBUCKET) } }; diff --git a/web-server/src/utils/auth.ts b/web-server/src/utils/auth.ts index c28059fce..4ec15a662 100644 --- a/web-server/src/utils/auth.ts +++ b/web-server/src/utils/auth.ts @@ -100,37 +100,76 @@ export const getMissingGitLabScopes = (scopes: string[]): string[] => { return missingScopes; }; -//BitBucket Functions +// BitBucket Functions +interface BitBucketValidationResponse { + headers: Record; + data?: any; +} + +interface BitBucketCredentials { + username: string; + appPassword: string; +} + export const checkBitBucketValidity = async ( username: string, - password: string, - customDomain?: string -) => { + password: string +): Promise => { + if (!username?.trim() || !password?.trim()) { + throw new Error('Username and App Password are required'); + } + try { - const response = await axios.post( + const response = await axios.post( "/api/integrations/bitbucket/scopes", { - username, - appPassword: password, - customDomain - }, + username: username.trim(), + appPassword: password + } as BitBucketCredentials, { headers: { 'Content-Type': 'application/json' - } + }, + timeout: 10000 } ); return response.data; - } catch (error) { - throw new Error('Invalid BitBucket credentials', { cause: error }); + } catch (error: any) { + if (error.code === 'ECONNABORTED') { + throw new Error('Request timeout. Please check your internet connection and try again.'); + } + + if (error.response?.status === 401) { + throw new Error('Invalid username or App Password. Please verify your credentials.'); + } + + if (error.response?.status === 403) { + throw new Error('Access forbidden. Please ensure your App Password has the required permissions.'); + } + + if (error.response?.status >= 500) { + throw new Error('BitBucket service is currently unavailable. Please try again later.'); + } + + const message = error.response?.data?.message || + error.message || + 'Unable to validate BitBucket credentials. Please try again.'; + throw new Error(message); } }; -const BITBUCKET_SCOPES = ['issue', 'pullrequest', 'project', 'account']; +const BITBUCKET_SCOPES = ['issue', 'pullrequest', 'project', 'account'] as const; -export const getMissingBitBucketScopes = (scopes: string[]): string[] => { - const missingScopes = BITBUCKET_SCOPES.filter( - (scope) => !scopes.includes(scope) +export const getMissingBitBucketScopes = (userScopes: string[]): string[] => { + if (!Array.isArray(userScopes)) { + return [...BITBUCKET_SCOPES]; + } + + const normalizedUserScopes = userScopes + .map(scope => scope.trim().toLowerCase()) + .filter(Boolean); + + return BITBUCKET_SCOPES.filter( + requiredScope => !normalizedUserScopes.includes(requiredScope.toLowerCase()) ); - return missingScopes; }; \ No newline at end of file From 26f4adc97355b4adca521ed42697224ff6787b36 Mon Sep 17 00:00:00 2001 From: Ayyan Shaikh Date: Sun, 1 Jun 2025 19:13:59 +0530 Subject: [PATCH 3/5] feat(bitbucket): integrate Bitbucket support with repository search and team management --- .../analytics_server/mhq/api/request_utils.py | 33 ++-- .../mhq/store/models/code/enums.py | 1 + .../api/internal/[org_id]/git_provider_org.ts | 49 +++++- .../pages/api/internal/[org_id]/utils.ts | 150 ++++++++++++++++-- .../api/resources/orgs/[org_id]/teams/v2.ts | 7 +- .../src/components/Teams/CreateTeams.tsx | 5 + 6 files changed, 217 insertions(+), 28 deletions(-) diff --git a/backend/analytics_server/mhq/api/request_utils.py b/backend/analytics_server/mhq/api/request_utils.py index 0ffc819a3..f7fddf8ca 100644 --- a/backend/analytics_server/mhq/api/request_utils.py +++ b/backend/analytics_server/mhq/api/request_utils.py @@ -7,6 +7,7 @@ from stringcase import snakecase from voluptuous import Invalid from werkzeug.exceptions import BadRequest +from mhq.utils.log import LOG from mhq.store.models.code.repository import TeamRepos from mhq.service.code.models.org_repo import RawTeamOrgRepo from mhq.store.models.code import WorkflowFilter, CodeProvider @@ -82,20 +83,24 @@ def coerce_workflow_filter(filter_data: str) -> WorkflowFilter: def coerce_org_repo(repo: Dict[str, str]) -> RawTeamOrgRepo: - return RawTeamOrgRepo( - team_id=repo.get("team_id"), - provider=CodeProvider(repo.get("provider")), - name=repo.get("name"), - org_name=repo.get("org"), - slug=repo.get("slug"), - idempotency_key=repo.get("idempotency_key"), - default_branch=repo.get("default_branch"), - deployment_type=( - TeamReposDeploymentType(repo.get("deployment_type")) - if repo.get("deployment_type") - else TeamReposDeploymentType.PR_MERGE - ), - ) + try: + return RawTeamOrgRepo( + team_id=repo.get("team_id"), + provider=CodeProvider(repo.get("provider")), + name=repo.get("name"), + org_name=repo.get("org"), + slug=repo.get("slug"), + idempotency_key=repo.get("idempotency_key"), + default_branch=repo.get("default_branch"), + deployment_type=( + TeamReposDeploymentType(repo.get("deployment_type")) + if repo.get("deployment_type") + else TeamReposDeploymentType.PR_MERGE + ), + ) + except Exception as e: + LOG.error(f"Error creating RawTeamOrgRepo with data: {repo}. Error: {str(e)}") + raise def coerce_org_repos(repos: List[Dict[str, str]]) -> List[RawTeamOrgRepo]: diff --git a/backend/analytics_server/mhq/store/models/code/enums.py b/backend/analytics_server/mhq/store/models/code/enums.py index 032854c91..125b1780e 100644 --- a/backend/analytics_server/mhq/store/models/code/enums.py +++ b/backend/analytics_server/mhq/store/models/code/enums.py @@ -4,6 +4,7 @@ class CodeProvider(Enum): GITHUB = "github" GITLAB = "gitlab" + BITBUCKET = "bitbucket" class CodeBookmarkType(Enum): diff --git a/web-server/pages/api/internal/[org_id]/git_provider_org.ts b/web-server/pages/api/internal/[org_id]/git_provider_org.ts index 5ee9c0af6..c7dcc9b47 100644 --- a/web-server/pages/api/internal/[org_id]/git_provider_org.ts +++ b/web-server/pages/api/internal/[org_id]/git_provider_org.ts @@ -1,6 +1,12 @@ import * as yup from 'yup'; -import { gitlabSearch, searchGithubRepos, getGithubToken, getGitlabToken } from '@/api/internal/[org_id]/utils'; +import { + gitlabSearch, + searchGithubRepos, + bitbucketSearch, + getGithubToken, + getGitlabToken +} from '@/api/internal/[org_id]/utils'; import { Endpoint } from '@/api-helpers/global'; import { Integration } from '@/constants/integrations'; @@ -42,6 +48,42 @@ endpoint.handle.GET(getSchema, async (req, res) => { export default endpoint.serve(); +const getGithubToken = async (org_id: ID) => { + return await db('Integration') + .select() + .where({ + org_id, + name: Integration.GITHUB + }) + .returning('*') + .then(getFirstRow) + .then((r) => dec(r.access_token_enc_chunks)); +}; + +const getGitlabToken = async (org_id: ID) => { + return await db('Integration') + .select() + .where({ + org_id, + name: Integration.GITLAB + }) + .returning('*') + .then(getFirstRow) + .then((r) => dec(r.access_token_enc_chunks)); +}; + +const getBitbucketToken = async (org_id: ID) => { + return await db('Integration') + .select() + .where({ + org_id, + name: Integration.BITBUCKET + }) + .returning('*') + .then(getFirstRow) + .then((r) => dec(r.access_token_enc_chunks)); +}; + const fetchMap = [ { provider: Integration.GITHUB, @@ -52,5 +94,10 @@ const fetchMap = [ provider: Integration.GITLAB, search: gitlabSearch, getToken: getGitlabToken + }, + { + provider: Integration.BITBUCKET, + search: bitbucketSearch, + getToken: getBitbucketToken } ]; diff --git a/web-server/pages/api/internal/[org_id]/utils.ts b/web-server/pages/api/internal/[org_id]/utils.ts index e80975b54..e8d12691e 100644 --- a/web-server/pages/api/internal/[org_id]/utils.ts +++ b/web-server/pages/api/internal/[org_id]/utils.ts @@ -3,10 +3,10 @@ import { head } from 'ramda'; import { Row } from '@/constants/db'; import { Integration } from '@/constants/integrations'; -import { BaseRepo } from '@/types/resources'; -import { db, getFirstRow } from '@/utils/db'; import { DEFAULT_GH_URL } from '@/constants/urls'; +import { BaseRepo } from '@/types/resources'; import { dec } from '@/utils/auth-supplementary'; +import { db, getFirstRow } from '@/utils/db'; type GithubRepo = { name: string; @@ -271,6 +271,134 @@ export const gitlabSearch = async (pat: string, searchString: string) => { return searchGitlabRepos(pat, search); }; +// Bitbucket functions + +type BitbucketRepo = { + uuid: string; + name: string; + full_name: string; + description?: string; + language?: string; + mainbranch?: { + name: string; + }; + links: { + html: { + href: string; + }; + }; + owner: { + username: string; + }; +}; + +type BitbucketResponse = { + values: BitbucketRepo[]; + next?: string; +}; + +const BITBUCKET_API_URL = 'https://api.bitbucket.org/2.0'; + +export const searchBitbucketRepos = async ( + credentials: string, + searchString: string +): Promise => { + let urlString = convertUrlToQuery(searchString); + if (urlString !== searchString && urlString.includes('/')) { + try { + return await searchBitbucketRepoWithURL(credentials, urlString); + } catch (e) { + return await searchBitbucketReposWithNames(credentials, urlString); + } + } + return await searchBitbucketReposWithNames(credentials, urlString); +}; + +const searchBitbucketRepoWithURL = async ( + credentials: string, + searchString: string +): Promise => { + const apiUrl = `${BITBUCKET_API_URL}/repositories/${searchString}`; + + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Bitbucket API error: ${response.statusText}`); + } + + const repo = (await response.json()) as BitbucketRepo; + + return [ + { + id: repo.uuid.replace(/[{}]/g, ''), + name: repo.name, + desc: repo.description, + slug: repo.name, + parent: repo.owner.username, + web_url: repo.links.html.href, + branch: repo.mainbranch?.name, + language: repo.language, + provider: Integration.BITBUCKET + } + ] as BaseRepo[]; +}; + +const searchBitbucketReposWithNames = async ( + credentials: string, + searchString: string +): Promise => { + const apiUrl = `${BITBUCKET_API_URL}/repositories`; + const params = new URLSearchParams({ + q: `name~"${searchString}"`, + role: 'member', + pagelen: '50' + }); + + const response = await fetch(`${apiUrl}?${params}`, { + method: 'GET', + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Bitbucket API error: ${response.statusText}`); + } + + const responseBody = (await response.json()) as BitbucketResponse; + const repositories = responseBody.values || []; + + return repositories.map( + (repo) => + ({ + id: repo.uuid.replace(/[{}]/g, ''), + name: repo.name, + desc: repo.description, + slug: repo.name, + parent: repo.owner.username, + web_url: repo.links.html.href, + branch: repo.mainbranch?.name, + language: repo.language || null, + provider: Integration.BITBUCKET + }) as BaseRepo + ); +}; + +export const bitbucketSearch = async ( + credentials: string, + searchString: string +): Promise => { + let search = convertUrlToQuery(searchString); + return searchBitbucketRepos(credentials, search); +}; + const convertUrlToQuery = (url: string) => { let query = url; try { @@ -282,6 +410,7 @@ const convertUrlToQuery = (url: string) => { query = query.replace('http://', ''); query = query.replace('github.com/', ''); query = query.replace('gitlab.com/', ''); + query = query.replace('bitbucket.org/', ''); query = query.startsWith('www.') ? query.slice(4) : query; query = query.endsWith('/') ? query.slice(0, -1) : query; } @@ -315,26 +444,27 @@ export const getGitHubCustomDomain = async (): Promise => { return head(provider_meta || [])?.custom_domain || null; } catch (error) { - console.error('Error occured while getting custom domain from database:', error); + console.error( + 'Error occured while getting custom domain from database:', + error + ); return null; } }; -const normalizeSlashes = (url: string) => - url.replace(/(? url.replace(/(? { const customDomain = await getGitHubCustomDomain(); - const base = customDomain - ? `${customDomain}/api/v3` - : DEFAULT_GH_URL; + const base = customDomain ? `${customDomain}/api/v3` : DEFAULT_GH_URL; return normalizeSlashes(`${base}/${path}`); }; - export const getGitHubGraphQLUrl = async (): Promise => { const customDomain = await getGitHubCustomDomain(); - return customDomain ? `${customDomain}/api/graphql` : `${DEFAULT_GH_URL}/graphql`; + return customDomain + ? `${customDomain}/api/graphql` + : `${DEFAULT_GH_URL}/graphql`; }; export const getGithubToken = async (org_id: ID) => { diff --git a/web-server/pages/api/resources/orgs/[org_id]/teams/v2.ts b/web-server/pages/api/resources/orgs/[org_id]/teams/v2.ts index bf0db917e..feda6a4a1 100644 --- a/web-server/pages/api/resources/orgs/[org_id]/teams/v2.ts +++ b/web-server/pages/api/resources/orgs/[org_id]/teams/v2.ts @@ -91,7 +91,7 @@ endpoint.handle.GET(getSchema, async (req, res) => { org_id, providers?.length ? (providers as Integration[]) - : [Integration.GITHUB, Integration.GITLAB] + : [Integration.GITHUB, Integration.GITLAB,Integration.BITBUCKET] ); res.send({ @@ -116,6 +116,7 @@ endpoint.handle.POST(postSchema, async (req, res) => { } as any as ReqRepoWithProvider); }); }, org_repos); + console.log('orgReposList in POST', orgReposList); const [team, onboardingState] = await Promise.all([ createTeam(org_id, name, []), getOnBoardingState(org_id) @@ -165,7 +166,7 @@ endpoint.handle.PATCH(patchSchema, async (req, res) => { } as any as ReqRepoWithProvider); }); }, org_repos); - +console.log('orgReposList in Patch', orgReposList); const [team] = await Promise.all([ updateTeam(id, name, []), handleRequest<(Row<'TeamRepos'> & Row<'OrgRepo'>)[]>(`/teams/${id}/repos`, { @@ -301,7 +302,7 @@ const updateReposWorkflows = async ( .whereIn('name', reposForWorkflows) .where('org_id', org_id) .andWhere('is_active', true) - .and.whereIn('provider', [Integration.GITHUB, Integration.GITLAB]); + .and.whereIn('provider', [Integration.GITHUB, Integration.GITLAB, Integration.BITBUCKET]); const groupedRepos = groupBy(dbReposForWorkflows, 'name'); diff --git a/web-server/src/components/Teams/CreateTeams.tsx b/web-server/src/components/Teams/CreateTeams.tsx index 7f57d8a8c..3ca014ba5 100644 --- a/web-server/src/components/Teams/CreateTeams.tsx +++ b/web-server/src/components/Teams/CreateTeams.tsx @@ -34,6 +34,7 @@ import { DeploymentWorkflowSelector } from '@/components/WorkflowSelector'; import { Integration } from '@/constants/integrations'; import { useModal } from '@/contexts/ModalContext'; import { useBoolState, useEasyState } from '@/hooks/useEasyState'; +import BitbucketIcon from '@/mocks/icons/bitbucket.svg'; import GitlabIcon from '@/mocks/icons/gitlab.svg'; import { BaseRepo, DeploymentSources } from '@/types/resources'; import { trimWithEllipsis } from '@/utils/stringFormatting'; @@ -311,6 +312,8 @@ const TeamRepos: FC = () => { {option.provider === Integration.GITHUB ? ( + ) : option.provider === Integration.BITBUCKET ? ( + ) : ( )} @@ -447,6 +450,8 @@ const DisplayRepos: FC = () => { > {repo.provider === Integration.GITHUB ? ( + ) : repo.provider === Integration.BITBUCKET ? ( + ) : ( )} From c4a52474bc0ea7d07760ca41eef24d5992595d9d Mon Sep 17 00:00:00 2001 From: Ayyan Shaikh Date: Fri, 6 Jun 2025 15:22:43 +0530 Subject: [PATCH 4/5] feat(bitbucket): implement Bitbucket API service and ETL handler for repository management --- .../analytics_server/mhq/exapi/bitbucket.py | 126 ++++++++++++++ .../mhq/exapi/models/bitbucket.py | 32 ++++ .../mhq/service/code/integration.py | 1 + .../code/sync/etl_bitbucket_handler.py | 154 ++++++++++++++++++ .../mhq/service/code/sync/etl_code_factory.py | 5 + .../mhq/store/models/integrations/enums.py | 1 + 6 files changed, 319 insertions(+) create mode 100644 backend/analytics_server/mhq/exapi/bitbucket.py create mode 100644 backend/analytics_server/mhq/exapi/models/bitbucket.py create mode 100644 backend/analytics_server/mhq/service/code/sync/etl_bitbucket_handler.py diff --git a/backend/analytics_server/mhq/exapi/bitbucket.py b/backend/analytics_server/mhq/exapi/bitbucket.py new file mode 100644 index 000000000..e53025fda --- /dev/null +++ b/backend/analytics_server/mhq/exapi/bitbucket.py @@ -0,0 +1,126 @@ +import requests +from typing import Optional, Dict, Any + +from mhq.utils.log import LOG +from mhq.exapi.models.bitbucket import BitbucketRepo + +class BitbucketApiService: + def __init__(self, access_token: str): + self._token = access_token + self.base_url = "https://api.bitbucket.org/2.0" + self.headers = {"Authorization": f"Basic {self._token}"} + self.session = requests.Session() + self.session.headers.update(self.headers) + + def check_pat(self) -> bool: + """ + Checks if Personal Access Token is valid. + + Returns: + bool: True if PAT is valid, False otherwise + + Raises: + requests.RequestException: If the request fails + """ + url = f"{self.base_url}/user" + try: + response = self.session.get(url, timeout=30) + return response.status_code == 200 + except requests.RequestException as e: + LOG.error(f"PAT validation failed: {e}") + raise requests.RequestException(f"PAT validation failed: {e}") + + def _handle_error(self, response: requests.Response) -> None: + """ + Handle HTTP error responses from Bitbucket API. + + Args: + response: The HTTP response object + + Raises: + requests.HTTPError: If response status code is not 200 + """ + if response.status_code != 200: + try: + error_data = response.json() + error = error_data.get("error", "Unknown error") + message = error_data.get("message", "No message provided") + except ValueError: + error = "Invalid response format" + message = response.text or "No error details available" + + error_msg = f"Request failed with status {response.status_code}: {error} - {message}" + LOG.error(error_msg) + raise requests.HTTPError(error_msg) + + def get_workspace_repos(self, workspace: str, repo_slug: str) -> BitbucketRepo: + """ + Get repository information for a specific workspace and repository. + + Args: + workspace: The workspace name + repo_slug: The repository slug + + Returns: + BitbucketRepo: Repository information object + + Raises: + requests.HTTPError: If the request fails + requests.RequestException: If the request encounters an error + """ + url = f"{self.base_url}/repositories/{workspace}/{repo_slug}" + try: + response = self.session.get(url, timeout=30) + self._handle_error(response) + repo = response.json() + return BitbucketRepo(repo) + except requests.RequestException as e: + LOG.error(f"Failed to get repository {workspace}/{repo_slug}: {e}") + raise + + def get_repo_contributors(self, workspace: str, repo_slug: str) -> Dict[str,int]: + """ + Get all contributors for a repository with their contribution counts. + + Args: + workspace: The workspace name + repo_slug: The repository slug + + Returns: + dict: Dictionary with contributor names as keys and contribution counts as values + + Raises: + requests.HTTPError: If the request fails + requests.RequestException: If the request encounters an error + """ + url = f"{self.base_url}/repositories/{workspace}/{repo_slug}/commits" + contributors = {} + + try: + while url: + response = self.session.get(url, timeout=30) + self._handle_error(response) + + data = response.json() + commits = data.get('values', []) + + for commit in commits: + author = commit.get('author', {}) + user = author.get('user', {}) + display_name = user.get('display_name', 'Unknown') + + if display_name in contributors: + contributors[display_name] += 1 + else: + contributors[display_name] = 1 + + url = data.get('next') + + return contributors + + except requests.RequestException as e: + LOG.error(f"Failed to get contributors for {workspace}/{repo_slug}: {e}") + raise + + + diff --git a/backend/analytics_server/mhq/exapi/models/bitbucket.py b/backend/analytics_server/mhq/exapi/models/bitbucket.py new file mode 100644 index 000000000..825cf1723 --- /dev/null +++ b/backend/analytics_server/mhq/exapi/models/bitbucket.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Dict, List, Optional + +from mhq.utils.time import dt_from_iso_time_string + + +@dataclass +class BitbucketRepo: + name: str + org_name: str + default_branch: str + idempotency_key: str + slug: str + description: str + web_url: str + languages: Optional[Dict] = None + contributors: Optional[List] = None + + def __init__(self, repo: Dict): + self.name = repo.get("name", "") + self.org_name = repo.get("workspace", {}).get("name", "") + self.default_branch = repo.get("mainbranch", {}).get("name", "") + self.idempotency_key = str(repo.get("uuid", "")) + self.slug = repo.get("slug", "") + self.description = repo.get("description", "") + self.web_url = repo.get("links", {}).get("html", {}).get("href", "") + self.languages = repo.get("language") + + def __hash__(self): + return hash(self.idempotency_key) \ No newline at end of file diff --git a/backend/analytics_server/mhq/service/code/integration.py b/backend/analytics_server/mhq/service/code/integration.py index e79a1792c..b4085b659 100644 --- a/backend/analytics_server/mhq/service/code/integration.py +++ b/backend/analytics_server/mhq/service/code/integration.py @@ -6,6 +6,7 @@ CODE_INTEGRATION_BUCKET = [ UserIdentityProvider.GITHUB.value, UserIdentityProvider.GITLAB.value, + UserIdentityProvider.BITBUCKET.value ] diff --git a/backend/analytics_server/mhq/service/code/sync/etl_bitbucket_handler.py b/backend/analytics_server/mhq/service/code/sync/etl_bitbucket_handler.py new file mode 100644 index 000000000..ed927ba04 --- /dev/null +++ b/backend/analytics_server/mhq/service/code/sync/etl_bitbucket_handler.py @@ -0,0 +1,154 @@ +import uuid +from datetime import datetime +from typing import List, Dict, Optional, Tuple, Set + +import pytz + +from mhq.exapi.models.bitbucket import BitbucketRepo +from mhq.exapi.bitbucket import BitbucketApiService +from mhq.exapi.github import GithubApiService +from mhq.service.code.sync.etl_code_analytics import CodeETLAnalyticsService +from mhq.service.code.sync.etl_provider_handler import CodeProviderETLHandler +from mhq.store.models import UserIdentityProvider +from mhq.store.models.code import ( + OrgRepo, + PullRequestState, + PullRequest, + PullRequestCommit, + PullRequestEvent, + PullRequestEventType, + PullRequestRevertPRMapping, + CodeProvider, +) +from mhq.store.repos.code import CodeRepoService +from mhq.store.repos.core import CoreRepoService +from mhq.utils.log import LOG +from mhq.utils.time import time_now, ISO_8601_DATE_FORMAT + +PR_PROCESSING_CHUNK_SIZE = 100 + + +class BitbucketETLHandler(CodeProviderETLHandler): + """Handler for Bitbucket ETL operations.""" + + def __init__( + self, + org_id: str, + bitbucket_api_service: BitbucketApiService, + code_repo_service: CodeRepoService, + code_etl_analytics_service: CodeETLAnalyticsService, + # bitbucket_revert_pr_sync_handler: RevertPRsGitHubSyncHandler, + ): + self.org_id = org_id + self._api = bitbucket_api_service + self.code_repo_service = code_repo_service + self.code_etl_analytics_service : CodeETLAnalyticsService = ( + code_etl_analytics_service + ) + self.provider = CodeProvider.BITBUCKET.value + # self.bitbucket_revert_pr_sync_handler: RevertPRsGitHubSyncHandler = ( + # github_revert_pr_sync_handler + # ) + + def check_pat_validity(self) -> bool: + """Check if the Bitbucket Personal Access Token is valid. + + Returns: + bool: True if the PAT is valid. + + Raises: + Exception: If the Bitbucket credentials are invalid. + """ + is_valid = self._api.check_pat() + if not is_valid: + raise Exception("Bitbucket credentials are invalid. Please check username or password.") + return is_valid + + def get_org_repos(self, org_repos: List[OrgRepo]) -> List[OrgRepo]: + """Get organization repositories from Bitbucket API. + + Args: + org_repos: List of organization repositories to fetch. + + Returns: + List of processed OrgRepo objects. + """ + bitbucket_repos: List[BitbucketRepo] = [] + for org_repo in org_repos: + workspace = org_repo.org_name + repo_slug = org_repo.name + try: + bitbucket_repo = self._api.get_workspace_repos(workspace, repo_slug) + bitbucket_repos.append(bitbucket_repo) + except Exception as e: + LOG.error(f"Error getting Bitbucket repository {workspace}/{repo_slug}: {e}") + continue + repo_idempotency_key_org_repo_map = { + org_repo.idempotency_key: org_repo for org_repo in org_repos + } + + return [ + self._process_bitbucket_repo( + repo_idempotency_key_org_repo_map.get(str(bitbucket_repo.idempotency_key)), + bitbucket_repo + ) + for bitbucket_repo in bitbucket_repos + if repo_idempotency_key_org_repo_map.get(str(bitbucket_repo.idempotency_key)) + ] + + def _process_bitbucket_repo( + self, org_repo: OrgRepo, bitbucket_repo: BitbucketRepo + ) -> OrgRepo: + """Process a Bitbucket repository into an OrgRepo object. + + Args: + org_repo: Original organization repository. + bitbucket_repo: Bitbucket repository data. + + Returns: + Processed OrgRepo object. + """ + return OrgRepo( + id=org_repo.id, + org_id=self.org_id, + name=bitbucket_repo.name, + provider=self.provider, + org_name=bitbucket_repo.org_name, + default_branch=bitbucket_repo.default_branch, + language=bitbucket_repo.languages, + contributors=self._api.get_repo_contributors( + bitbucket_repo.org_name, bitbucket_repo.name + ), + idempotency_key=str(bitbucket_repo.idempotency_key), + slug=bitbucket_repo.name, + updated_at=time_now(), + ) + + + +def _get_access_token(org_id: str) -> Optional[str]: + """Retrieve access token for the given organization.""" + core_repo_service = CoreRepoService() + access_token = core_repo_service.get_access_token( + org_id, UserIdentityProvider.BITBUCKET + ) + + if not access_token: + LOG.error( + f"Access token not found for org {org_id} and provider " + f"{UserIdentityProvider.BITBUCKET.value}" + ) + + return access_token + + +def get_bitbucket_etl_handler(org_id: str) -> BitbucketETLHandler: + """Factory function to create a BitbucketETLHandler instance.""" + access_token = _get_access_token(org_id) + + return BitbucketETLHandler( + org_id=org_id, + bitbucket_api_service=BitbucketApiService(access_token), + code_repo_service=CodeRepoService(), + code_etl_analytics_service=CodeETLAnalyticsService(), + ) \ No newline at end of file diff --git a/backend/analytics_server/mhq/service/code/sync/etl_code_factory.py b/backend/analytics_server/mhq/service/code/sync/etl_code_factory.py index 327bb92c8..60c876b1c 100644 --- a/backend/analytics_server/mhq/service/code/sync/etl_code_factory.py +++ b/backend/analytics_server/mhq/service/code/sync/etl_code_factory.py @@ -1,5 +1,7 @@ +from mhq.utils.log import LOG from mhq.service.code.sync.etl_gitlab_handler import get_gitlab_etl_handler from mhq.service.code.sync.etl_github_handler import get_github_etl_handler +from mhq.service.code.sync.etl_bitbucket_handler import get_bitbucket_etl_handler from mhq.service.code.sync.etl_provider_handler import CodeProviderETLHandler from mhq.store.models.code import CodeProvider @@ -15,4 +17,7 @@ def __call__(self, provider: str) -> CodeProviderETLHandler: if provider == CodeProvider.GITLAB.value: return get_gitlab_etl_handler(self.org_id) + if provider == CodeProvider.BITBUCKET.value: + return get_bitbucket_etl_handler(self.org_id) + raise NotImplementedError(f"Unknown provider - {provider}") diff --git a/backend/analytics_server/mhq/store/models/integrations/enums.py b/backend/analytics_server/mhq/store/models/integrations/enums.py index 423bde139..5ba2f87b8 100644 --- a/backend/analytics_server/mhq/store/models/integrations/enums.py +++ b/backend/analytics_server/mhq/store/models/integrations/enums.py @@ -4,6 +4,7 @@ class UserIdentityProvider(Enum): GITHUB = "github" GITLAB = "gitlab" + BITBUCKET = "bitbucket" @classmethod def get_enum(self, provider: str): From abe79fa54a8d2c2cc089acab8a2ca64c1c4ba7c3 Mon Sep 17 00:00:00 2001 From: Ayyan Shaikh Date: Sun, 20 Jul 2025 21:39:51 +0530 Subject: [PATCH 5/5] feat: Implement Bitbucket integration and PR revert synchronization - Added RevertPRsBitbucketSyncHandler to manage pull request revert mappings. - Enhanced ExternalIntegrationsService with Bitbucket API methods for workspace and repository management. - Updated incidents integration to support Bitbucket as an incident provider. - Modified ETL incidents factory to handle Bitbucket incidents. - Introduced BITBUCKET enum in IncidentProvider for better integration handling. - Improved datetime parsing in time utility to support various formats. - Created unit tests for Bitbucket ETL handler to ensure proper functionality. - Updated Bitbucket authentication API to use email and API token instead of username and app password. - Refactored ConfigureBitbucketModalBody to accommodate new authentication method. - Adjusted auth utility functions to validate Bitbucket credentials using email and API token. --- .../mhq/exapi/models/bitbucket.py | 103 ++++- .../code/sync/etl_bitbucket_handler.py | 390 ++++++++++++++++-- .../code/sync/revert_pr_bitbucket_sync.py | 191 +++++++++ .../service/external_integrations_service.py | 34 ++ .../mhq/service/incidents/integration.py | 2 +- .../incidents/sync/etl_incidents_factory.py | 3 + .../mhq/store/models/incidents/enums.py | 1 + backend/analytics_server/mhq/utils/time.py | 48 ++- .../code/sync/test_etl_bitbucket_handler.py | 135 ++++++ .../api/integrations/bitbucket/scopes.ts | 95 +++-- .../ConfigureBitbucketModalBody.tsx | 257 ++++++------ web-server/src/utils/auth.ts | 82 ++-- 12 files changed, 1113 insertions(+), 228 deletions(-) create mode 100644 backend/analytics_server/mhq/service/code/sync/revert_pr_bitbucket_sync.py create mode 100644 backend/analytics_server/tests/service/code/sync/test_etl_bitbucket_handler.py diff --git a/backend/analytics_server/mhq/exapi/models/bitbucket.py b/backend/analytics_server/mhq/exapi/models/bitbucket.py index 825cf1723..47e8726f6 100644 --- a/backend/analytics_server/mhq/exapi/models/bitbucket.py +++ b/backend/analytics_server/mhq/exapi/models/bitbucket.py @@ -20,8 +20,9 @@ class BitbucketRepo: def __init__(self, repo: Dict): self.name = repo.get("name", "") - self.org_name = repo.get("workspace", {}).get("name", "") - self.default_branch = repo.get("mainbranch", {}).get("name", "") + workspace = repo.get("workspace", {}) + self.org_name = workspace.get("slug", workspace.get("name", "")) + self.default_branch = repo.get("mainbranch", {}).get("name", "main") self.idempotency_key = str(repo.get("uuid", "")) self.slug = repo.get("slug", "") self.description = repo.get("description", "") @@ -29,4 +30,100 @@ def __init__(self, repo: Dict): self.languages = repo.get("language") def __hash__(self): - return hash(self.idempotency_key) \ No newline at end of file + return hash(self.idempotency_key) + + +class BitbucketPRState(Enum): + OPEN = "OPEN" + MERGED = "MERGED" + SUPERSEDED = "SUPERSEDED" + DECLINED = "DECLINED" + + +@dataclass +class BitbucketPR: + number: int + title: str + url: str + author: str + reviewers: List[str] + state: BitbucketPRState + base_branch: str + head_branch: str + data: Dict + created_at: datetime + updated_at: datetime + merged_at: Optional[datetime] = None + closed_at: Optional[datetime] = None + merge_commit_sha: Optional[str] = None + + def __init__(self, pr: Dict): + self.number = pr.get("id", 0) + self.title = pr.get("title", "") + self.url = pr.get("links", {}).get("html", {}).get("href", "") + self.author = pr.get("author", {}).get("display_name", "") + self.reviewers = [ + reviewer.get("display_name", "") + for reviewer in pr.get("reviewers", []) + ] + state_str = pr.get("state", "OPEN").upper() + try: + self.state = BitbucketPRState(state_str) + except ValueError: + + self.state = BitbucketPRState.OPEN + self.base_branch = pr.get("destination", {}).get("branch", {}).get("name", "") + self.head_branch = pr.get("source", {}).get("branch", {}).get("name", "") + self.data = pr + self.created_at = dt_from_iso_time_string(pr.get("created_on", "")) or datetime.now() + self.updated_at = dt_from_iso_time_string(pr.get("updated_on", "")) or datetime.now() + + # Parse merge/close dates + if pr.get("merge_commit"): + self.merged_at = self.updated_at + self.merge_commit_sha = pr.get("merge_commit", {}).get("hash", "") + + if self.state in [BitbucketPRState.DECLINED, BitbucketPRState.SUPERSEDED]: + self.closed_at = self.updated_at + + +@dataclass +class BitbucketCommit: + hash: str + message: str + url: str + data: Dict + author_email: str + created_at: datetime + + def __init__(self, commit: Dict): + self.hash = commit.get("hash", "") + self.message = commit.get("message", "") + self.url = commit.get("links", {}).get("html", {}).get("href", "") + self.data = commit + self.author_email = commit.get("author", {}).get("raw", "").split("<")[-1].replace(">", "").strip() + self.created_at = dt_from_iso_time_string(commit.get("date", "")) or datetime.now() + + +class BitbucketReviewState(Enum): + APPROVED = "approved" + CHANGES_REQUESTED = "changes_requested" + COMMENTED = "commented" + + +@dataclass +class BitbucketReview: + id: str + state: BitbucketReviewState + created_at: datetime + actor_username: str + data: Dict + idempotency_key: str + + def __init__(self, review: Dict): + self.id = str(review.get("uuid", "")) + self.state = BitbucketReviewState(review.get("state", "commented")) + self.created_at = dt_from_iso_time_string(review.get("date", "")) or datetime.now() + self.actor_username = review.get("user", {}).get("display_name", "") + self.data = review + self.idempotency_key = self.id \ No newline at end of file diff --git a/backend/analytics_server/mhq/service/code/sync/etl_bitbucket_handler.py b/backend/analytics_server/mhq/service/code/sync/etl_bitbucket_handler.py index ed927ba04..5ad88a6f6 100644 --- a/backend/analytics_server/mhq/service/code/sync/etl_bitbucket_handler.py +++ b/backend/analytics_server/mhq/service/code/sync/etl_bitbucket_handler.py @@ -4,11 +4,14 @@ import pytz -from mhq.exapi.models.bitbucket import BitbucketRepo +from mhq.exapi.models.bitbucket import BitbucketRepo, BitbucketPR, BitbucketCommit, BitbucketReview, BitbucketPRState from mhq.exapi.bitbucket import BitbucketApiService -from mhq.exapi.github import GithubApiService from mhq.service.code.sync.etl_code_analytics import CodeETLAnalyticsService from mhq.service.code.sync.etl_provider_handler import CodeProviderETLHandler +from mhq.service.code.sync.revert_pr_bitbucket_sync import ( + RevertPRsBitbucketSyncHandler, + get_revert_prs_bitbucket_sync_handler, +) from mhq.store.models import UserIdentityProvider from mhq.store.models.code import ( OrgRepo, @@ -17,6 +20,7 @@ PullRequestCommit, PullRequestEvent, PullRequestEventType, + PullRequestEventState, PullRequestRevertPRMapping, CodeProvider, ) @@ -37,7 +41,7 @@ def __init__( bitbucket_api_service: BitbucketApiService, code_repo_service: CodeRepoService, code_etl_analytics_service: CodeETLAnalyticsService, - # bitbucket_revert_pr_sync_handler: RevertPRsGitHubSyncHandler, + bitbucket_revert_pr_sync_handler: RevertPRsBitbucketSyncHandler, ): self.org_id = org_id self._api = bitbucket_api_service @@ -46,9 +50,9 @@ def __init__( code_etl_analytics_service ) self.provider = CodeProvider.BITBUCKET.value - # self.bitbucket_revert_pr_sync_handler: RevertPRsGitHubSyncHandler = ( - # github_revert_pr_sync_handler - # ) + self.bitbucket_revert_pr_sync_handler: RevertPRsBitbucketSyncHandler = ( + bitbucket_revert_pr_sync_handler + ) def check_pat_validity(self) -> bool: """Check if the Bitbucket Personal Access Token is valid. @@ -87,14 +91,15 @@ def get_org_repos(self, org_repos: List[OrgRepo]) -> List[OrgRepo]: org_repo.idempotency_key: org_repo for org_repo in org_repos } - return [ - self._process_bitbucket_repo( - repo_idempotency_key_org_repo_map.get(str(bitbucket_repo.idempotency_key)), - bitbucket_repo - ) - for bitbucket_repo in bitbucket_repos - if repo_idempotency_key_org_repo_map.get(str(bitbucket_repo.idempotency_key)) - ] + processed_repos = [] + for bitbucket_repo in bitbucket_repos: + org_repo = repo_idempotency_key_org_repo_map.get(str(bitbucket_repo.idempotency_key)) + if org_repo is not None: + processed_repo = self._process_bitbucket_repo(org_repo, bitbucket_repo) + if processed_repo is not None: + processed_repos.append(processed_repo) + + return processed_repos def _process_bitbucket_repo( self, org_repo: OrgRepo, bitbucket_repo: BitbucketRepo @@ -108,22 +113,349 @@ def _process_bitbucket_repo( Returns: Processed OrgRepo object. """ - return OrgRepo( - id=org_repo.id, - org_id=self.org_id, - name=bitbucket_repo.name, - provider=self.provider, - org_name=bitbucket_repo.org_name, - default_branch=bitbucket_repo.default_branch, - language=bitbucket_repo.languages, - contributors=self._api.get_repo_contributors( - bitbucket_repo.org_name, bitbucket_repo.name + processed_repo = OrgRepo() + processed_repo.id = org_repo.id + processed_repo.org_id = self.org_id + processed_repo.name = bitbucket_repo.name + processed_repo.provider = self.provider + processed_repo.org_name = bitbucket_repo.org_name + processed_repo.default_branch = bitbucket_repo.default_branch + processed_repo.language = bitbucket_repo.languages + processed_repo.contributors = self._api.get_repo_contributors( + bitbucket_repo.org_name, bitbucket_repo.name + ) + processed_repo.idempotency_key = str(bitbucket_repo.idempotency_key) + processed_repo.slug = bitbucket_repo.name + processed_repo.updated_at = time_now() + return processed_repo + + def get_repo_pull_requests_data( + self, org_repo: OrgRepo, bookmark: datetime + ) -> Tuple[List[PullRequest], List[PullRequestCommit], List[PullRequestEvent]]: + """Get all pull requests, their commits and events for a repository. + + Args: + org_repo: OrgRepo object to get pull requests for + bookmark: Bookmark date to get all pull requests after this date + + Returns: + Tuple of pull requests, their commits and events + """ + workspace = org_repo.org_name + repo_slug = org_repo.name + + try: + bitbucket_prs: List[BitbucketPR] = self._api.get_pull_requests( + workspace, repo_slug, state="all" + ) + except Exception as e: + LOG.error(f"Error getting pull requests for {workspace}/{repo_slug}: {e}") + return [], [], [] + + prs_to_process = [] + for pr in bitbucket_prs: + if pr.updated_at.replace(tzinfo=pytz.UTC) <= bookmark: + continue + + state_changed_at = pr.merged_at if pr.merged_at else pr.closed_at + if ( + pr.state != BitbucketPRState.OPEN.value + and state_changed_at + and state_changed_at.replace(tzinfo=pytz.UTC) < bookmark + ): + continue + + prs_to_process.append(pr) + + if not prs_to_process: + LOG.info("Nothing to process 🎉") + return [], [], [] + + pull_requests: List[PullRequest] = [] + pr_commits: List[PullRequestCommit] = [] + pr_events: List[PullRequestEvent] = [] + prs_added: Set[int] = set() + + for bitbucket_pr in prs_to_process: + if bitbucket_pr.number in prs_added: + continue + + pr_model, event_models, pr_commit_models = self.process_pr( + str(org_repo.id), bitbucket_pr, workspace, repo_slug + ) + pull_requests.append(pr_model) + pr_events += event_models + pr_commits += pr_commit_models + prs_added.add(bitbucket_pr.number) + + return pull_requests, pr_commits, pr_events + + def process_pr( + self, repo_id: str, pr: BitbucketPR, workspace: str, repo_slug: str + ) -> Tuple[PullRequest, List[PullRequestEvent], List[PullRequestCommit]]: + """Process a single pull request and return its model with events and commits. + + Args: + repo_id: Repository ID + pr: BitbucketPR object + workspace: Bitbucket workspace name + repo_slug: Repository slug + + Returns: + Tuple of PR model, events, and commits + """ + existing_pr_model: Optional[PullRequest] = self.code_repo_service.get_repo_pr_by_number( + repo_id, pr.number + ) + pr_event_model_list: List[PullRequestEvent] = ( + self.code_repo_service.get_pr_events(existing_pr_model) if existing_pr_model else [] + ) + + try: + reviews: List[BitbucketReview] = self._api.get_pr_reviews( + workspace, repo_slug, pr.number + ) + diff_stats = self._api.get_pr_diff_stats(workspace, repo_slug, pr.number) + except Exception as e: + LOG.error(f"Error getting PR details for {workspace}/{repo_slug}/pull/{pr.number}: {e}") + reviews = [] + diff_stats = {"additions": 0, "deletions": 0, "changed_files": 0} + + pr_model: PullRequest = self._to_pr_model( + pr, existing_pr_model, repo_id, len(reviews), diff_stats + ) + pr_events_model_list: List[PullRequestEvent] = self._to_pr_events( + reviews, pr_model, pr_event_model_list + ) + + pr_commits_model_list: List[PullRequestCommit] = [] + # Get commits for all PRs, not just merged ones, to calculate proper analytics + try: + commits: List[BitbucketCommit] = self._api.get_pr_commits( + workspace, repo_slug, pr.number + ) + pr_commits_model_list = self._to_pr_commits(commits, pr_model) + + # Update the commit count in PR meta + if pr_model.meta and "code_stats" in pr_model.meta: + pr_model.meta["code_stats"]["commits"] = len(pr_commits_model_list) + + except Exception as e: + LOG.error(f"Error getting commits for PR {pr.number}: {e}") + # Set commit count to 0 if we can't get commits + if pr_model.meta and "code_stats" in pr_model.meta: + pr_model.meta["code_stats"]["commits"] = 0 + + pr_model = self.code_etl_analytics_service.create_pr_metrics( + pr_model, pr_events_model_list, pr_commits_model_list + ) + + return pr_model, pr_events_model_list, pr_commits_model_list + + def get_revert_prs_mapping( + self, prs: List[PullRequest] + ) -> List[PullRequestRevertPRMapping]: + """Get revert PR mappings for the given PRs. + + Args: + prs: List of PullRequest objects + + Returns: + List of PullRequestRevertPRMapping objects + """ + return self.bitbucket_revert_pr_sync_handler(prs) + + def _to_pr_model( + self, + pr: BitbucketPR, + pr_model: Optional[PullRequest], + repo_id: str, + review_comments: int = 0, + diff_stats: Optional[Dict[str, int]] = None, + ) -> PullRequest: + """Convert BitbucketPR to PullRequest model. + + Args: + pr: BitbucketPR object + pr_model: Existing PullRequest model if any + repo_id: Repository ID + review_comments: Number of review comments + diff_stats: Diff statistics dict + + Returns: + PullRequest model + """ + if diff_stats is None: + diff_stats = {"additions": 0, "deletions": 0, "changed_files": 0} + + state = self._get_state(pr) + pr_id = pr_model.id if pr_model else uuid.uuid4() + state_changed_at = None + + if state != PullRequestState.OPEN: + state_changed_at = ( + pr.merged_at.replace(tzinfo=pytz.UTC) + if pr.merged_at + else pr.closed_at.replace(tzinfo=pytz.UTC) if pr.closed_at + else None + ) + + merge_commit_sha: Optional[str] = self._get_merge_commit_sha(pr.data, state) + + pr_model = PullRequest() + pr_model.id = pr_id + pr_model.number = str(pr.number) + pr_model.title = pr.title + pr_model.url = pr.url + pr_model.created_at = pr.created_at.replace(tzinfo=pytz.UTC) + pr_model.updated_at = pr.updated_at.replace(tzinfo=pytz.UTC) + pr_model.state_changed_at = state_changed_at + pr_model.state = state + pr_model.base_branch = pr.base_branch + pr_model.head_branch = pr.head_branch + pr_model.author = pr.author + pr_model.repo_id = repo_id + pr_model.data = pr.data + pr_model.requested_reviews = pr.reviewers + pr_model.meta = dict( + code_stats=dict( + commits=0, # This will be updated when we get actual commit count + additions=diff_stats.get("additions", 0), + deletions=diff_stats.get("deletions", 0), + changed_files=diff_stats.get("changed_files", 0), + comments=review_comments, ), - idempotency_key=str(bitbucket_repo.idempotency_key), - slug=bitbucket_repo.name, - updated_at=time_now(), + user_profile=dict(username=pr.author), ) + pr_model.provider = UserIdentityProvider.BITBUCKET.value + pr_model.merge_commit_sha = merge_commit_sha + + return pr_model + + @staticmethod + def _get_merge_commit_sha(raw_data: Dict, state: PullRequestState) -> Optional[str]: + """Extract merge commit SHA from raw data. + + Args: + raw_data: Raw PR data from Bitbucket + state: PR state + + Returns: + Merge commit SHA if available + """ + if state != PullRequestState.MERGED: + return None + merge_commit = raw_data.get("merge_commit") + if merge_commit: + return merge_commit.get("hash") + + return None + + @staticmethod + def _get_state(pr: BitbucketPR) -> PullRequestState: + """Convert Bitbucket PR state to internal PR state. + + Args: + pr: BitbucketPR object + + Returns: + PullRequestState enum value + """ + if pr.state == BitbucketPRState.MERGED: + return PullRequestState.MERGED + elif pr.state in [BitbucketPRState.DECLINED, BitbucketPRState.SUPERSEDED]: + return PullRequestState.CLOSED + else: + return PullRequestState.OPEN + + @staticmethod + def _map_bitbucket_review_state_to_pr_event_state(review_state: str) -> str: + """Map Bitbucket review state to internal PullRequestEventState. + + Args: + review_state: Bitbucket review state + + Returns: + Internal PullRequestEventState value + """ + from mhq.exapi.models.bitbucket import BitbucketReviewState + + if review_state == BitbucketReviewState.APPROVED.value: + return PullRequestEventState.APPROVED.value + elif review_state == BitbucketReviewState.CHANGES_REQUESTED.value: + return PullRequestEventState.CHANGES_REQUESTED.value + else: + return PullRequestEventState.COMMENTED.value + + @staticmethod + def _to_pr_events( + reviews: List[BitbucketReview], + pr_model: PullRequest, + pr_events_model: List[PullRequestEvent], + ) -> List[PullRequestEvent]: + """Convert Bitbucket reviews to PullRequestEvent models. + + Args: + reviews: List of BitbucketReview objects + pr_model: PullRequest model + pr_events_model: Existing PR events + + Returns: + List of PullRequestEvent models + """ + pr_events: List[PullRequestEvent] = [] + pr_event_id_map = {event.idempotency_key: event.id for event in pr_events_model} + + for review in reviews: + if not review.created_at: + continue + + review_data = review.data.copy() + review_data["state"] = BitbucketETLHandler._map_bitbucket_review_state_to_pr_event_state( + review.state.value + ) + + pr_event = PullRequestEvent() + pr_event.id = pr_event_id_map.get(review.idempotency_key, uuid.uuid4()) + pr_event.pull_request_id = str(pr_model.id) + pr_event.type = PullRequestEventType.REVIEW.value + pr_event.data = review_data + pr_event.created_at = review.created_at.replace(tzinfo=pytz.UTC) + pr_event.idempotency_key = review.idempotency_key + pr_event.org_repo_id = pr_model.repo_id + pr_event.actor_username = review.actor_username + pr_events.append(pr_event) + return pr_events + + def _to_pr_commits( + self, + commits: List[BitbucketCommit], + pr_model: PullRequest, + ) -> List[PullRequestCommit]: + """Convert Bitbucket commits to PullRequestCommit models. + + Args: + commits: List of BitbucketCommit objects + pr_model: PullRequest model + + Returns: + List of PullRequestCommit models + """ + pr_commits: List[PullRequestCommit] = [] + + for commit in commits: + pr_commit = PullRequestCommit() + pr_commit.hash = commit.hash + pr_commit.pull_request_id = str(pr_model.id) + pr_commit.url = commit.url + pr_commit.data = commit.data + pr_commit.message = commit.message + pr_commit.author = commit.author_email + pr_commit.created_at = commit.created_at.replace(tzinfo=pytz.UTC) + pr_commit.org_repo_id = pr_model.repo_id + pr_commits.append(pr_commit) + return pr_commits def _get_access_token(org_id: str) -> Optional[str]: @@ -146,9 +478,13 @@ def get_bitbucket_etl_handler(org_id: str) -> BitbucketETLHandler: """Factory function to create a BitbucketETLHandler instance.""" access_token = _get_access_token(org_id) + if not access_token: + raise Exception(f"Access token not found for org {org_id} and provider {UserIdentityProvider.BITBUCKET.value}") + return BitbucketETLHandler( org_id=org_id, bitbucket_api_service=BitbucketApiService(access_token), code_repo_service=CodeRepoService(), code_etl_analytics_service=CodeETLAnalyticsService(), + bitbucket_revert_pr_sync_handler=get_revert_prs_bitbucket_sync_handler(), ) \ No newline at end of file diff --git a/backend/analytics_server/mhq/service/code/sync/revert_pr_bitbucket_sync.py b/backend/analytics_server/mhq/service/code/sync/revert_pr_bitbucket_sync.py new file mode 100644 index 000000000..2bd65041f --- /dev/null +++ b/backend/analytics_server/mhq/service/code/sync/revert_pr_bitbucket_sync.py @@ -0,0 +1,191 @@ +import re +from datetime import datetime +from typing import List, Set, Dict, Optional + +from mhq.store.models.code import ( + PullRequest, + PullRequestRevertPRMapping, + PullRequestRevertPRMappingActorType, +) +from mhq.store.repos.code import CodeRepoService +from mhq.utils.time import time_now + + +class RevertPRsBitbucketSyncHandler: + def __init__( + self, + code_repo_service: CodeRepoService, + ): + self.code_repo_service = code_repo_service + + def __call__(self, *args, **kwargs): + return self.process_revert_prs(*args, **kwargs) + + def process_revert_prs( + self, prs: List[PullRequest] + ) -> List[PullRequestRevertPRMapping]: + revert_prs: List[PullRequest] = [] + original_prs: List[PullRequest] = [] + + for pr in prs: + pr_number = ( + self._get_revert_pr_number(pr.head_branch) if pr.head_branch else None + ) + if pr_number is None: + original_prs.append(pr) + else: + revert_prs.append(pr) + + mappings_of_revert_prs = self._get_revert_pr_mapping_for_revert_prs(revert_prs) + mappings_of_original_prs = self._get_revert_pr_mapping_for_original_prs( + original_prs + ) + revert_pr_mappings = set(mappings_of_original_prs + mappings_of_revert_prs) + + return list(revert_pr_mappings) + + def _get_revert_pr_mapping_for_original_prs( + self, prs: List[PullRequest] + ) -> List[PullRequestRevertPRMapping]: + """ + This function takes a list of PRs and for each PR it tries to + find if that pr has been reverted and by which PR. It is done + by taking repo_id and the pr_number and searching for the + string 'revert-[pr-number]' in the head branch. + """ + + repo_ids: Set[str] = set() + repo_id_to_pr_number_to_id_map: Dict[str, Dict[str, str]] = {} + pr_numbers_match_strings: List[str] = [] + + for pr in prs: + pr_numbers_match_strings.append(f"revert-{pr.number}") + repo_ids.add(str(pr.repo_id)) + + if str(pr.repo_id) not in repo_id_to_pr_number_to_id_map: + repo_id_to_pr_number_to_id_map[str(pr.repo_id)] = {} + + repo_id_to_pr_number_to_id_map[str(pr.repo_id)][str(pr.number)] = pr.id + + if len(pr_numbers_match_strings) == 0: + return [] + + revert_prs: List[PullRequest] = ( + self.code_repo_service.get_prs_by_head_branch_match_strings( + list(repo_ids), pr_numbers_match_strings + ) + ) + + revert_pr_mappings: List[PullRequestRevertPRMapping] = [] + + for rev_pr in revert_prs: + original_pr_number = self._get_revert_pr_number(rev_pr.head_branch) + if original_pr_number is None: + continue + + repo_key_exists = repo_id_to_pr_number_to_id_map.get(str(rev_pr.repo_id)) + if repo_key_exists is None: + continue + + original_pr_id = repo_id_to_pr_number_to_id_map[str(rev_pr.repo_id)].get( + str(original_pr_number) + ) + if original_pr_id is None: + continue + + revert_pr_mp = PullRequestRevertPRMapping() + revert_pr_mp.pr_id = rev_pr.id + revert_pr_mp.actor_type = PullRequestRevertPRMappingActorType.SYSTEM + revert_pr_mp.actor = None + revert_pr_mp.reverted_pr = original_pr_id + revert_pr_mp.updated_at = time_now() + revert_pr_mappings.append(revert_pr_mp) + + return revert_pr_mappings + + def _get_revert_pr_mapping_for_revert_prs( + self, prs: List[PullRequest] + ) -> List[PullRequestRevertPRMapping]: + """ + This function takes a list of pull requests and for each pull request + checks if it is a revert pr or not. If it is a revert pr it tries to + create a mapping of that revert pr with the reverted pr and then returns + a list of those mappings + """ + + revert_pr_numbers: List[str] = [] + repo_ids: Set[str] = set() + repo_id_to_pr_number_to_id_map: Dict[str, Dict[str, str]] = {} + + for pr in prs: + revert_pr_number = self._get_revert_pr_number(pr.head_branch) + if revert_pr_number is None: + continue + + revert_pr_numbers.append(str(revert_pr_number)) + repo_ids.add(str(pr.repo_id)) + + if str(pr.repo_id) not in repo_id_to_pr_number_to_id_map: + repo_id_to_pr_number_to_id_map[str(pr.repo_id)] = {} + + repo_id_to_pr_number_to_id_map[str(pr.repo_id)][ + str(revert_pr_number) + ] = pr.id + + if len(revert_pr_numbers) == 0: + return [] + + reverted_prs: List[PullRequest] = ( + self.code_repo_service.get_reverted_prs_by_numbers( + list(repo_ids), revert_pr_numbers + ) + ) + + revert_pr_mappings: List[PullRequestRevertPRMapping] = [] + for rev_pr in reverted_prs: + repo_key_exists = repo_id_to_pr_number_to_id_map.get(str(rev_pr.repo_id)) + if repo_key_exists is None: + continue + + original_pr_id = repo_id_to_pr_number_to_id_map[str(rev_pr.repo_id)].get( + str(rev_pr.number) + ) + if original_pr_id is None: + continue + + revert_pr_mp = PullRequestRevertPRMapping() + revert_pr_mp.pr_id = original_pr_id + revert_pr_mp.actor_type = PullRequestRevertPRMappingActorType.SYSTEM + revert_pr_mp.actor = None + revert_pr_mp.reverted_pr = rev_pr.id + revert_pr_mp.updated_at = time_now() + revert_pr_mappings.append(revert_pr_mp) + + return revert_pr_mappings + + def _get_revert_pr_number(self, branch_name: str) -> Optional[int]: + """ + Extract the PR number from revert branch names. + Common patterns: + - revert-123-feature-branch + - revert-pr-123 + - revert-feature-branch-123 + """ + if not branch_name: + return None + + # Pattern to match revert branches (similar to GitHub) + pattern = r"revert-(\d+)-\w+" + + match = re.search(pattern, branch_name.lower()) + if match: + try: + return int(match.group(1)) + except (ValueError, IndexError): + pass + + return None + + +def get_revert_prs_bitbucket_sync_handler() -> RevertPRsBitbucketSyncHandler: + return RevertPRsBitbucketSyncHandler(CodeRepoService()) diff --git a/backend/analytics_server/mhq/service/external_integrations_service.py b/backend/analytics_server/mhq/service/external_integrations_service.py index ff28d6a5a..5e601c07e 100644 --- a/backend/analytics_server/mhq/service/external_integrations_service.py +++ b/backend/analytics_server/mhq/service/external_integrations_service.py @@ -3,7 +3,9 @@ from github.Organization import Organization as GithubOrganization from mhq.exapi.models.gitlab import GitlabRepo, GitlabUser +from mhq.exapi.models.bitbucket import BitbucketRepo from mhq.exapi.gitlab import GitlabApiService +from mhq.exapi.bitbucket import BitbucketApiService from mhq.utils.log import LOG from mhq.exapi.github import GithubApiService from mhq.store.models import UserIdentityProvider @@ -98,6 +100,38 @@ def get_gitlab_user_projects(self, page_size: int, page: int) -> List[GitlabRepo return projects + def get_bitbucket_workspace_repo( + self, workspace: str, repo_slug: str + ) -> BitbucketRepo: + bitbucket_api_service = BitbucketApiService(self.access_token) + try: + repo: BitbucketRepo = bitbucket_api_service.get_workspace_repos( + workspace, repo_slug + ) + except Exception as e: + raise e + return repo + + def get_bitbucket_workspaces(self) -> List[Dict]: + bitbucket_api_service = BitbucketApiService(self.access_token) + try: + workspaces: List[Dict] = bitbucket_api_service.get_user_workspaces() + except Exception as e: + raise e + return workspaces + + def get_bitbucket_workspace_repositories( + self, workspace: str, page_size: int = 50 + ) -> List[BitbucketRepo]: + bitbucket_api_service = BitbucketApiService(self.access_token) + try: + repositories: List[BitbucketRepo] = bitbucket_api_service.get_workspace_repositories( + workspace, page_size + ) + except Exception as e: + raise e + return repositories + def get_external_integrations_service( org_id: str, user_identity_provider: UserIdentityProvider diff --git a/backend/analytics_server/mhq/service/incidents/integration.py b/backend/analytics_server/mhq/service/incidents/integration.py index fd10e09a6..97d0f7892 100644 --- a/backend/analytics_server/mhq/service/incidents/integration.py +++ b/backend/analytics_server/mhq/service/incidents/integration.py @@ -6,7 +6,7 @@ from mhq.store.models.incidents import IncidentProvider, IncidentSource from mhq.store.repos.core import CoreRepoService -GIT_INCIDENT_INTEGRATION_BUCKET = [IncidentProvider.GITHUB.value] +GIT_INCIDENT_INTEGRATION_BUCKET = [IncidentProvider.GITHUB.value, IncidentProvider.BITBUCKET.value] class IncidentsIntegrationService: diff --git a/backend/analytics_server/mhq/service/incidents/sync/etl_incidents_factory.py b/backend/analytics_server/mhq/service/incidents/sync/etl_incidents_factory.py index 571178196..a42b447e0 100644 --- a/backend/analytics_server/mhq/service/incidents/sync/etl_incidents_factory.py +++ b/backend/analytics_server/mhq/service/incidents/sync/etl_incidents_factory.py @@ -16,4 +16,7 @@ def __call__(self, provider: str) -> IncidentsProviderETLHandler: if provider == IncidentProvider.GITLAB.value: return get_incidents_sync_etl_handler(self.org_id) + if provider == IncidentProvider.BITBUCKET.value: + return get_incidents_sync_etl_handler(self.org_id) + raise NotImplementedError(f"Unknown provider - {provider}") diff --git a/backend/analytics_server/mhq/store/models/incidents/enums.py b/backend/analytics_server/mhq/store/models/incidents/enums.py index 6b46cf80d..9a2c2babd 100644 --- a/backend/analytics_server/mhq/store/models/incidents/enums.py +++ b/backend/analytics_server/mhq/store/models/incidents/enums.py @@ -4,6 +4,7 @@ class IncidentProvider(Enum): GITHUB = "github" GITLAB = "gitlab" + BITBUCKET = "bitbucket" class IncidentSource(Enum): diff --git a/backend/analytics_server/mhq/utils/time.py b/backend/analytics_server/mhq/utils/time.py index 6f62f5bcd..8140f90cf 100644 --- a/backend/analytics_server/mhq/utils/time.py +++ b/backend/analytics_server/mhq/utils/time.py @@ -273,30 +273,34 @@ def fill_missing_week_buckets( def dt_from_iso_time_string(j_str_dt) -> Optional[datetime]: if not j_str_dt: return None - datetime_formats = [ - "%Y-%m-%dT%H:%M:%S.%f%z", - "%Y-%m-%dT%H:%M:%S%z", - "%Y-%m-%dT%H:%M:%S.%fZ", + + # List of common datetime formats used by different APIs + formats = [ + "%Y-%m-%dT%H:%M:%S.%f%z", # With microseconds and timezone + "%Y-%m-%dT%H:%M:%S%z", # Without microseconds but with timezone + "%Y-%m-%dT%H:%M:%S.%fZ", # With microseconds, Z timezone + "%Y-%m-%dT%H:%M:%SZ", # Without microseconds, Z timezone + "%Y-%m-%dT%H:%M:%S", # Without timezone ] - normalized_dt_str = ( - j_str_dt.replace("Z", "+00:00") if j_str_dt.endswith("Z") else j_str_dt - ) - for fmt in datetime_formats: + + for fmt in formats: try: - if "%z" in fmt: - dt_without_timezone = datetime.strptime(normalized_dt_str, fmt) + if fmt.endswith('%z'): + dt_without_timezone = datetime.strptime(j_str_dt, fmt) + return dt_without_timezone.astimezone(pytz.UTC) + elif fmt.endswith('Z'): + # Replace Z with +00:00 for proper parsing + j_str_dt_fixed = j_str_dt.replace('Z', '+00:00') + dt_without_timezone = datetime.strptime(j_str_dt_fixed, fmt.replace('Z', '%z')) + return dt_without_timezone.astimezone(pytz.UTC) else: - dt_without_timezone = datetime.strptime(j_str_dt, fmt).replace( - tzinfo=pytz.UTC - ) - - return dt_without_timezone.astimezone(pytz.UTC) + # Assume UTC if no timezone info + dt_without_timezone = datetime.strptime(j_str_dt, fmt) + return dt_without_timezone.replace(tzinfo=pytz.UTC) except ValueError: continue - try: - dt = datetime.fromisoformat(normalized_dt_str) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=pytz.UTC) - return dt.astimezone(pytz.UTC) - except (ValueError, AttributeError): - return None + + # If none of the formats work, log an error and return None + from mhq.utils.log import LOG + LOG.warning(f"Could not parse datetime string: {j_str_dt}") + return None diff --git a/backend/analytics_server/tests/service/code/sync/test_etl_bitbucket_handler.py b/backend/analytics_server/tests/service/code/sync/test_etl_bitbucket_handler.py new file mode 100644 index 000000000..d5f7590d1 --- /dev/null +++ b/backend/analytics_server/tests/service/code/sync/test_etl_bitbucket_handler.py @@ -0,0 +1,135 @@ +from datetime import datetime +import uuid +import pytz +from unittest.mock import Mock + +from mhq.service.code.sync.etl_bitbucket_handler import BitbucketETLHandler +from mhq.exapi.models.bitbucket import BitbucketPR, BitbucketPRState +from mhq.store.models.code import PullRequestState +from mhq.store.models import UserIdentityProvider +from mhq.utils.string import uuid4_str + +ORG_ID = uuid4_str() + + +def test__to_pr_model_given_a_bitbucket_pr_returns_new_pr_model(): + """Test that BitbucketPR is correctly converted to PullRequest model.""" + repo_id = uuid4_str() + number = 123 + author = "test_user" + merged_at = datetime(2022, 6, 29, 10, 53, 15, tzinfo=pytz.UTC) + head_branch = "feature" + base_branch = "main" + title = "Test PR" + review_comments = 2 + + # Create a mock Bitbucket PR data structure + pr_data = { + "id": number, + "title": title, + "links": {"html": {"href": f"https://bitbucket.org/workspace/repo/pull-requests/{number}"}}, + "author": {"display_name": author}, + "reviewers": [{"display_name": "reviewer1"}], + "state": "MERGED", + "destination": {"branch": {"name": base_branch}}, + "source": {"branch": {"name": head_branch}}, + "created_on": "2022-06-29T10:53:15+00:00", + "updated_on": "2022-06-29T11:53:15+00:00", + "merge_commit": {"hash": "abcd1234"} + } + + bitbucket_pr = BitbucketPR(pr_data) + + # Mock diff stats + diff_stats = { + "additions": 10, + "deletions": 5, + "changed_files": 2 + } + + bitbucket_etl_handler = BitbucketETLHandler( + ORG_ID, + Mock(), # bitbucket_api_service + Mock(), # code_repo_service + Mock(), # code_etl_analytics_service + Mock() # bitbucket_revert_pr_sync_handler + ) + pr_model = bitbucket_etl_handler._to_pr_model( + pr=bitbucket_pr, + pr_model=None, + repo_id=repo_id, + review_comments=review_comments, + diff_stats=diff_stats, + ) + + # Assertions + assert pr_model.number == str(number) + assert pr_model.title == title + assert pr_model.author == author + assert pr_model.state == PullRequestState.MERGED + assert pr_model.base_branch == base_branch + assert pr_model.head_branch == head_branch + assert pr_model.repo_id == repo_id + assert pr_model.provider == UserIdentityProvider.BITBUCKET.value + assert pr_model.merge_commit_sha == "abcd1234" + assert pr_model.meta["code_stats"]["additions"] == 10 + assert pr_model.meta["code_stats"]["deletions"] == 5 + assert pr_model.meta["code_stats"]["changed_files"] == 2 + assert pr_model.meta["code_stats"]["comments"] == review_comments + + +def test__get_state_converts_bitbucket_state_to_internal_state(): + """Test that Bitbucket PR states are correctly mapped to internal states.""" + bitbucket_etl_handler = BitbucketETLHandler( + ORG_ID, + Mock(), # bitbucket_api_service + Mock(), # code_repo_service + Mock(), # code_etl_analytics_service + Mock() # bitbucket_revert_pr_sync_handler + ) + + # Test MERGED state + pr_data_merged = {"id": 1, "state": "MERGED"} + pr_merged = BitbucketPR(pr_data_merged) + assert bitbucket_etl_handler._get_state(pr_merged) == PullRequestState.MERGED + + # Test DECLINED state + pr_data_declined = {"id": 2, "state": "DECLINED"} + pr_declined = BitbucketPR(pr_data_declined) + assert bitbucket_etl_handler._get_state(pr_declined) == PullRequestState.CLOSED + + # Test SUPERSEDED state + pr_data_superseded = {"id": 3, "state": "SUPERSEDED"} + pr_superseded = BitbucketPR(pr_data_superseded) + assert bitbucket_etl_handler._get_state(pr_superseded) == PullRequestState.CLOSED + + # Test OPEN state + pr_data_open = {"id": 4, "state": "OPEN"} + pr_open = BitbucketPR(pr_data_open) + assert bitbucket_etl_handler._get_state(pr_open) == PullRequestState.OPEN + + +def test__get_merge_commit_sha_returns_correct_sha(): + """Test that merge commit SHA is correctly extracted.""" + bitbucket_etl_handler = BitbucketETLHandler( + ORG_ID, + Mock(), # bitbucket_api_service + Mock(), # code_repo_service + Mock(), # code_etl_analytics_service + Mock() # bitbucket_revert_pr_sync_handler + ) + + # Test with merge commit present + raw_data_with_merge = {"merge_commit": {"hash": "abcd1234"}} + sha = bitbucket_etl_handler._get_merge_commit_sha(raw_data_with_merge, PullRequestState.MERGED) + assert sha == "abcd1234" + + # Test with no merge commit (non-merged PR) + raw_data_no_merge = {} + sha = bitbucket_etl_handler._get_merge_commit_sha(raw_data_no_merge, PullRequestState.OPEN) + assert sha is None + + # Test with non-merged state + raw_data_closed = {"merge_commit": {"hash": "abcd1234"}} + sha = bitbucket_etl_handler._get_merge_commit_sha(raw_data_closed, PullRequestState.CLOSED) + assert sha is None diff --git a/web-server/pages/api/integrations/bitbucket/scopes.ts b/web-server/pages/api/integrations/bitbucket/scopes.ts index 0a9750741..5dfd6f7b0 100644 --- a/web-server/pages/api/integrations/bitbucket/scopes.ts +++ b/web-server/pages/api/integrations/bitbucket/scopes.ts @@ -1,44 +1,52 @@ -import { Endpoint, nullSchema } from '@/api-helpers/global'; -import { handleRequest } from '@/api-helpers/axios'; +import axios from 'axios'; import * as yup from 'yup'; +import { Endpoint, nullSchema } from '@/api-helpers/global'; + const payloadSchema = yup.object({ - username: yup + email: yup .string() - .required('Username is required') + .required('Email is required') + .email('Please enter a valid email address') .trim() - .min(1, 'Username cannot be empty') - .max(100, 'Username too long'), - appPassword: yup + .min(1, 'Email cannot be empty') + .max(100, 'Email too long'), + apiToken: yup .string() - .required('App password is required') - .min(1, 'App password cannot be empty') - .max(500, 'App password too long') + .required('API token is required') + .min(1, 'API token cannot be empty') + .max(500, 'API token too long') }); const endpoint = new Endpoint(nullSchema); endpoint.handle.POST(payloadSchema, async (req, res) => { try { - const { username, appPassword } = req.payload; - const sanitizedUsername = username.replace(/[^\w.-]/g, ''); - - if (sanitizedUsername !== username) { + const { email, apiToken } = req.payload; + + // Basic email validation + if (!email?.trim() || !apiToken?.trim()) { return res.status(400).json({ - message: 'Invalid username format. Only alphanumeric characters, dots, and hyphens are allowed.' + message: 'Email and API token are required' }); } - + + // Change this to the Atlassian Bitbucket Cloud REST API endpoint const url = 'https://api.bitbucket.org/2.0/user'; - - const response = await handleRequest(url, { + + const response = await axios({ + url, method: 'GET', headers: { - Authorization: `Basic ${Buffer.from(`${sanitizedUsername}:${appPassword}`).toString('base64')}`, - 'User-Agent': 'MiddlewareApp/1.0' + Authorization: `Basic ${Buffer.from( + `${email.trim()}:${apiToken}` + ).toString('base64')}`, + 'User-Agent': 'MiddlewareApp/1.0', + Accept: 'application/json', + 'Content-Type': 'application/json' }, timeout: 10000 - }, true); + }); if (!response.headers) { return res.status(400).json({ @@ -46,39 +54,62 @@ endpoint.handle.POST(payloadSchema, async (req, res) => { }); } + // Validate that we received user data + if (!response.data || typeof response.data !== 'object') { + return res.status(400).json({ + message: 'Invalid response from BitBucket API' + }); + } + + // Check for required user fields to ensure authentication was successful + if (!response.data.uuid || !response.data.username) { + return res.status(400).json({ + message: 'Bitbucket authentication successful but user data incomplete' + }); + } + res.status(200).json({ - ...response, + data: response.data, headers: response.headers }); } catch (error: any) { console.error('Error fetching Bitbucket user:', { message: error.message, status: error.response?.status, - hasCredentials: !!(req.payload?.username && req.payload?.appPassword) + hasCredentials: !!(req.payload?.email && req.payload?.apiToken), + url: 'https://api.bitbucket.org/2.0/user' }); - + const status = error.response?.status || 500; let message = 'Internal Server Error'; - + switch (status) { case 401: - message = 'Invalid BitBucket credentials'; + message = + 'Invalid Bitbucket credentials. Please check your email and API Token.'; break; case 403: - message = 'Access forbidden. Check your App Password permissions'; + message = + 'Access forbidden. Check your API Token permissions or ensure it has not expired.'; break; case 404: - message = 'BitBucket user not found'; + message = 'Bitbucket user not found. Please verify your email.'; break; case 429: - message = 'Rate limit exceeded. Please try again later'; + message = 'Rate limit exceeded. Please try again later.'; + break; + case 400: + message = 'Bad request. Please check your credentials format.'; break; default: - message = error.response?.data?.error?.message || message; + message = + error.response?.data?.error?.message || + error.message || + 'Failed to validate Bitbucket credentials'; } - + res.status(status).json({ message }); } }); -export default endpoint.serve(); \ No newline at end of file +export default endpoint.serve(); diff --git a/web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx b/web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx index aad67a580..4a3bbf7ee 100644 --- a/web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx +++ b/web-server/src/content/Dashboards/ConfigureBitbucketModalBody.tsx @@ -12,11 +12,7 @@ import { useBoolState, useEasyState } from '@/hooks/useEasyState'; import { fetchCurrentOrg } from '@/slices/auth'; import { fetchTeams } from '@/slices/team'; import { useDispatch } from '@/store'; -import { - linkProvider, - checkBitBucketValidity, - getMissingBitBucketScopes -} from '@/utils/auth'; +import { linkProvider, checkBitBucketValidity } from '@/utils/auth'; import { depFn } from '@/utils/fn'; interface ConfigureBitbucketModalBodyProps { @@ -24,94 +20,113 @@ interface ConfigureBitbucketModalBodyProps { } interface FormErrors { - username: string; - password: string; + email: string; + token: string; } -export const ConfigureBitbucketModalBody: FC = ({ onClose }) => { - const username = useEasyState(''); - const password = useEasyState(''); +export const ConfigureBitbucketModalBody: FC< + ConfigureBitbucketModalBodyProps +> = ({ onClose }) => { + const email = useEasyState(''); + const token = useEasyState(''); const { orgId } = useAuth(); const { enqueueSnackbar } = useSnackbar(); const dispatch = useDispatch(); const isLoading = useBoolState(); - const showUsernameError = useEasyState(''); - const showPasswordError = useEasyState(''); + const showEmailError = useEasyState(''); + const showTokenError = useEasyState(''); - const setUsernameError = useCallback( - (err: string) => depFn(showUsernameError.set, err), - [showUsernameError.set] + const setEmailError = useCallback( + (err: string) => depFn(showEmailError.set, err), + [showEmailError.set] ); - const setPasswordError = useCallback( - (err: string) => depFn(showPasswordError.set, err), - [showPasswordError.set] + const setTokenError = useCallback( + (err: string) => depFn(showTokenError.set, err), + [showTokenError.set] ); const clearErrors = useCallback(() => { - showUsernameError.set(''); - showPasswordError.set(''); - }, [showUsernameError.set, showPasswordError.set]); + showEmailError.set(''); + showTokenError.set(''); + }, [showEmailError, showTokenError]); const validateForm = useCallback((): FormErrors => { - const errors: FormErrors = { username: '', password: '' }; - - if (!username.value.trim()) { - errors.username = 'Please enter your Bitbucket username'; + const errors: FormErrors = { email: '', token: '' }; + + if (!email.value.trim()) { + errors.email = 'Please enter your Bitbucket email'; } - - if (!password.value.trim()) { - errors.password = 'Please enter your App Password'; + + if (!token.value.trim()) { + errors.token = 'Please enter your API Token'; } - + return errors; - }, [username.value, password.value]); + }, [email.value, token.value]); - const handleUsernameChange = useCallback((val: string) => { - username.set(val); - if (showUsernameError.value) { - showUsernameError.set(''); - } - }, [username.set, showUsernameError.value, showUsernameError.set]); + const handleEmailChange = useCallback( + (val: string) => { + email.set(val); + if (showEmailError.value) { + showEmailError.set(''); + } + }, + [email, showEmailError] + ); - const handlePasswordChange = useCallback((val: string) => { - password.set(val); - if (showPasswordError.value) { - showPasswordError.set(''); - } - }, [password.set, showPasswordError.value, showPasswordError.set]); + const handleTokenChange = useCallback( + (val: string) => { + token.set(val); + if (showTokenError.value) { + showTokenError.set(''); + } + }, + [token, showTokenError] + ); const handleSubmission = useCallback(async () => { clearErrors(); - + const errors = validateForm(); - if (errors.username || errors.password) { - if (errors.username) setUsernameError(errors.username); - if (errors.password) setPasswordError(errors.password); + if (errors.email || errors.token) { + if (errors.email) setEmailError(errors.email); + if (errors.token) setTokenError(errors.token); return; } depFn(isLoading.true); - + try { - const res = await checkBitBucketValidity(username.value.trim(), password.value); - - const scopeHeader = res.headers?.["X-Oauth-Scopes"] || res.headers?.["x-oauth-scopes"]; - - if (!scopeHeader) { - throw new Error('Unable to verify App Password permissions. Please ensure your App Password has the required scopes.'); - } - - const scopes = scopeHeader.split(',').map((s: string) => s.trim()).filter(Boolean); - const missing = getMissingBitBucketScopes(scopes); + const res = await checkBitBucketValidity(email.value.trim(), token.value); - if (missing.length > 0) { - throw new Error(`App Password is missing required scopes: ${missing.join(', ')}. Please regenerate with all required permissions.`); - } + // const scopeHeader = + // res.data.headers?.['X-Oauth-Scopes'] || + // res.data.headers?.['x-oauth-scopes']; + // console.log(scopeHeader); + // if (!scopeHeader) { + // throw new Error( + // 'Unable to verify API Token permissions. Please ensure your API Token has the required scopes.' + // ); + // } + + // const scopes = scopeHeader + // .split(',') + // .map((s: string) => s.trim()) + // .filter(Boolean); + // const missing = getMissingBitBucketScopes(scopes); + + // if (missing.length > 0) { + // throw new Error( + // `API Token is missing required scopes: ${missing.join( + // ', ' + // )}. Please regenerate with all required permissions.` + // ); + // } - const encodedCredentials = btoa(`${username.value.trim()}:${password.value}`); + const encodedCredentials = btoa(`${email.value.trim()}:${token.value}`); await linkProvider(encodedCredentials, orgId, Integration.BITBUCKET, { - username: username.value.trim() + email: email.value.trim() }); await Promise.all([ @@ -123,25 +138,30 @@ export const ConfigureBitbucketModalBody: FC = variant: 'success', autoHideDuration: 3000 }); - + onClose(); } catch (err: any) { console.error('Error linking Bitbucket:', err); - - const errorMessage = err.message || 'Failed to link Bitbucket. Please try again.'; - + + const errorMessage = + err.message || 'Failed to link Bitbucket. Please try again.'; + // Categorize errors for better UX - if (errorMessage.toLowerCase().includes('username') || - errorMessage.toLowerCase().includes('user not found')) { - setUsernameError(errorMessage); - } else if (errorMessage.toLowerCase().includes('password') || - errorMessage.toLowerCase().includes('unauthorized') || - errorMessage.toLowerCase().includes('authentication')) { - setPasswordError('Invalid App Password. Please check your credentials.'); + if ( + errorMessage.toLowerCase().includes('email') || + errorMessage.toLowerCase().includes('user not found') + ) { + setEmailError(errorMessage); + } else if ( + errorMessage.toLowerCase().includes('token') || + errorMessage.toLowerCase().includes('unauthorized') || + errorMessage.toLowerCase().includes('authentication') + ) { + setTokenError('Invalid API Token. Please check your credentials.'); } else if (errorMessage.toLowerCase().includes('scope')) { - setPasswordError(errorMessage); + setTokenError(errorMessage); } else { - setPasswordError(errorMessage); + setTokenError(errorMessage); } } finally { depFn(isLoading.false); @@ -149,52 +169,56 @@ export const ConfigureBitbucketModalBody: FC = }, [ clearErrors, validateForm, - username.value, - password.value, + email.value, + token.value, isLoading, - setUsernameError, - setPasswordError, + setEmailError, + setTokenError, orgId, dispatch, enqueueSnackbar, onClose ]); - const handleKeyDown = useCallback((e: React.KeyboardEvent, action: 'focus-password' | 'submit') => { - if (e.key === 'Enter') { - e.preventDefault(); - if (action === 'focus-password') { - document.getElementById('bitbucket-password')?.focus(); - } else { - handleSubmission(); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent, action: 'focus-token' | 'submit') => { + if (e.key === 'Enter') { + e.preventDefault(); + if (action === 'focus-token') { + document.getElementById('bitbucket-token')?.focus(); + } else { + handleSubmission(); + } } - } - }, [handleSubmission]); + }, + [handleSubmission] + ); return ( handleUsernameChange(e.currentTarget.value)} - onKeyDown={(e) => handleKeyDown(e, 'focus-password')} + id="bitbucket-email" + label="Email" + type="email" + error={!!showEmailError.value} + helperText={showEmailError.value} + value={email.value} + onChange={(e) => handleEmailChange(e.currentTarget.value)} + onKeyDown={(e) => handleKeyDown(e, 'focus-token')} disabled={isLoading.value} fullWidth - autoComplete="username" + autoComplete="email" /> handlePasswordChange(e.currentTarget.value)} + label="API Token" + error={!!showTokenError.value} + helperText={showTokenError.value} + value={token.value} + onChange={(e) => handleTokenChange(e.currentTarget.value)} onKeyDown={(e) => handleKeyDown(e, 'submit')} disabled={isLoading.value} fullWidth @@ -204,12 +228,12 @@ export const ConfigureBitbucketModalBody: FC = - Generate an App Password{' '} + Generate an API Token{' '} here @@ -219,7 +243,7 @@ export const ConfigureBitbucketModalBody: FC = loading={isLoading.value} variant="contained" onClick={handleSubmission} - disabled={!username.value.trim() || !password.value.trim()} + disabled={!email.value.trim() || !token.value.trim()} > Link Bitbucket @@ -233,7 +257,7 @@ export const ConfigureBitbucketModalBody: FC = const TokenPermissions: FC = () => { const imageLoaded = useBoolState(false); - + const expandedStyles = useMemo(() => { const base = { border: `2px solid ${alpha('#2684FF', 0.6)}`, @@ -245,14 +269,14 @@ const TokenPermissions: FC = () => { maxWidth: 'calc(100% - 48px)', left: '12px' }; - + const positions = [ { top: '300px', height: '32px' }, { top: '360px', height: '32px' }, { top: '420px', height: '32px' } ]; - - return positions.map(cfg => ({ ...cfg, ...base })); + + return positions.map((cfg) => ({ ...cfg, ...base })); }, []); return ( @@ -274,17 +298,18 @@ const TokenPermissions: FC = () => { height={976} alt="Bitbucket App Password required permissions setup" onLoadingComplete={imageLoaded.true} - style={{ - opacity: imageLoaded.value ? 1 : 0, - transition: 'opacity 0.8s ease', - filter: 'invert(1)' + style={{ + opacity: imageLoaded.value ? 1 : 0, + transition: 'opacity 0.8s ease', + filter: 'invert(1)' }} priority /> - {imageLoaded.value && expandedStyles.map((style, index) => ( - - ))} + {imageLoaded.value && + expandedStyles.map((style, index) => ( + + ))} {!imageLoaded.value && ( => { - if (!username?.trim() || !password?.trim()) { - throw new Error('Username and App Password are required'); + if (!email?.trim() || !apiToken?.trim()) { + throw new Error('Email and API Token are required'); + } + + // Basic email validation + const trimmedEmail = email.trim(); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(trimmedEmail)) { + throw new Error('Please enter a valid email address.'); } try { const response = await axios.post( - "/api/integrations/bitbucket/scopes", + '/api/integrations/bitbucket/scopes', { - username: username.trim(), - appPassword: password + email: trimmedEmail, + apiToken: apiToken } as BitBucketCredentials, { headers: { 'Content-Type': 'application/json' }, - timeout: 10000 + timeout: 15000 // Increased timeout for better reliability } ); + + // Validate response structure + if (!response.data || !response.data.headers) { + throw new Error('Invalid response from BitBucket API'); + } + return response.data; } catch (error: any) { if (error.code === 'ECONNABORTED') { - throw new Error('Request timeout. Please check your internet connection and try again.'); + throw new Error( + 'Request timeout. Please check your internet connection and try again.' + ); } - + if (error.response?.status === 401) { - throw new Error('Invalid username or App Password. Please verify your credentials.'); + throw new Error( + 'Invalid email or API Token. Please verify your credentials.' + ); } - + if (error.response?.status === 403) { - throw new Error('Access forbidden. Please ensure your App Password has the required permissions.'); + throw new Error( + 'Access forbidden. Please ensure your API Token has the required permissions.' + ); } - + if (error.response?.status >= 500) { - throw new Error('BitBucket service is currently unavailable. Please try again later.'); + throw new Error( + 'Bitbucket service is currently unavailable. Please try again later.' + ); } - - const message = error.response?.data?.message || - error.message || - 'Unable to validate BitBucket credentials. Please try again.'; + + const message = + error.response?.data?.message || + error.message || + 'Unable to validate Bitbucket credentials. Please try again.'; throw new Error(message); } }; -const BITBUCKET_SCOPES = ['issue', 'pullrequest', 'project', 'account'] as const; +const BITBUCKET_SCOPES = [ + 'issue', + 'pullrequest', + 'project', + 'account' +] as const; export const getMissingBitBucketScopes = (userScopes: string[]): string[] => { if (!Array.isArray(userScopes)) { return [...BITBUCKET_SCOPES]; } - + const normalizedUserScopes = userScopes - .map(scope => scope.trim().toLowerCase()) + .map((scope) => scope.trim().toLowerCase()) .filter(Boolean); - + return BITBUCKET_SCOPES.filter( - requiredScope => !normalizedUserScopes.includes(requiredScope.toLowerCase()) + (requiredScope) => + !normalizedUserScopes.includes(requiredScope.toLowerCase()) ); -}; \ No newline at end of file +};