diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index 81b4d22040d1..ad50c0a183bf 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -617,6 +617,18 @@ const Constants = { resourceType: 'pulls', type: 'GITHUB', }, + GITLAB_ISSUE: { + id: 3, + label: 'Issue', + resourceType: 'issue', + type: 'GITLAB', + }, + GITLAB_MR: { + id: 4, + label: 'Merge Request', + resourceType: 'merge_request', + 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..bddc7595ebf5 --- /dev/null +++ b/frontend/common/hooks/useHasGitLabIntegration.ts @@ -0,0 +1,9 @@ +import { useGetGitLabConfigurationQuery } from 'common/services/useGitlabConfiguration' + +export function useHasGitLabIntegration(projectId: number) { + const { data } = useGetGitLabConfigurationQuery( + { project_id: projectId }, + { skip: !projectId }, + ) + return { hasIntegration: !!data?.length } +} diff --git a/frontend/common/services/useGitlab.ts b/frontend/common/services/useGitlab.ts new file mode 100644 index 000000000000..902448fbe0a8 --- /dev/null +++ b/frontend/common/services/useGitlab.ts @@ -0,0 +1,98 @@ +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) => ({ + getGitLabIssues: builder.query< + Res['gitlabIssues'], + Req['getGitLabIssues'] + >({ + providesTags: [{ id: 'LIST', type: 'GitLab' }], + query: (query: Req['getGitLabIssues']) => ({ + url: `projects/${query.project_id}/gitlab/issues/?${Utils.toParam({ + gitlab_project_id: query.gitlab_project_id, + page: query.page ?? 1, + page_size: query.page_size ?? 100, + search_text: query.q || undefined, + state: 'opened', // Only open items are linkable to feature flags. + })}`, + }), + }), + getGitLabMergeRequests: builder.query< + Res['gitlabMergeRequests'], + Req['getGitLabMergeRequests'] + >({ + providesTags: [{ id: 'LIST', type: 'GitLab' }], + query: (query: Req['getGitLabMergeRequests']) => ({ + url: `projects/${ + query.project_id + }/gitlab/merge-requests/?${Utils.toParam({ + gitlab_project_id: query.gitlab_project_id, + page: query.page ?? 1, + page_size: query.page_size ?? 100, + search_text: query.q || undefined, + state: 'opened', // Only open items are linkable to feature flags. + })}`, + }), + }), + getGitLabProjects: builder.query< + Res['gitlabProjects'], + Req['getGitLabProjects'] + >({ + providesTags: [{ id: 'LIST', type: 'GitLab' }], + query: (query: Req['getGitLabProjects']) => ({ + url: `projects/${query.project_id}/gitlab/projects/?${Utils.toParam({ + page: query.page ?? 1, + page_size: query.page_size ?? 100, + })}`, + }), + }), + // END OF ENDPOINTS + }), + }) + +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), + ) +} +export async function getGitLabIssues( + store: any, + data: Req['getGitLabIssues'], + options?: Parameters< + typeof gitlabService.endpoints.getGitLabIssues.initiate + >[1], +) { + return store.dispatch( + gitlabService.endpoints.getGitLabIssues.initiate(data, options), + ) +} +export async function getGitLabMergeRequests( + store: any, + data: Req['getGitLabMergeRequests'], + options?: Parameters< + typeof gitlabService.endpoints.getGitLabMergeRequests.initiate + >[1], +) { + return store.dispatch( + gitlabService.endpoints.getGitLabMergeRequests.initiate(data, options), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useGetGitLabIssuesQuery, + useGetGitLabMergeRequestsQuery, + useGetGitLabProjectsQuery, + // END OF EXPORTS +} = gitlabService diff --git a/frontend/common/services/useGitlabConfiguration.ts b/frontend/common/services/useGitlabConfiguration.ts new file mode 100644 index 000000000000..2f68bd7bbcab --- /dev/null +++ b/frontend/common/services/useGitlabConfiguration.ts @@ -0,0 +1,46 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const gitlabConfigurationService = service + .enhanceEndpoints({ addTagTypes: ['GitLabConfiguration'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + getGitLabConfiguration: builder.query< + Res['gitlabConfiguration'], + Req['getGitLabConfiguration'] + >({ + providesTags: [{ id: 'LIST', type: 'GitLabConfiguration' }], + query: (query: Req['getGitLabConfiguration']) => ({ + url: `projects/${query.project_id}/integrations/gitlab/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function getGitLabConfiguration( + store: any, + data: Req['getGitLabConfiguration'], + options?: Parameters< + typeof gitlabConfigurationService.endpoints.getGitLabConfiguration.initiate + >[1], +) { + return store.dispatch( + gitlabConfigurationService.endpoints.getGitLabConfiguration.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useGetGitLabConfigurationQuery, + // END OF EXPORTS +} = gitlabConfigurationService + +/* Usage examples: +const { data, isLoading } = useGetGitLabConfigurationQuery({ project_id: 2 }, {}) //get hook +gitlabConfigurationService.endpoints.getGitLabConfiguration.select({project_id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index f53247ec6aa0..4ff277c266e2 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -936,5 +936,15 @@ export type Req = { code_challenge_method: string state?: string } + getGitLabConfiguration: { project_id: number } + getGitLabProjects: PagedRequest<{ project_id: number }> + getGitLabIssues: PagedRequest<{ + project_id: number + gitlab_project_id: number + }> + getGitLabMergeRequests: PagedRequest<{ + project_id: number + gitlab_project_id: number + }> // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index b28a763e6b94..69a33db88bb8 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -292,6 +292,37 @@ export type GithubResource = { draft: boolean } +export type GitLabConfiguration = { + id: number + gitlab_instance_url: string +} + +export type GitLabProject = { + id: number + name: string + path_with_namespace: string +} + +export type GitLabIssue = { + web_url: string + id: number + title: string + iid: number + state: string +} + +export type GitLabMergeRequest = { + web_url: string + id: number + title: string + iid: number + state: string + merged: boolean + draft: boolean +} + +export type GitLabLinkType = 'issue' | 'merge_request' + export type GithubPaginatedRepos = { total_count: number repository_selection: string @@ -1275,5 +1306,9 @@ export type Res = { processOAuthConsent: { redirect_uri: string } + gitlabConfiguration: GitLabConfiguration[] + gitlabProjects: PagedResponse + gitlabIssues: PagedResponse + gitlabMergeRequests: PagedResponse // END OF TYPES } diff --git a/frontend/web/components/GitLabLinkSection.tsx b/frontend/web/components/GitLabLinkSection.tsx new file mode 100644 index 000000000000..44b0a76e0593 --- /dev/null +++ b/frontend/web/components/GitLabLinkSection.tsx @@ -0,0 +1,99 @@ +import React, { FC, useState } from 'react' +import Constants from 'common/constants' +import ErrorMessage from './ErrorMessage' +import GitLabProjectSelect from './GitLabProjectSelect' +import GitLabSearchSelect from './GitLabSearchSelect' +import { useGetGitLabProjectsQuery } from 'common/services/useGitlab' +import type { + GitLabIssue, + GitLabLinkType, + GitLabMergeRequest, +} from 'common/types/responses' + +type GitLabLinkSectionProps = { + projectId: number + linkedUrls: string[] +} + +const GitLabLinkSection: FC = ({ + linkedUrls, + projectId, +}) => { + const gitlabTypes = Object.values(Constants.resourceTypes).filter( + (v) => v.type === 'GITLAB', + ) + + const [gitlabProjectId, setGitlabProjectId] = useState(null) + const [linkType, setLinkType] = useState('issue') + const [selectedItem, setSelectedItem] = useState< + GitLabIssue | GitLabMergeRequest | null + >(null) + + const { + data: projectsData, + isError: isProjectsError, + isLoading: isProjectsLoading, + } = useGetGitLabProjectsQuery({ + page: 1, + page_size: 100, + project_id: projectId, + }) + const projects = projectsData?.results ?? [] + + return ( +
+ +
+ +
+ o.value === value) ?? null} + onChange={(v: { value: number }) => onChange(v.value)} + options={options} + isLoading={isLoading} + isDisabled={isDisabled} + /> +
+ ) +} + +export default GitLabProjectSelect diff --git a/frontend/web/components/GitLabSearchSelect.tsx b/frontend/web/components/GitLabSearchSelect.tsx new file mode 100644 index 000000000000..4c2ef34fdb80 --- /dev/null +++ b/frontend/web/components/GitLabSearchSelect.tsx @@ -0,0 +1,83 @@ +import React, { FC } from 'react' +import useInfiniteScroll from 'common/useInfiniteScroll' +import { Req } from 'common/types/requests' +import { + Res, + type GitLabIssue, + type GitLabLinkType, + type GitLabMergeRequest, +} from 'common/types/responses' +import { + useGetGitLabIssuesQuery, + useGetGitLabMergeRequestsQuery, +} from 'common/services/useGitlab' + +type GitLabSearchSelectProps = { + projectId: number + gitlabProjectId: number + linkType: GitLabLinkType + value: GitLabIssue | GitLabMergeRequest | null + onChange: (selection: GitLabIssue | GitLabMergeRequest) => void + linkedUrls: string[] +} + +const GitLabSearchSelect: FC = ({ + gitlabProjectId, + linkType, + linkedUrls, + onChange, + projectId, + value, +}) => { + const useQuery = + linkType === 'issue' + ? useGetGitLabIssuesQuery + : (useGetGitLabMergeRequestsQuery as typeof useGetGitLabIssuesQuery) + + const { data, isFetching, isLoading, searchItems } = useInfiniteScroll< + Req['getGitLabIssues'], + Res['gitlabIssues'] + >( + useQuery, + { + gitlab_project_id: gitlabProjectId, + page_size: 100, + project_id: projectId, + }, + 100, + { skip: !gitlabProjectId }, + ) + + const options = data?.results + ?.filter((r) => !linkedUrls.includes(r.web_url)) + .map((r) => ({ label: `${r.title} #${r.iid}`, value: r })) + + return ( +
+