From 731a8d25ba5ddb111903d1c42cffcf716bac45ae Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 16 Apr 2026 21:04:16 -0300 Subject: [PATCH 1/3] Allow browsing GitLab issues and MRs (backend) --- api/integrations/gitlab/client/__init__.py | 21 ++ api/integrations/gitlab/client/api.py | 149 ++++++++++++ api/integrations/gitlab/client/types.py | 34 +++ api/integrations/gitlab/serializers.py | 13 + api/integrations/gitlab/views/__init__.py | 13 + .../gitlab/views/browse_gitlab.py | 164 +++++++++++++ .../{views.py => views/configuration.py} | 6 +- api/projects/urls.py | 22 +- .../unit/integrations/gitlab/test_client.py | 224 ++++++++++++++++++ .../{test_views.py => test_configuration.py} | 6 +- .../integrations/gitlab/test_proxy_views.py | 206 ++++++++++++++++ 11 files changed, 851 insertions(+), 7 deletions(-) create mode 100644 api/integrations/gitlab/client/__init__.py create mode 100644 api/integrations/gitlab/client/api.py create mode 100644 api/integrations/gitlab/client/types.py create mode 100644 api/integrations/gitlab/views/__init__.py create mode 100644 api/integrations/gitlab/views/browse_gitlab.py rename api/integrations/gitlab/{views.py => views/configuration.py} (92%) create mode 100644 api/tests/unit/integrations/gitlab/test_client.py rename api/tests/unit/integrations/gitlab/{test_views.py => test_configuration.py} (97%) create mode 100644 api/tests/unit/integrations/gitlab/test_proxy_views.py diff --git a/api/integrations/gitlab/client/__init__.py b/api/integrations/gitlab/client/__init__.py new file mode 100644 index 000000000000..2aa4dca61a44 --- /dev/null +++ b/api/integrations/gitlab/client/__init__.py @@ -0,0 +1,21 @@ +from integrations.gitlab.client.api import ( + fetch_gitlab_projects, + search_gitlab_issues, + search_gitlab_merge_requests, +) +from integrations.gitlab.client.types import ( + GitLabIssue, + GitLabMergeRequest, + GitLabPage, + GitLabProject, +) + +__all__ = [ + "GitLabIssue", + "GitLabMergeRequest", + "GitLabPage", + "GitLabProject", + "fetch_gitlab_projects", + "search_gitlab_issues", + "search_gitlab_merge_requests", +] diff --git a/api/integrations/gitlab/client/api.py b/api/integrations/gitlab/client/api.py new file mode 100644 index 000000000000..03c915188cfd --- /dev/null +++ b/api/integrations/gitlab/client/api.py @@ -0,0 +1,149 @@ +from collections.abc import Mapping +from typing import Any + +import requests + +from integrations.gitlab.client.types import ( + GitLabIssue, + GitLabMergeRequest, + GitLabPage, + GitLabProject, + T, +) + + +def _get_from_gitlab_api( + instance_url: str, + access_token: str, + *, + path: str, + params: dict[str, Any] | None = None, +) -> requests.Response: + response = requests.get( + f"{instance_url}/api/v4/{path}", + headers={"PRIVATE-TOKEN": access_token}, + params=params, + ) + response.raise_for_status() + return response + + +def _gitlab_page( + results: list[T], + headers: Mapping[str, str], +) -> GitLabPage[T]: + return { + "results": results, + "current_page": int(headers.get("x-page", "1")), + "total_pages": int(headers.get("x-total-pages", "1")), + "total_count": int(headers.get("x-total", str(len(results)))), + } + + +def fetch_gitlab_projects( + instance_url: str, + access_token: str, + *, + page: int, + page_size: int, +) -> GitLabPage[GitLabProject]: + response = _get_from_gitlab_api( + instance_url, + access_token, + path="projects", + params={ + "membership": "true", + "per_page": str(page_size), + "page": str(page), + }, + ) + + results: list[GitLabProject] = [ + GitLabProject( + id=p["id"], + name=p["name"], + path_with_namespace=p["path_with_namespace"], + ) + for p in response.json() + ] + return _gitlab_page(results, response.headers) + + +def search_gitlab_issues( + instance_url: str, + access_token: str, + *, + gitlab_project_id: int, + page: int, + page_size: int, + search_text: str | None = None, + state: str | None = "opened", +) -> GitLabPage[GitLabIssue]: + query: dict[str, str | int] = { + "per_page": page_size, + "page": page, + } + if search_text: + query["search"] = search_text + if state: + query["state"] = state + + response = _get_from_gitlab_api( + instance_url, + access_token, + path=f"projects/{gitlab_project_id}/issues", + params=query, + ) + + results: list[GitLabIssue] = [ + { + "web_url": item["web_url"], + "id": item["id"], + "title": item["title"], + "iid": item["iid"], + "state": item["state"], + } + for item in response.json() + ] + return _gitlab_page(results, response.headers) + + +def search_gitlab_merge_requests( + instance_url: str, + access_token: str, + *, + gitlab_project_id: int, + page: int, + page_size: int, + search_text: str | None = None, + state: str | None = "opened", +) -> GitLabPage[GitLabMergeRequest]: + query: dict[str, str | int] = { + "per_page": page_size, + "page": page, + } + if search_text: + query["search"] = search_text + if state: + query["state"] = state + + response = _get_from_gitlab_api( + instance_url, + access_token, + path=f"projects/{gitlab_project_id}/merge_requests", + params=query, + ) + + results: list[GitLabMergeRequest] = [ + { + "web_url": item["web_url"], + "id": item["id"], + "title": item["title"], + "iid": item["iid"], + "state": item["state"], + "merged": item.get("merged_at") is not None, + "draft": item.get("draft", False), + } + for item in response.json() + ] + return _gitlab_page(results, response.headers) diff --git a/api/integrations/gitlab/client/types.py b/api/integrations/gitlab/client/types.py new file mode 100644 index 000000000000..c02ec639aa13 --- /dev/null +++ b/api/integrations/gitlab/client/types.py @@ -0,0 +1,34 @@ +from typing import Generic, TypedDict, TypeVar + +T = TypeVar("T") + + +class GitLabProject(TypedDict): + id: int + name: str + path_with_namespace: str + + +class GitLabIssue(TypedDict): + web_url: str + id: int + title: str + iid: int + state: str + + +class GitLabMergeRequest(TypedDict): + web_url: str + id: int + title: str + iid: int + state: str + merged: bool + draft: bool + + +class GitLabPage(TypedDict, Generic[T]): + results: list[T] + current_page: int + total_pages: int + total_count: int diff --git a/api/integrations/gitlab/serializers.py b/api/integrations/gitlab/serializers.py index 20f31b6ef43f..f6e3ba0f7f64 100644 --- a/api/integrations/gitlab/serializers.py +++ b/api/integrations/gitlab/serializers.py @@ -1,5 +1,7 @@ from typing import Any +from rest_framework import serializers + from integrations.common.serializers import BaseProjectIntegrationModelSerializer from integrations.gitlab.models import GitLabConfiguration @@ -15,3 +17,14 @@ def to_representation(self, instance: GitLabConfiguration) -> dict[str, Any]: data = super().to_representation(instance) data["access_token"] = WRITE_ONLY_PLACEHOLDER return data + + +class PaginatedQueryParamsSerializer(serializers.Serializer[None]): + page = serializers.IntegerField(default=1, min_value=1) + page_size = serializers.IntegerField(default=100, min_value=1, max_value=100) + + +class SearchQueryParamsSerializer(PaginatedQueryParamsSerializer): + gitlab_project_id = serializers.IntegerField() + search_text = serializers.CharField(required=False, allow_blank=True) + state = serializers.CharField(default="opened", required=False) diff --git a/api/integrations/gitlab/views/__init__.py b/api/integrations/gitlab/views/__init__.py new file mode 100644 index 000000000000..d6e5c6739098 --- /dev/null +++ b/api/integrations/gitlab/views/__init__.py @@ -0,0 +1,13 @@ +from integrations.gitlab.views.browse_gitlab import ( + BrowseGitLabIssues, + BrowseGitLabMergeRequests, + BrowseGitLabProjects, +) +from integrations.gitlab.views.configuration import GitLabConfigurationViewSet + +__all__ = [ + "BrowseGitLabIssues", + "BrowseGitLabMergeRequests", + "BrowseGitLabProjects", + "GitLabConfigurationViewSet", +] diff --git a/api/integrations/gitlab/views/browse_gitlab.py b/api/integrations/gitlab/views/browse_gitlab.py new file mode 100644 index 000000000000..95650eb5101e --- /dev/null +++ b/api/integrations/gitlab/views/browse_gitlab.py @@ -0,0 +1,164 @@ +import abc +from typing import Any, Generic + +import requests +import structlog +from rest_framework import status +from rest_framework.generics import ListAPIView +from rest_framework.request import Request +from rest_framework.response import Response + +from integrations.gitlab.client import ( + GitLabIssue, + GitLabMergeRequest, + GitLabPage, + GitLabProject, + fetch_gitlab_projects, + search_gitlab_issues, + search_gitlab_merge_requests, +) +from integrations.gitlab.client.types import T +from integrations.gitlab.models import GitLabConfiguration +from integrations.gitlab.serializers import ( + PaginatedQueryParamsSerializer, + SearchQueryParamsSerializer, +) +from projects.permissions import NestedProjectPermissions + +logger = structlog.get_logger("gitlab") + + +class _GitLabListView(ListAPIView, abc.ABC, Generic[T]): # type: ignore[type-arg] + permission_classes = [NestedProjectPermissions] + serializer_class = PaginatedQueryParamsSerializer + action = "list" # NestedProjectPermissions reads from ViewSet.action + + @abc.abstractmethod + def fetch_page( + self, + config: GitLabConfiguration, + validated_data: dict[str, Any], + ) -> GitLabPage[T]: ... + + def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: + try: + config = self._get_gitlab_config() + except GitLabConfiguration.DoesNotExist: + return Response( + data={"detail": "This project has no GitLab configuration"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = self.serializer_class(data=request.query_params) + serializer.is_valid(raise_exception=True) + + try: + page_data = self.fetch_page(config, serializer.validated_data) + except requests.RequestException as exc: + logger.error("api-call-failed", exc_info=exc) + return Response( + data={"detail": f"GitLab API error: {exc}"}, + status=status.HTTP_424_FAILED_DEPENDENCY, + ) + + return self._paginated_response(page_data, request) + + def _get_gitlab_config(self) -> GitLabConfiguration: + return GitLabConfiguration.objects.get( # type: ignore[no-any-return] + project_id=self.kwargs["project_pk"], + deleted_at__isnull=True, + ) + + def _paginated_response( + self, + page_data: GitLabPage[T], + request: Request, + ) -> Response: + current = page_data["current_page"] + total = page_data["total_pages"] + + def page_url(page: int) -> str: + params = request.query_params.copy() + params["page"] = str(page) + return request.build_absolute_uri(f"{request.path}?{params.urlencode()}") + + return Response( + { + "count": page_data["total_count"], + "results": page_data["results"], + "next": page_url(current + 1) if current < total else None, + "previous": page_url(current - 1) if current > 1 else None, + } + ) + + +class BrowseGitLabProjects(_GitLabListView[GitLabProject]): + def fetch_page( + self, + config: GitLabConfiguration, + validated_data: dict[str, Any], + ) -> GitLabPage[GitLabProject]: + page_data = fetch_gitlab_projects( + instance_url=config.gitlab_instance_url, + access_token=config.access_token, + page=validated_data["page"], + page_size=validated_data["page_size"], + ) + + logger.info( + "projects-fetched", + project__id=self.kwargs["project_pk"], + ) + return page_data + + +class BrowseGitLabIssues(_GitLabListView[GitLabIssue]): + serializer_class = SearchQueryParamsSerializer + + def fetch_page( + self, + config: GitLabConfiguration, + validated_data: dict[str, Any], + ) -> GitLabPage[GitLabIssue]: + page_data = search_gitlab_issues( + instance_url=config.gitlab_instance_url, + access_token=config.access_token, + gitlab_project_id=validated_data["gitlab_project_id"], + page=validated_data["page"], + page_size=validated_data["page_size"], + search_text=validated_data.get("search_text"), + state=validated_data.get("state", "opened"), + ) + + logger.info( + "issues-fetched", + project__id=self.kwargs["project_pk"], + gitlab_project_id=validated_data["gitlab_project_id"], + ) + return page_data + + +class BrowseGitLabMergeRequests(_GitLabListView[GitLabMergeRequest]): + serializer_class = SearchQueryParamsSerializer + + def fetch_page( + self, + config: GitLabConfiguration, + validated_data: dict[str, Any], + ) -> GitLabPage[GitLabMergeRequest]: + page_data = search_gitlab_merge_requests( + instance_url=config.gitlab_instance_url, + access_token=config.access_token, + gitlab_project_id=validated_data["gitlab_project_id"], + page=validated_data["page"], + page_size=validated_data["page_size"], + search_text=validated_data.get("search_text"), + state=validated_data.get("state", "opened"), + ) + + logger.info( + "merge-requests-fetched", + project__id=self.kwargs["project_pk"], + gitlab_project_id=validated_data["gitlab_project_id"], + ) + return page_data diff --git a/api/integrations/gitlab/views.py b/api/integrations/gitlab/views/configuration.py similarity index 92% rename from api/integrations/gitlab/views.py rename to api/integrations/gitlab/views/configuration.py index 161a7707f240..6b0499caa719 100644 --- a/api/integrations/gitlab/views.py +++ b/api/integrations/gitlab/views/configuration.py @@ -24,7 +24,7 @@ def perform_create(self, serializer: GitLabConfigurationSerializer) -> None: # super().perform_create(serializer) instance: GitLabConfiguration = serializer.instance # type: ignore[assignment] self._log_for(instance).info( - "gitlab-configuration-created", + "configuration-created", gitlab_instance_url=instance.gitlab_instance_url, ) @@ -32,11 +32,11 @@ def perform_update(self, serializer: GitLabConfigurationSerializer) -> None: # super().perform_update(serializer) instance: GitLabConfiguration = serializer.instance # type: ignore[assignment] self._log_for(instance).info( - "gitlab-configuration-updated", + "configuration-updated", gitlab_instance_url=instance.gitlab_instance_url, ) def perform_destroy(self, instance: GitLabConfiguration) -> None: log = self._log_for(instance) super().perform_destroy(instance) - log.info("gitlab-configuration-deleted") + log.info("configuration-deleted") diff --git a/api/projects/urls.py b/api/projects/urls.py index 07a68b79fd3d..80d4e8d4bd14 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -19,7 +19,12 @@ from features.multivariate.views import MultivariateFeatureOptionViewSet from features.views import FeatureViewSet from integrations.datadog.views import DataDogConfigurationViewSet -from integrations.gitlab.views import GitLabConfigurationViewSet +from integrations.gitlab.views import ( + BrowseGitLabIssues, + BrowseGitLabMergeRequests, + BrowseGitLabProjects, + GitLabConfigurationViewSet, +) from integrations.grafana.views import GrafanaProjectConfigurationViewSet from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet @@ -145,4 +150,19 @@ FeatureImportListView.as_view(), name="feature-imports", ), + path( + "/gitlab/projects/", + BrowseGitLabProjects.as_view(), + name="get-gitlab-projects", + ), + path( + "/gitlab/issues/", + BrowseGitLabIssues.as_view(), + name="get-gitlab-issues", + ), + path( + "/gitlab/merge-requests/", + BrowseGitLabMergeRequests.as_view(), + name="get-gitlab-merge-requests", + ), ] diff --git a/api/tests/unit/integrations/gitlab/test_client.py b/api/tests/unit/integrations/gitlab/test_client.py new file mode 100644 index 000000000000..c3f9ab3e21fe --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_client.py @@ -0,0 +1,224 @@ +import responses + +from integrations.gitlab.client import ( + fetch_gitlab_projects, + search_gitlab_issues, + search_gitlab_merge_requests, +) + +INSTANCE_URL = "https://gitlab.example.com" +ACCESS_TOKEN = "glpat-test-token" + + +@responses.activate +def test_fetch_gitlab_projects__single_page__returns_projects_and_page_metadata() -> ( + None +): + # Given + responses.get( + f"{INSTANCE_URL}/api/v4/projects", + json=[ + { + "id": 1, + "name": "My Project", + "path_with_namespace": "group/my-project", + "extra_field": "ignored", + }, + ], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "1"}, + match=[responses.matchers.header_matcher({"PRIVATE-TOKEN": ACCESS_TOKEN})], + ) + + # When + result = fetch_gitlab_projects( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + page=1, + page_size=100, + ) + + # Then + assert result["results"] == [ + {"id": 1, "name": "My Project", "path_with_namespace": "group/my-project"}, + ] + assert result["current_page"] == 1 + assert result["total_pages"] == 1 + assert result["total_count"] == 1 + + +@responses.activate +def test_fetch_gitlab_projects__second_page__returns_correct_page_metadata() -> None: + # Given + responses.get( + f"{INSTANCE_URL}/api/v4/projects", + json=[{"id": 2, "name": "P2", "path_with_namespace": "g/p2"}], + headers={"x-page": "2", "x-total-pages": "3", "x-total": "250"}, + match=[responses.matchers.header_matcher({"PRIVATE-TOKEN": ACCESS_TOKEN})], + ) + + # When + result = fetch_gitlab_projects( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + page=2, + page_size=100, + ) + + # Then + assert result["current_page"] == 2 + assert result["total_pages"] == 3 + assert result["total_count"] == 250 + + +@responses.activate +def test_search_gitlab_issues__default_params__returns_issues() -> None: + # Given + responses.get( + f"{INSTANCE_URL}/api/v4/projects/42/issues", + json=[ + { + "web_url": "https://gitlab.example.com/g/p/-/issues/1", + "id": 101, + "title": "Bug report", + "iid": 1, + "state": "opened", + }, + ], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "1"}, + match=[responses.matchers.header_matcher({"PRIVATE-TOKEN": ACCESS_TOKEN})], + ) + + # When + result = search_gitlab_issues( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=42, + page=1, + page_size=100, + ) + + # Then + assert result["results"] == [ + { + "web_url": "https://gitlab.example.com/g/p/-/issues/1", + "id": 101, + "title": "Bug report", + "iid": 1, + "state": "opened", + }, + ] + + +@responses.activate +def test_search_gitlab_issues__with_search_text__sends_search_param() -> None: + # Given + responses.get( + f"{INSTANCE_URL}/api/v4/projects/42/issues", + 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( + {"per_page": "100", "page": "1", "state": "opened", "search": "login"}, + strict_match=False, + ), + ], + ) + + # When + result = search_gitlab_issues( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=42, + page=1, + page_size=100, + search_text="login", + ) + + # Then + assert result["results"] == [] + assert result["total_count"] == 0 + + +@responses.activate +def test_search_gitlab_merge_requests__default_params__returns_merge_requests() -> None: + # Given + responses.get( + f"{INSTANCE_URL}/api/v4/projects/42/merge_requests", + json=[ + { + "web_url": "https://gitlab.example.com/g/p/-/merge_requests/5", + "id": 201, + "title": "Add feature", + "iid": 5, + "state": "opened", + "merged_at": None, + "draft": False, + }, + ], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "1"}, + match=[responses.matchers.header_matcher({"PRIVATE-TOKEN": ACCESS_TOKEN})], + ) + + # When + result = search_gitlab_merge_requests( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=42, + page=1, + page_size=100, + ) + + # Then + assert result["results"] == [ + { + "web_url": "https://gitlab.example.com/g/p/-/merge_requests/5", + "id": 201, + "title": "Add feature", + "iid": 5, + "state": "opened", + "merged": False, + "draft": False, + }, + ] + + +@responses.activate +def test_search_gitlab_merge_requests__merged_mr__merged_is_true() -> None: + # Given + responses.get( + f"{INSTANCE_URL}/api/v4/projects/42/merge_requests", + json=[ + { + "web_url": "https://gitlab.example.com/g/p/-/merge_requests/6", + "id": 202, + "title": "Merged MR", + "iid": 6, + "state": "merged", + "merged_at": "2026-01-01T00:00:00Z", + "draft": False, + }, + ], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "1"}, + match=[ + responses.matchers.header_matcher({"PRIVATE-TOKEN": ACCESS_TOKEN}), + responses.matchers.query_param_matcher( + {"per_page": "100", "page": "1", "state": "merged", "search": "deploy"}, + strict_match=False, + ), + ], + ) + + # When + result = search_gitlab_merge_requests( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=42, + page=1, + page_size=100, + search_text="deploy", + state="merged", + ) + + # Then + assert result["results"][0]["merged"] is True diff --git a/api/tests/unit/integrations/gitlab/test_views.py b/api/tests/unit/integrations/gitlab/test_configuration.py similarity index 97% rename from api/tests/unit/integrations/gitlab/test_views.py rename to api/tests/unit/integrations/gitlab/test_configuration.py index 12638a3f89d9..aa842081e0fd 100644 --- a/api/tests/unit/integrations/gitlab/test_views.py +++ b/api/tests/unit/integrations/gitlab/test_configuration.py @@ -41,7 +41,7 @@ def test_create_configuration__valid_data__persists_and_masks_token( assert log.events == [ { - "event": "gitlab-configuration-created", + "event": "configuration-created", "level": "info", "gitlab_instance_url": "https://gitlab.example.com", "project__id": project.id, @@ -95,7 +95,7 @@ def test_update_configuration__valid_data__persists_and_masks_token( assert log.events == [ { - "event": "gitlab-configuration-updated", + "event": "configuration-updated", "level": "info", "gitlab_instance_url": "https://gitlab.updated.com", "project__id": project.id, @@ -121,7 +121,7 @@ def test_delete_configuration__existing__soft_deletes( assert log.events == [ { - "event": "gitlab-configuration-deleted", + "event": "configuration-deleted", "level": "info", "project__id": project.id, "organisation__id": project.organisation_id, diff --git a/api/tests/unit/integrations/gitlab/test_proxy_views.py b/api/tests/unit/integrations/gitlab/test_proxy_views.py new file mode 100644 index 000000000000..b704f577e7ed --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_proxy_views.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import requests +from rest_framework import status + +from integrations.gitlab.models import GitLabConfiguration + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + from rest_framework.test import APIClient + + from projects.models import Project + + +@pytest.fixture() +def gitlab_config(project: Project) -> GitLabConfiguration: + return GitLabConfiguration.objects.create( # type: ignore[no-any-return] + project=project, + gitlab_instance_url="https://gitlab.example.com", + access_token="glpat-test-token", + ) + + +@pytest.mark.usefixtures("gitlab_config") +def test_gitlab_project_list__valid_config__returns_paginated_response( + admin_client: APIClient, + project: Project, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "integrations.gitlab.views.browse_gitlab.fetch_gitlab_projects", + return_value={ + "results": [{"id": 1, "name": "P", "path_with_namespace": "g/p"}], + "current_page": 1, + "total_pages": 2, + "total_count": 150, + }, + ) + + # When + response = admin_client.get( + f"/api/v1/projects/{project.id}/gitlab/projects/", + {"page": 1, "page_size": 100}, + ) + + # Then + data = response.json() + assert response.status_code == status.HTTP_200_OK + assert data["results"][0]["name"] == "P" + assert data["count"] == 150 + assert data["next"] is not None + assert data["previous"] is None + + +def test_gitlab_project_list__no_gitlab_config__returns_400( + admin_client: APIClient, + project: Project, +) -> None: + # Given / When + response = admin_client.get( + f"/api/v1/projects/{project.id}/gitlab/projects/", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.usefixtures("gitlab_config") +def test_gitlab_issue_list__valid_config__returns_issues( + admin_client: APIClient, + project: Project, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "integrations.gitlab.views.browse_gitlab.search_gitlab_issues", + return_value={ + "results": [ + { + "web_url": "https://gitlab.example.com/g/p/-/issues/1", + "id": 101, + "title": "Bug", + "iid": 1, + "state": "opened", + } + ], + "current_page": 1, + "total_pages": 1, + "total_count": 1, + }, + ) + + # When + response = admin_client.get( + f"/api/v1/projects/{project.id}/gitlab/issues/", + {"gitlab_project_id": 42}, + ) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["results"][0]["title"] == "Bug" + + +@pytest.mark.usefixtures("gitlab_config") +def test_gitlab_issue_list__missing_gitlab_project_id__returns_400( + admin_client: APIClient, + project: Project, +) -> None: + # Given / When + response = admin_client.get( + f"/api/v1/projects/{project.id}/gitlab/issues/", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.usefixtures("gitlab_config") +def test_gitlab_merge_request_list__valid_config__returns_merge_requests( + admin_client: APIClient, + project: Project, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "integrations.gitlab.views.browse_gitlab.search_gitlab_merge_requests", + return_value={ + "results": [ + { + "web_url": "https://gitlab.example.com/g/p/-/merge_requests/5", + "id": 201, + "title": "Feature", + "iid": 5, + "state": "opened", + "merged": False, + "draft": False, + } + ], + "current_page": 1, + "total_pages": 1, + "total_count": 1, + }, + ) + + # When + response = admin_client.get( + f"/api/v1/projects/{project.id}/gitlab/merge-requests/", + {"gitlab_project_id": 42}, + ) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["results"][0]["title"] == "Feature" + + +def test_gitlab_merge_request_list__no_gitlab_config__returns_400( + admin_client: APIClient, + project: Project, +) -> None: + # Given / When + response = admin_client.get( + f"/api/v1/projects/{project.id}/gitlab/merge-requests/", + {"gitlab_project_id": 42}, + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.usefixtures("gitlab_config") +def test_gitlab_merge_request_list__missing_gitlab_project_id__returns_400( + admin_client: APIClient, + project: Project, +) -> None: + # Given / When + response = admin_client.get( + f"/api/v1/projects/{project.id}/gitlab/merge-requests/", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.usefixtures("gitlab_config") +def test_browse_gitlab__api_unreachable__returns_424( + admin_client: APIClient, + project: Project, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "integrations.gitlab.views.browse_gitlab.fetch_gitlab_projects", + side_effect=requests.RequestException("connection refused"), + ) + + # When + response = admin_client.get( + f"/api/v1/projects/{project.id}/gitlab/projects/", + ) + + # Then + assert response.status_code == status.HTTP_424_FAILED_DEPENDENCY From c644394d9ec4a4d4d5cabe39095bee0767c6384d Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 16 Apr 2026 18:32:47 -0300 Subject: [PATCH 2/3] Don't expose computer talk --- api/integrations/gitlab/views/browse_gitlab.py | 2 +- api/tests/unit/integrations/gitlab/test_proxy_views.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/integrations/gitlab/views/browse_gitlab.py b/api/integrations/gitlab/views/browse_gitlab.py index 95650eb5101e..2aba862c9d77 100644 --- a/api/integrations/gitlab/views/browse_gitlab.py +++ b/api/integrations/gitlab/views/browse_gitlab.py @@ -57,7 +57,7 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: except requests.RequestException as exc: logger.error("api-call-failed", exc_info=exc) return Response( - data={"detail": f"GitLab API error: {exc}"}, + data={"detail": "GitLab API is unreachable"}, status=status.HTTP_424_FAILED_DEPENDENCY, ) diff --git a/api/tests/unit/integrations/gitlab/test_proxy_views.py b/api/tests/unit/integrations/gitlab/test_proxy_views.py index b704f577e7ed..fc9298863732 100644 --- a/api/tests/unit/integrations/gitlab/test_proxy_views.py +++ b/api/tests/unit/integrations/gitlab/test_proxy_views.py @@ -204,3 +204,4 @@ def test_browse_gitlab__api_unreachable__returns_424( # Then assert response.status_code == status.HTTP_424_FAILED_DEPENDENCY + assert response.json()["detail"] == "GitLab API is unreachable" From 7f340348ca8c6741ac0b5dc1fe75889741c4e044 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 17 Apr 2026 14:08:55 -0300 Subject: [PATCH 3/3] Fix API response --- api/integrations/gitlab/views/browse_gitlab.py | 2 +- api/tests/unit/integrations/gitlab/test_proxy_views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/integrations/gitlab/views/browse_gitlab.py b/api/integrations/gitlab/views/browse_gitlab.py index 2aba862c9d77..7dd3aa4587dd 100644 --- a/api/integrations/gitlab/views/browse_gitlab.py +++ b/api/integrations/gitlab/views/browse_gitlab.py @@ -58,7 +58,7 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: logger.error("api-call-failed", exc_info=exc) return Response( data={"detail": "GitLab API is unreachable"}, - status=status.HTTP_424_FAILED_DEPENDENCY, + status=status.HTTP_503_SERVICE_UNAVAILABLE, ) return self._paginated_response(page_data, request) diff --git a/api/tests/unit/integrations/gitlab/test_proxy_views.py b/api/tests/unit/integrations/gitlab/test_proxy_views.py index fc9298863732..0151265fc6e8 100644 --- a/api/tests/unit/integrations/gitlab/test_proxy_views.py +++ b/api/tests/unit/integrations/gitlab/test_proxy_views.py @@ -186,7 +186,7 @@ def test_gitlab_merge_request_list__missing_gitlab_project_id__returns_400( @pytest.mark.usefixtures("gitlab_config") -def test_browse_gitlab__api_unreachable__returns_424( +def test_browse_gitlab__api_unreachable__returns_503( admin_client: APIClient, project: Project, mocker: MockerFixture, @@ -203,5 +203,5 @@ def test_browse_gitlab__api_unreachable__returns_424( ) # Then - assert response.status_code == status.HTTP_424_FAILED_DEPENDENCY + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE assert response.json()["detail"] == "GitLab API is unreachable"