Skip to content
12 changes: 12 additions & 0 deletions frontend/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 9 additions & 0 deletions frontend/common/hooks/useHasGitLabIntegration.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Comment thread
Zaimwa9 marked this conversation as resolved.
98 changes: 98 additions & 0 deletions frontend/common/services/useGitlab.ts
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions frontend/common/services/useGitlabConfiguration.ts
Original file line number Diff line number Diff line change
@@ -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
*/
10 changes: 10 additions & 0 deletions frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
35 changes: 35 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
total_count: number
repository_selection: string
Expand Down Expand Up @@ -1275,5 +1306,9 @@ export type Res = {
processOAuthConsent: {
redirect_uri: string
}
gitlabConfiguration: GitLabConfiguration[]
gitlabProjects: PagedResponse<GitLabProject>
gitlabIssues: PagedResponse<GitLabIssue>
gitlabMergeRequests: PagedResponse<GitLabMergeRequest>
// END OF TYPES
}
99 changes: 99 additions & 0 deletions frontend/web/components/GitLabLinkSection.tsx
Original file line number Diff line number Diff line change
@@ -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<GitLabLinkSectionProps> = ({
linkedUrls,
projectId,
}) => {
const gitlabTypes = Object.values(Constants.resourceTypes).filter(
(v) => v.type === 'GITLAB',
)

const [gitlabProjectId, setGitlabProjectId] = useState<number | null>(null)
const [linkType, setLinkType] = useState<GitLabLinkType>('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 (
<div>
<label className='cols-sm-2 control-label'>
Link GitLab issue or merge request
</label>
<div className='d-flex gap-2 mb-2'>
<GitLabProjectSelect
projects={projects}
isLoading={isProjectsLoading}
isDisabled={isProjectsError}
value={gitlabProjectId}
onChange={setGitlabProjectId}
/>
<div style={{ width: 200 }}>
<Select
autoSelect
className='w-100 react-select'
size='select-md'
placeholder='Select type'
value={gitlabTypes.find((v) => v.resourceType === linkType)}
onChange={(v: { resourceType: GitLabLinkType }) => {
setLinkType(v.resourceType)
setSelectedItem(null)
}}
options={gitlabTypes.map((e) => ({
label: e.label,
resourceType: e.resourceType,
value: e.id,
}))}
/>
</div>
</div>
{isProjectsError && (
<ErrorMessage error='Failed to load GitLab projects' />
)}
Comment on lines +75 to +77
Copy link
Copy Markdown
Contributor

@Zaimwa9 Zaimwa9 Apr 18, 2026

Choose a reason for hiding this comment

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

Feel free to resolve.
Your question when to use toast versus ErrorMessage made me think and what could make sense to me is to show a toast following a punctual action that failed (button click etc, PUT / POST) and using an ErrorMessage on a component that can't be used persistently because fetching the data failed without action from the user.

Wdyt @talissoncosta ?

{gitlabProjectId != null && (
<>
<GitLabSearchSelect
projectId={projectId}
gitlabProjectId={gitlabProjectId}
linkType={linkType}
value={selectedItem}
onChange={(item) => setSelectedItem(item)}
linkedUrls={linkedUrls}
/>
<div className='text-right mt-2'>
<Button disabled theme='primary'>
Link
</Button>
</div>
</>
)}
</div>
)
}

export default GitLabLinkSection
40 changes: 40 additions & 0 deletions frontend/web/components/GitLabProjectSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { FC } from 'react'
import type { GitLabProject } from 'common/types/responses'

type GitLabProjectSelectProps = {
projects: GitLabProject[]
isLoading: boolean
isDisabled: boolean
value: number | null
onChange: (id: number) => void
}

const GitLabProjectSelect: FC<GitLabProjectSelectProps> = ({
isDisabled,
isLoading,
onChange,
projects,
value,
}) => {
const options = projects.map((p) => ({
label: p.path_with_namespace,
value: p.id,
}))

return (
<div style={{ minWidth: 250 }}>
<Select
className='w-100 react-select'
size='select-md'
placeholder={isLoading ? 'Loading...' : 'Select GitLab Project'}
value={options.find((o) => o.value === value) ?? null}
onChange={(v: { value: number }) => onChange(v.value)}
options={options}
isLoading={isLoading}
isDisabled={isDisabled}
/>
</div>
)
}

export default GitLabProjectSelect
Loading
Loading