diff --git a/api/integrations/gitlab/exceptions.py b/api/integrations/gitlab/exceptions.py index f332841d7bd8..be94cc3b13e8 100644 --- a/api/integrations/gitlab/exceptions.py +++ b/api/integrations/gitlab/exceptions.py @@ -3,4 +3,6 @@ class DuplicateGitLabIntegration(APIException): status_code = 400 - default_detail = "A GitLab integration already exists for this project and repository." + default_detail = ( + "A GitLab integration already exists for this project and repository." + ) diff --git a/api/integrations/gitlab/views.py b/api/integrations/gitlab/views.py index 950918c6d3db..e6d6676cce9c 100644 --- a/api/integrations/gitlab/views.py +++ b/api/integrations/gitlab/views.py @@ -96,7 +96,9 @@ class GitLabConfigurationViewSet(viewsets.ModelViewSet): # type: ignore[type-ar def get_serializer_class( self, - ) -> type[GitLabConfigurationSerializer] | type[GitLabConfigurationCreateSerializer]: + ) -> ( + type[GitLabConfigurationSerializer] | type[GitLabConfigurationCreateSerializer] + ): if self.action == "create": return GitLabConfigurationCreateSerializer return GitLabConfigurationSerializer diff --git a/api/projects/urls.py b/api/projects/urls.py index 09f571caec86..4889ff793be5 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -21,11 +21,13 @@ from integrations.datadog.views import DataDogConfigurationViewSet from integrations.gitlab.views import ( GitLabConfigurationViewSet, - fetch_issues as gitlab_fetch_issues, fetch_merge_requests, fetch_project_members, fetch_projects, ) +from integrations.gitlab.views import ( + fetch_issues as gitlab_fetch_issues, +) from integrations.grafana.views import GrafanaProjectConfigurationViewSet from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index df68667a4f6b..acca3d6df8ee 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -492,6 +492,10 @@ const Constants = { githubIssue: 'GitHub Issue', githubPR: 'Github PR', }, + gitlabType: { + gitlabIssue: 'GitLab Issue', + gitlabMR: 'GitLab MR', + }, integrationCategoryDescriptions: { 'All': 'Send data on what flags served to each identity.', 'Analytics': 'Send data on what flags served to each identity.', @@ -685,6 +689,18 @@ const Constants = { resourceType: 'pulls', type: 'GITHUB', }, + GITLAB_ISSUE: { + id: 3, + label: 'Issue', + resourceType: 'issues', + type: 'GITLAB', + }, + GITLAB_MR: { + id: 4, + label: 'Merge Request', + resourceType: 'merge_requests', + type: 'GITLAB', + }, }, roles: { 'ADMIN': 'Organisation Administrator', diff --git a/frontend/common/hooks/useHasGitlabIntegration.ts b/frontend/common/hooks/useHasGitlabIntegration.ts new file mode 100644 index 000000000000..fb961cd5bf91 --- /dev/null +++ b/frontend/common/hooks/useHasGitlabIntegration.ts @@ -0,0 +1,12 @@ +import { useGetGitlabIntegrationQuery } from 'common/services/useGitlabIntegration' + +export function useHasGitlabIntegration(projectId: number) { + const { data } = useGetGitlabIntegrationQuery( + { project_id: projectId }, + { skip: !projectId }, + ) + + return { + hasIntegration: !!data?.results?.length, + } +} diff --git a/frontend/common/services/useGitlab.ts b/frontend/common/services/useGitlab.ts new file mode 100644 index 000000000000..219618674001 --- /dev/null +++ b/frontend/common/services/useGitlab.ts @@ -0,0 +1,73 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' +import Utils from 'common/utils/utils' + +export const gitlabService = service + .enhanceEndpoints({ addTagTypes: ['Gitlab'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + getGitlabProjects: builder.query< + Res['gitlabProjects'], + Req['getGitlabProjects'] + >({ + providesTags: [{ id: 'LIST', type: 'Gitlab' }], + query: (query: Req['getGitlabProjects']) => ({ + url: `projects/${query.project_id}/gitlab/projects/`, + }), + }), + getGitlabResources: builder.query< + Res['gitlabResources'], + Req['getGitlabResources'] + >({ + providesTags: [{ id: 'LIST', type: 'Gitlab' }], + query: (query: Req['getGitlabResources']) => ({ + url: + `projects/${query.project_id}/gitlab/${query.gitlab_resource}/` + + `?${Utils.toParam({ + gitlab_project_id: query.gitlab_project_id, + page: query.page, + page_size: query.page_size, + project_name: query.project_name, + })}`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function getGitlabResources( + store: any, + data: Req['getGitlabResources'], + options?: Parameters< + typeof gitlabService.endpoints.getGitlabResources.initiate + >[1], +) { + return store.dispatch( + gitlabService.endpoints.getGitlabResources.initiate(data, options), + ) +} +export async function getGitlabProjects( + store: any, + data: Req['getGitlabProjects'], + options?: Parameters< + typeof gitlabService.endpoints.getGitlabProjects.initiate + >[1], +) { + return store.dispatch( + gitlabService.endpoints.getGitlabProjects.initiate(data, options), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useGetGitlabProjectsQuery, + useGetGitlabResourcesQuery, + // END OF EXPORTS +} = gitlabService + +/* Usage examples: +const { data, isLoading } = useGetGitlabResourcesQuery({ project_id: 2, gitlab_resource: 'issues', gitlab_project_id: 1, project_name: 'my-project' }, {}) //get hook +const { data, isLoading } = useGetGitlabProjectsQuery({ project_id: 2 }, {}) //get hook +gitlabService.endpoints.getGitlabProjects.select({ project_id: 2 })(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useGitlabIntegration.ts b/frontend/common/services/useGitlabIntegration.ts new file mode 100644 index 000000000000..eb98a32ac4bf --- /dev/null +++ b/frontend/common/services/useGitlabIntegration.ts @@ -0,0 +1,118 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const gitlabIntegrationService = service + .enhanceEndpoints({ addTagTypes: ['GitlabIntegration'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createGitlabIntegration: builder.mutation< + Res['gitlabIntegrations'], + Req['createGitlabIntegration'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GitlabIntegration' }], + query: (query: Req['createGitlabIntegration']) => ({ + body: query.body, + method: 'POST', + url: `projects/${query.project_id}/integrations/gitlab/`, + }), + }), + deleteGitlabIntegration: builder.mutation< + Res['gitlabIntegrations'], + Req['deleteGitlabIntegration'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GitlabIntegration' }], + query: (query: Req['deleteGitlabIntegration']) => ({ + method: 'DELETE', + url: `projects/${query.project_id}/integrations/gitlab/${query.gitlab_integration_id}/`, + }), + }), + getGitlabIntegration: builder.query< + Res['gitlabIntegrations'], + Req['getGitlabIntegration'] + >({ + providesTags: [{ id: 'LIST', type: 'GitlabIntegration' }], + query: (query: Req['getGitlabIntegration']) => ({ + url: `projects/${query.project_id}/integrations/gitlab/`, + }), + }), + updateGitlabIntegration: builder.mutation< + Res['gitlabIntegrations'], + Req['updateGitlabIntegration'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GitlabIntegration' }], + query: (query: Req['updateGitlabIntegration']) => ({ + body: query.body, + method: 'PATCH', + url: `projects/${query.project_id}/integrations/gitlab/${query.gitlab_integration_id}/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createGitlabIntegration( + store: any, + data: Req['createGitlabIntegration'], + options?: Parameters< + typeof gitlabIntegrationService.endpoints.createGitlabIntegration.initiate + >[1], +) { + return store.dispatch( + gitlabIntegrationService.endpoints.createGitlabIntegration.initiate( + data, + options, + ), + ) +} +export async function deleteGitlabIntegration( + store: any, + data: Req['deleteGitlabIntegration'], + options?: Parameters< + typeof gitlabIntegrationService.endpoints.deleteGitlabIntegration.initiate + >[1], +) { + return store.dispatch( + gitlabIntegrationService.endpoints.deleteGitlabIntegration.initiate( + data, + options, + ), + ) +} +export async function getGitlabIntegration( + store: any, + data: Req['getGitlabIntegration'], + options?: Parameters< + typeof gitlabIntegrationService.endpoints.getGitlabIntegration.initiate + >[1], +) { + return store.dispatch( + gitlabIntegrationService.endpoints.getGitlabIntegration.initiate( + data, + options, + ), + ) +} +export async function updateGitlabIntegration( + store: any, + data: Req['updateGitlabIntegration'], + options?: Parameters< + typeof gitlabIntegrationService.endpoints.updateGitlabIntegration.initiate + >[1], +) { + return store.dispatch( + gitlabIntegrationService.endpoints.updateGitlabIntegration.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateGitlabIntegrationMutation, + useDeleteGitlabIntegrationMutation, + useGetGitlabIntegrationQuery, + useUpdateGitlabIntegrationMutation, + // END OF EXPORTS +} = gitlabIntegrationService diff --git a/frontend/common/stores/default-flags.ts b/frontend/common/stores/default-flags.ts index 7bd8d4415353..8db4e8e48100 100644 --- a/frontend/common/stores/default-flags.ts +++ b/frontend/common/stores/default-flags.ts @@ -87,6 +87,32 @@ const defaultFlags = { 'tags': ['logging'], 'title': 'Dynatrace', }, + 'gitlab': { + 'description': + 'View your Flagsmith flags inside GitLab issues and merge requests.', + 'docs': + 'https://docs.flagsmith.com/integrations/project-management/gitlab', + 'fields': [ + { + 'default': 'https://gitlab.com', + 'key': 'gitlab_instance_url', + 'label': 'GitLab Instance URL', + }, + { + 'hidden': true, + 'key': 'access_token', + 'label': 'Access Token', + }, + { + 'key': 'webhook_secret', + 'label': 'Webhook Secret', + }, + ], + 'image': '/static/images/integrations/gitlab.svg', + 'isGitlabIntegration': true, + 'perEnvironment': false, + 'title': 'GitLab', + }, 'grafana': { 'description': 'Receive Flagsmith annotations to your Grafana instance on feature flag and segment changes.', diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index fc48e4e17841..3abb9fdb7bce 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -623,6 +623,36 @@ export type Req = { github_resource: string }> getGithubRepos: { installation_id: string; organisation_id: number } + // GitLab + getGitlabIntegration: { project_id: number; id?: number } + createGitlabIntegration: { + project_id: number + body: { + gitlab_instance_url: string + access_token: string + webhook_secret: string + } + } + updateGitlabIntegration: { + project_id: number + gitlab_integration_id: number + body: { + gitlab_project_id?: number + project_name?: string + tagging_enabled?: boolean + } + } + deleteGitlabIntegration: { + project_id: number + gitlab_integration_id: number + } + getGitlabResources: PagedRequest<{ + project_id: number + gitlab_project_id: number + project_name: string + gitlab_resource: string + }> + getGitlabProjects: { project_id: number } getServersideEnvironmentKeys: { environmentId: string } deleteServersideEnvironmentKeys: { environmentId: string; id: string } createServersideEnvironmentKeys: { diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 2265891e198c..5e8e2c85d33e 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -321,6 +321,7 @@ export type IntegrationData = { image: string fields: IntegrationField[] | undefined isExternalInstallation: boolean + isGitlabIntegration?: boolean perEnvironment: boolean title?: string organisation?: string @@ -1162,6 +1163,26 @@ export type Res = { githubRepository: PagedResponse githubResources: GitHubPagedResponse githubRepos: GithubPaginatedRepos + // GitLab + gitlabIntegration: { + id: number + gitlab_instance_url: string + webhook_secret: string + project: number + } + gitlabIntegrations: PagedResponse + GitlabResource: { + web_url: string + id: number + iid: number + title: string + state: string + merged: boolean + draft: boolean + } + gitlabResources: PagedResponse + GitlabProject: { id: number; name: string; path_with_namespace: string } + gitlabProjects: PagedResponse segmentPriorities: {} featureSegment: FeatureState['feature_segment'] featureVersions: PagedResponse diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 88389946f1a4..65098a70af0d 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -377,10 +377,12 @@ const Utils = Object.assign({}, require('./base/_utils'), { return 'identities' }, getIntegrationData() { - return Utils.getFlagsmithJSONValue( + const data = Utils.getFlagsmithJSONValue( 'integration_data', defaultFlags.integration_data, ) + // Merge default integration entries that may not be in the remote flag yet + return { ...defaultFlags.integration_data, ...data } }, getIsEdge() { const model = ProjectStore.model as null | ProjectType diff --git a/frontend/web/components/ExternalResourcesLinkTab.tsx b/frontend/web/components/ExternalResourcesLinkTab.tsx index f3c34443e4d3..9c0dc1078674 100644 --- a/frontend/web/components/ExternalResourcesLinkTab.tsx +++ b/frontend/web/components/ExternalResourcesLinkTab.tsx @@ -1,44 +1,55 @@ import React, { FC, useState } from 'react' -import ExternalResourcesTable, { - ExternalResourcesTableBase, -} from './ExternalResourcesTable' -import { ExternalResource, GithubResource } from 'common/types/responses' +import ExternalResourcesTable from './ExternalResourcesTable' +import { ExternalResource, GithubResource, Res } from 'common/types/responses' import { useCreateExternalResourceMutation } from 'common/services/useExternalResource' import Constants from 'common/constants' import GitHubResourcesSelect from './GitHubResourcesSelect' +import GitLabResourcesSelect from './GitLabResourcesSelect' import AppActions from 'common/dispatcher/app-actions' +type VcsProvider = 'github' | 'gitlab' + type ExternalResourcesLinkTabType = { githubId: string + hasIntegrationWithGitlab: boolean organisationId: number featureId: string projectId: number environmentId: string } -type AddExternalResourceRowType = ExternalResourcesTableBase & { - selectedResources?: ExternalResource[] - environmentId: string - githubId: string -} - const ExternalResourcesLinkTab: FC = ({ environmentId, featureId, githubId, + hasIntegrationWithGitlab, organisationId, projectId, }) => { const githubTypes = Object.values(Constants.resourceTypes).filter( (v) => v.type === 'GITHUB', ) + const gitlabTypes = Object.values(Constants.resourceTypes).filter( + (v) => v.type === 'GITLAB', + ) + + const hasGithub = !!githubId + const hasGitlab = hasIntegrationWithGitlab + + const defaultProvider: VcsProvider = + hasGitlab && !hasGithub ? 'gitlab' : 'github' + const defaultResourceType = + defaultProvider === 'gitlab' + ? gitlabTypes[0]?.resourceType + : githubTypes[0]?.resourceType + const [vcsProvider, setVcsProvider] = useState(defaultProvider) const [createExternalResource] = useCreateExternalResourceMutation() - const [resourceType, setResourceType] = useState(githubTypes[0].resourceType) + const [resourceType, setResourceType] = useState(defaultResourceType) const [selectedResources, setSelectedResources] = useState() - const addResource = (featureExternalResource: GithubResource) => { + const addGithubResource = (featureExternalResource: GithubResource) => { const type = Object.keys(Constants.resourceTypes).find( (key: string) => Constants.resourceTypes[key as keyof typeof Constants.resourceTypes] @@ -63,16 +74,90 @@ const ExternalResourcesLinkTab: FC = ({ AppActions.refreshFeatures(projectId, environmentId) }) } + + const addGitlabResource = ( + featureExternalResource: Res['GitlabResource'], + ) => { + const type = Object.keys(Constants.resourceTypes).find((key: string) => { + const rt = + Constants.resourceTypes[key as keyof typeof Constants.resourceTypes] + return rt.resourceType === resourceType && rt.type === 'GITLAB' + }) + createExternalResource({ + body: { + feature: parseInt(featureId), + metadata: { + 'draft': featureExternalResource.draft, + 'merged': featureExternalResource.merged, + 'state': featureExternalResource.state, + 'title': featureExternalResource.title, + }, + type: type, + url: featureExternalResource.web_url, + }, + feature_id: featureId, + project_id: projectId, + }).then((res) => { + if ('error' in res) { + toast(`Error adding resource: ${JSON.stringify(res.error)}`, 'danger') + } else { + toast('External Resource Added') + } + AppActions.refreshFeatures(projectId, environmentId) + }) + } + + const handleProviderChange = (provider: VcsProvider) => { + setVcsProvider(provider) + if (provider === 'gitlab') { + setResourceType(gitlabTypes[0]?.resourceType) + } else { + setResourceType(githubTypes[0]?.resourceType) + } + } + return ( <> - v.url!)} - orgId={organisationId} - /> + {hasGithub && hasGitlab && ( +
+ + +
+ )} + {vcsProvider === 'gitlab' && hasGitlab ? ( + v.url ?? '')} + projectId={`${projectId}`} + linkedExternalResources={selectedResources} + /> + ) : ( + v.url ?? '')} + orgId={organisationId as unknown as string} + linkedExternalResources={selectedResources} + /> + )} { + const isGitlab = type?.startsWith('GITLAB') + const match = url.match(/\/(\d+)\/?$/) + const num = match ? match[1] : url.replace(/\D/g, '') + return isGitlab && type === 'GITLAB_MR' ? `!${num}` : `#${num}` +} + export type ExternalResourcesTableBase = { featureId: string projectId: string @@ -61,9 +68,10 @@ const ExternalResourceRow: FC = ({ - {`${ - externalResource?.metadata?.title - } (#${externalResource?.url.replace(/\D/g, '')})`}{' '} + {`${externalResource?.metadata?.title} (${getResourceNumber( + externalResource?.url, + externalResource?.type, + )})`}{' '}
diff --git a/frontend/web/components/GitLabResourcesSelect.tsx b/frontend/web/components/GitLabResourcesSelect.tsx new file mode 100644 index 000000000000..6cd3571c7532 --- /dev/null +++ b/frontend/web/components/GitLabResourcesSelect.tsx @@ -0,0 +1,152 @@ +import React, { FC, useState } from 'react' +import { ExternalResource, Res } from 'common/types/responses' +import Utils from 'common/utils/utils' +import useInfiniteScroll from 'common/useInfiniteScroll' +import { Req } from 'common/types/requests' +import { + useGetGitlabProjectsQuery, + useGetGitlabResourcesQuery, +} from 'common/services/useGitlab' +import Constants from 'common/constants' + +export type GitLabResourcesSelectType = { + onChange: (value: string) => void + linkedExternalResources: ExternalResource[] | undefined + projectId: string + resourceType: string + value: string[] | undefined // an array of resource URLs + setResourceType: (value: string) => void +} + +type GitLabResourcesValueType = { + value: string +} + +const GitLabResourcesSelect: FC = ({ + onChange, + projectId, + resourceType, + setResourceType, + value, +}) => { + const gitlabTypes = Object.values(Constants.resourceTypes).filter( + (v) => v.type === 'GITLAB', + ) + const [selectedProject, setSelectedProject] = useState('') + const gitlabProjectId = selectedProject + ? parseInt(selectedProject.split('::')[0]) + : undefined + const projectName = selectedProject + ? selectedProject.split('::')[1] + : undefined + + const { data: gitlabProjects } = useGetGitlabProjectsQuery({ + project_id: parseInt(projectId), + }) + + const { data, isFetching, isLoading, searchItems } = useInfiniteScroll< + Req['getGitlabResources'], + Res['gitlabResources'] + >( + useGetGitlabResourcesQuery, + { + gitlab_project_id: gitlabProjectId || 0, + gitlab_resource: resourceType, + page_size: 100, + project_id: parseInt(projectId), + project_name: projectName || '', + }, + 100, + { skip: !resourceType || !projectId || !gitlabProjectId || !projectName }, + ) + + const [searchText, setSearchText] = React.useState('') + + return ( + <> + +
+
+ v.resourceType === resourceType)} + onChange={(v: { resourceType: string }) => + setResourceType(v.resourceType) + } + options={gitlabTypes.map((e) => { + return { + label: e.label, + resourceType: e.resourceType, + value: e.id, + } + })} + /> +
+
+ {!!gitlabProjectId && !!projectName && ( +
+ { + const found = gitlabProjects?.results?.find((p) => p.id === v.value) + setSelectedGitlabProject(found || null) + }} + options={gitlabProjects?.results?.map((p) => ({ label: p.path_with_namespace, value: p.id }))} + /> + {selectedGitlabProject && ( + + )} +
+ )} + + + {gitlabIntegration.project_name && ( +
+ + { + onUpdate({ + body: { tagging_enabled: !gitlabIntegration.tagging_enabled }, + gitlab_integration_id: gitlabIntegration.id, + project_id: parseInt(projectId), + }) + }} + /> + Enable automatic tagging of features based on issue/MR state + +
+ )} + +
+ +

+ Add this webhook URL to your GitLab project or group settings. Enable triggers for: Issues events, Merge request events. +

+ + + {webhookUrl} + + + + + + {showSecret ? gitlabIntegration.webhook_secret : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'} + + + + +
+ +
+ +
+ + ) +} + +export default GitLabIntegrationDetails diff --git a/frontend/web/components/pages/GitLabSetupPage.tsx b/frontend/web/components/pages/GitLabSetupPage.tsx new file mode 100644 index 000000000000..a322bf2249dd --- /dev/null +++ b/frontend/web/components/pages/GitLabSetupPage.tsx @@ -0,0 +1,62 @@ +import React, { FC } from 'react' +import { + useCreateGitlabIntegrationMutation, + useDeleteGitlabIntegrationMutation, + useGetGitlabIntegrationQuery, + useUpdateGitlabIntegrationMutation, +} from 'common/services/useGitlabIntegration' +import { useGetGitlabProjectsQuery } from 'common/services/useGitlab' +import Project from 'common/project' +import GitLabIntegrationDetails from './GitLabIntegrationDetails' +import CreateGitLabIntegrationForm from './CreateGitLabIntegrationForm' + +type GitLabSetupPageType = { + projectId: string +} + +const GitLabSetupPage: FC = ({ projectId }) => { + const [createGitlabIntegration, { isLoading: isCreating }] = + useCreateGitlabIntegrationMutation() + const [updateGitlabIntegration] = useUpdateGitlabIntegrationMutation() + const [deleteGitlabIntegration] = useDeleteGitlabIntegrationMutation() + + const { data: gitlabIntegrations } = useGetGitlabIntegrationQuery( + { project_id: parseInt(projectId) }, + { skip: !projectId }, + ) + + const gitlabIntegration = gitlabIntegrations?.results?.[0] + + const { data: gitlabProjects } = useGetGitlabProjectsQuery( + { project_id: parseInt(projectId) }, + { skip: !gitlabIntegration }, + ) + + const apiHost = Project.api + ? Project.api.replace(/\/api\/v1\/?$/, '') + : window.location.origin + const webhookUrl = `${apiHost}/api/v1/gitlab-webhook/${projectId}/` + + if (gitlabIntegration) { + return ( + + ) + } + + return ( + + ) +} + +export default GitLabSetupPage