diff --git a/api/integrations/gitlab/client/api.py b/api/integrations/gitlab/client/api.py index da6e0fdef892..e94f3aa3aff6 100644 --- a/api/integrations/gitlab/client/api.py +++ b/api/integrations/gitlab/client/api.py @@ -57,16 +57,21 @@ def fetch_gitlab_projects( *, page: int, page_size: int, + search_text: str | None = None, ) -> GitLabPage[GitLabProject]: + params = { + "membership": "true", + "per_page": str(page_size), + "page": str(page), + } + if search_text: + params["search"] = search_text + response = _get_from_gitlab_api( instance_url, access_token, path="projects", - params={ - "membership": "true", - "per_page": str(page_size), - "page": str(page), - }, + params=params, ) results: list[GitLabProject] = [ diff --git a/api/integrations/gitlab/serializers.py b/api/integrations/gitlab/serializers.py index 0c0297d29fb0..c4d00c40e54f 100644 --- a/api/integrations/gitlab/serializers.py +++ b/api/integrations/gitlab/serializers.py @@ -24,6 +24,10 @@ class PaginatedQueryParamsSerializer(serializers.Serializer[None]): page_size = serializers.IntegerField(default=100, min_value=1, max_value=100) +class ProjectSearchQueryParamsSerializer(PaginatedQueryParamsSerializer): + search_text = serializers.CharField(required=False, allow_blank=True) + + class SearchQueryParamsSerializer(PaginatedQueryParamsSerializer): gitlab_project_id = serializers.IntegerField() search_text = serializers.CharField(required=False, allow_blank=True) diff --git a/api/integrations/gitlab/views/browse_gitlab.py b/api/integrations/gitlab/views/browse_gitlab.py index acedec65ef98..aae5f71a0aff 100644 --- a/api/integrations/gitlab/views/browse_gitlab.py +++ b/api/integrations/gitlab/views/browse_gitlab.py @@ -22,6 +22,7 @@ from integrations.gitlab.models import GitLabConfiguration from integrations.gitlab.serializers import ( PaginatedQueryParamsSerializer, + ProjectSearchQueryParamsSerializer, SearchQueryParamsSerializer, ) from projects.permissions import NestedProjectPermissions @@ -100,6 +101,8 @@ def page_url(page: int) -> str: class BrowseGitLabProjects(_GitLabListView[GitLabProject]): + serializer_class = ProjectSearchQueryParamsSerializer + def fetch_page( self, config: GitLabConfiguration, @@ -110,6 +113,7 @@ def fetch_page( access_token=config.access_token, page=validated_data["page"], page_size=validated_data["page_size"], + search_text=validated_data.get("search_text"), ) self._log_for(config).info("projects.fetched") diff --git a/api/tests/unit/integrations/gitlab/test_client.py b/api/tests/unit/integrations/gitlab/test_client.py index c75828eeadf0..d356586f2813 100644 --- a/api/tests/unit/integrations/gitlab/test_client.py +++ b/api/tests/unit/integrations/gitlab/test_client.py @@ -115,6 +115,67 @@ def test_search_gitlab_issues__default_params__returns_issues() -> None: ] +@responses.activate +def test_fetch_gitlab_projects__with_search_text__sends_search_param() -> None: + # Given + responses.get( + f"{INSTANCE_URL}/api/v4/projects", + json=[], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "0"}, + match=[ + responses.matchers.header_matcher({"PRIVATE-TOKEN": ACCESS_TOKEN}), + responses.matchers.query_param_matcher( + { + "membership": "true", + "per_page": "100", + "page": "1", + "search": "my-project", + }, + strict_match=False, + ), + ], + ) + + # When + result = fetch_gitlab_projects( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + page=1, + page_size=100, + search_text="my-project", + ) + + # Then + assert result["results"] == [] + + +@responses.activate +def test_fetch_gitlab_projects__without_search_text__omits_search_param() -> None: + # Given + responses.get( + f"{INSTANCE_URL}/api/v4/projects", + json=[], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "0"}, + match=[ + responses.matchers.query_param_matcher( + {"membership": "true", "per_page": "100", "page": "1"}, + strict_match=True, + ), + ], + ) + + # When + result = fetch_gitlab_projects( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + page=1, + page_size=100, + ) + + # Then + assert result["results"] == [] + + @responses.activate def test_search_gitlab_issues__with_search_text__sends_search_param() -> None: # Given diff --git a/api/tests/unit/integrations/gitlab/test_proxy_views.py b/api/tests/unit/integrations/gitlab/test_proxy_views.py index 685248d243bb..6028882033da 100644 --- a/api/tests/unit/integrations/gitlab/test_proxy_views.py +++ b/api/tests/unit/integrations/gitlab/test_proxy_views.py @@ -56,6 +56,34 @@ def test_gitlab_project_list__valid_config__returns_paginated_response( ] +@pytest.mark.usefixtures("gitlab_config") +def test_gitlab_project_list__with_search_text__forwards_search_to_gitlab( + admin_client: APIClient, + project: Project, + mocker: MockerFixture, +) -> None: + # Given + mocked_fetch = mocker.patch( + "integrations.gitlab.views.browse_gitlab.fetch_gitlab_projects", + return_value={ + "results": [], + "current_page": 1, + "total_pages": 1, + "total_count": 0, + }, + ) + + # When + response = admin_client.get( + f"/api/v1/projects/{project.id}/gitlab/projects/", + {"page": "1", "page_size": "100", "search_text": "my-project"}, + ) + + # Then + assert response.status_code == status.HTTP_200_OK + assert mocked_fetch.call_args.kwargs["search_text"] == "my-project" + + def test_gitlab_project_list__no_gitlab_config__returns_400( admin_client: APIClient, project: Project, diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index 6185878c46b4..d5cec246bed3 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -299,7 +299,7 @@ Attributes: ### `gitlab.api_call.failed` Logged at `error` from: - - `api/integrations/gitlab/views/browse_gitlab.py:59` + - `api/integrations/gitlab/views/browse_gitlab.py:60` Attributes: - `exc_info` diff --git a/frontend/common/services/useGitlab.ts b/frontend/common/services/useGitlab.ts index 902448fbe0a8..784ca1d774b8 100644 --- a/frontend/common/services/useGitlab.ts +++ b/frontend/common/services/useGitlab.ts @@ -48,6 +48,7 @@ export const gitlabService = service url: `projects/${query.project_id}/gitlab/projects/?${Utils.toParam({ page: query.page ?? 1, page_size: query.page_size ?? 100, + search_text: query.q || undefined, })}`, }), }), diff --git a/frontend/common/useInfiniteScroll.ts b/frontend/common/useInfiniteScroll.ts index 49c3dd0eb39b..f2987c3921ff 100644 --- a/frontend/common/useInfiniteScroll.ts +++ b/frontend/common/useInfiniteScroll.ts @@ -78,6 +78,7 @@ const useInfiniteScroll = < return { data: combinedData, + isError: queryResponse.isError, isFetching: queryResponse.isFetching, isLoading: queryResponse.isLoading, loadMore, diff --git a/frontend/web/components/GitLabLinkSection.tsx b/frontend/web/components/GitLabLinkSection.tsx index c8802c35775c..147d9b4428f8 100644 --- a/frontend/web/components/GitLabLinkSection.tsx +++ b/frontend/web/components/GitLabLinkSection.tsx @@ -1,11 +1,19 @@ import React, { FC, useState } from 'react' import AppActions from 'common/dispatcher/app-actions' import ErrorMessage from './ErrorMessage' -import GitLabProjectSelect from './GitLabProjectSelect' +import GitLabProjectSelect, { + type GitLabProjectOption, +} from './GitLabProjectSelect' import GitLabSearchSelect from './GitLabSearchSelect' import { useCreateExternalResourceMutation } from 'common/services/useExternalResource' +import useInfiniteScroll from 'common/useInfiniteScroll' +import { Req } from 'common/types/requests' import { useGetGitLabProjectsQuery } from 'common/services/useGitlab' -import type { GitLabIssue, GitLabMergeRequest } from 'common/types/responses' +import { + Res, + type GitLabIssue, + type GitLabMergeRequest, +} from 'common/types/responses' type GitLabLinkSectionProps = { projectId: number @@ -30,7 +38,9 @@ const GitLabLinkSection: FC = ({ projectId, }) => { const [createExternalResource] = useCreateExternalResourceMutation() - const [gitlabProjectId, setGitlabProjectId] = useState(null) + const [selectedProject, setSelectedProject] = + useState(null) + const gitlabProjectId = selectedProject?.value ?? null const [linkType, setLinkType] = useState('GITLAB_ISSUE') const [selectedItem, setSelectedItem] = useState< GitLabIssue | GitLabMergeRequest | null @@ -39,12 +49,17 @@ const GitLabLinkSection: FC = ({ const { data: projectsData, isError: isProjectsError, + isFetching: isProjectsFetching, isLoading: isProjectsLoading, - } = useGetGitLabProjectsQuery({ - page: 1, - page_size: 100, - project_id: projectId, - }) + searchItems: searchProjects, + } = useInfiniteScroll( + useGetGitLabProjectsQuery, + { + page_size: 100, + project_id: projectId, + }, + 100, + ) const projects = projectsData?.results ?? [] const linkSelectedItem = async () => { @@ -84,9 +99,11 @@ const GitLabLinkSection: FC = ({
options} 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)} + placeholder={isBusy ? 'Loading...' : 'Select GitLab Project'} + value={value} + onChange={(v: GitLabProjectOption) => onChange(v)} + onInputChange={(e: string) => onInputChange(e)} options={options} - isLoading={isLoading} - isDisabled={isDisabled} + isLoading={isBusy} + noOptionsMessage={() => { + if (isBusy) return 'Loading...' + return isError + ? 'Failed to load GitLab projects' + : 'No projects found' + }} />
)