From 731a8d25ba5ddb111903d1c42cffcf716bac45ae Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 16 Apr 2026 21:04:16 -0300 Subject: [PATCH 01/20] 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 02/20] 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 8075549d28525c6d6c2c3c9a93589c2231a9a0ab Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 16 Apr 2026 19:22:15 -0300 Subject: [PATCH 03/20] Allow browsing GitLab issues and MRs (frontend) fixup: mr --- frontend/common/constants.ts | 12 +++ .../common/hooks/useHasGitLabIntegration.ts | 9 ++ frontend/common/services/useGitlab.ts | 84 ++++++++++++++++++ .../common/services/useGitlabConfiguration.ts | 46 ++++++++++ frontend/common/types/requests.ts | 12 +++ frontend/common/types/responses.ts | 33 +++++++ .../components/ExternalResourcesLinkTab.tsx | 87 ------------------- frontend/web/components/GitHubLinkSection.tsx | 72 +++++++++++++++ frontend/web/components/GitLabLinkSection.tsx | 79 +++++++++++++++++ .../web/components/GitLabProjectSelect.tsx | 57 ++++++++++++ .../web/components/GitLabSearchSelect.tsx | 84 ++++++++++++++++++ .../modals/create-feature/index.tsx | 43 +++++++-- 12 files changed, 523 insertions(+), 95 deletions(-) create mode 100644 frontend/common/hooks/useHasGitLabIntegration.ts create mode 100644 frontend/common/services/useGitlab.ts create mode 100644 frontend/common/services/useGitlabConfiguration.ts delete mode 100644 frontend/web/components/ExternalResourcesLinkTab.tsx create mode 100644 frontend/web/components/GitHubLinkSection.tsx create mode 100644 frontend/web/components/GitLabLinkSection.tsx create mode 100644 frontend/web/components/GitLabProjectSelect.tsx create mode 100644 frontend/web/components/GitLabSearchSelect.tsx 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..9393e5adbb00 --- /dev/null +++ b/frontend/common/services/useGitlab.ts @@ -0,0 +1,84 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +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/?page_size=${query.page_size ?? 100}&page=${query.page ?? 1}`, + }), + }), + getGitLabIssues: builder.query< + Res['gitlabIssues'], + Req['getGitLabIssues'] + >({ + providesTags: [{ id: 'LIST', type: 'GitLab' }], + query: (query: Req['getGitLabIssues']) => ({ + url: + `projects/${query.project_id}/gitlab/issues/` + + `?gitlab_project_id=${query.gitlab_project_id}&page_size=${query.page_size ?? 100}&page=${query.page ?? 1}&search_text=${query.q || ''}&state=opened`, + }), + }), + getGitLabMergeRequests: builder.query< + Res['gitlabMergeRequests'], + Req['getGitLabMergeRequests'] + >({ + providesTags: [{ id: 'LIST', type: 'GitLab' }], + query: (query: Req['getGitLabMergeRequests']) => ({ + url: + `projects/${query.project_id}/gitlab/merge-requests/` + + `?gitlab_project_id=${query.gitlab_project_id}&page_size=${query.page_size ?? 100}&page=${query.page ?? 1}&search_text=${query.q || ''}&state=opened`, + }), + }), + // 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 { + useGetGitLabProjectsQuery, + useGetGitLabIssuesQuery, + useGetGitLabMergeRequestsQuery, + // 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..9388b790c637 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -936,5 +936,17 @@ 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 + state?: string + }> + getGitLabMergeRequests: PagedRequest<{ + project_id: number + gitlab_project_id: number + state?: string + }> // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index b28a763e6b94..302737a659f6 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -292,6 +292,35 @@ 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 GithubPaginatedRepos = { total_count: number repository_selection: string @@ -1275,5 +1304,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/ExternalResourcesLinkTab.tsx b/frontend/web/components/ExternalResourcesLinkTab.tsx deleted file mode 100644 index f3c34443e4d3..000000000000 --- a/frontend/web/components/ExternalResourcesLinkTab.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { FC, useState } from 'react' -import ExternalResourcesTable, { - ExternalResourcesTableBase, -} from './ExternalResourcesTable' -import { ExternalResource, GithubResource } from 'common/types/responses' -import { useCreateExternalResourceMutation } from 'common/services/useExternalResource' -import Constants from 'common/constants' -import GitHubResourcesSelect from './GitHubResourcesSelect' -import AppActions from 'common/dispatcher/app-actions' - -type ExternalResourcesLinkTabType = { - githubId: string - organisationId: number - featureId: string - projectId: number - environmentId: string -} - -type AddExternalResourceRowType = ExternalResourcesTableBase & { - selectedResources?: ExternalResource[] - environmentId: string - githubId: string -} - -const ExternalResourcesLinkTab: FC = ({ - environmentId, - featureId, - githubId, - organisationId, - projectId, -}) => { - const githubTypes = Object.values(Constants.resourceTypes).filter( - (v) => v.type === 'GITHUB', - ) - - const [createExternalResource] = useCreateExternalResourceMutation() - const [resourceType, setResourceType] = useState(githubTypes[0].resourceType) - const [selectedResources, setSelectedResources] = - useState() - - const addResource = (featureExternalResource: GithubResource) => { - const type = Object.keys(Constants.resourceTypes).find( - (key: string) => - Constants.resourceTypes[key as keyof typeof Constants.resourceTypes] - .resourceType === resourceType, - ) - createExternalResource({ - body: { - feature: parseInt(featureId), - metadata: { - 'draft': featureExternalResource.draft, - 'merged': featureExternalResource.merged, - 'state': featureExternalResource.state, - 'title': featureExternalResource.title, - }, - type: type, - url: featureExternalResource.html_url, - }, - feature_id: featureId, - project_id: projectId, - }).then(() => { - toast('External Resource Added') - AppActions.refreshFeatures(projectId, environmentId) - }) - } - return ( - <> - v.url!)} - orgId={organisationId} - /> - - setSelectedResources(r) - } - /> - - ) -} - -export default ExternalResourcesLinkTab diff --git a/frontend/web/components/GitHubLinkSection.tsx b/frontend/web/components/GitHubLinkSection.tsx new file mode 100644 index 000000000000..4dfb5f567fed --- /dev/null +++ b/frontend/web/components/GitHubLinkSection.tsx @@ -0,0 +1,72 @@ +import React, { FC, useState } from 'react' +import Constants from 'common/constants' +import GitHubResourcesSelect from 'components/GitHubResourcesSelect' +import { useCreateExternalResourceMutation } from 'common/services/useExternalResource' +import AppActions from 'common/dispatcher/app-actions' +import type { ExternalResource, GithubResource } from 'common/types/responses' + +type GitHubLinkSectionProps = { + githubId: string + organisationId: number + featureId: number + projectId: number + environmentId: string + linkedResources?: ExternalResource[] +} + +const GitHubLinkSection: FC = ({ + environmentId, + featureId, + githubId, + linkedResources, + organisationId, + projectId, +}) => { + const githubTypes = Object.values(Constants.resourceTypes).filter( + (v) => v.type === 'GITHUB', + ) + const [createExternalResource] = useCreateExternalResourceMutation() + const [githubResourceType, setGithubResourceType] = useState( + githubTypes[0]?.resourceType, + ) + + const addGithubResource = (githubResource: GithubResource) => { + const type = Object.keys(Constants.resourceTypes).find( + (key: string) => + Constants.resourceTypes[key as keyof typeof Constants.resourceTypes] + .resourceType === githubResourceType, + ) + createExternalResource({ + body: { + feature: featureId, + metadata: { + draft: githubResource.draft, + merged: githubResource.merged, + state: githubResource.state, + title: githubResource.title, + }, + type: type || '', + url: githubResource.html_url, + }, + feature_id: featureId, + project_id: projectId, + }).then(() => { + toast('External Resource Added') + AppActions.refreshFeatures(projectId, environmentId) + }) + } + + return ( + v.url!)} + orgId={organisationId as any} + linkedExternalResources={linkedResources} + /> + ) +} + +export default GitHubLinkSection diff --git a/frontend/web/components/GitLabLinkSection.tsx b/frontend/web/components/GitLabLinkSection.tsx new file mode 100644 index 000000000000..6217b229e33c --- /dev/null +++ b/frontend/web/components/GitLabLinkSection.tsx @@ -0,0 +1,79 @@ +import React, { FC, useState } from 'react' +import Constants from 'common/constants' +import GitLabProjectSelect from 'components/GitLabProjectSelect' +import GitLabSearchSelect from 'components/GitLabSearchSelect' +import type { GitLabIssue, GitLabMergeRequest } from 'common/types/responses' + +type GitLabLinkType = 'issue' | 'merge_request' + +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) + + return ( +
+ +
+ +
+ ({ + label: p.path_with_namespace, + value: p.id, + })) + .find((o) => o.value === value) + : null + } + onChange={(v: { value: number }) => onChange(v.value)} + options={ + data?.results?.map((p) => ({ + label: p.path_with_namespace, + value: p.id, + })) ?? [] + } + isLoading={isLoading} + isDisabled={isError} + /> +
+ ) +} + +export default GitLabProjectSelect diff --git a/frontend/web/components/GitLabSearchSelect.tsx b/frontend/web/components/GitLabSearchSelect.tsx new file mode 100644 index 000000000000..09769c14a3eb --- /dev/null +++ b/frontend/web/components/GitLabSearchSelect.tsx @@ -0,0 +1,84 @@ +import React, { FC } from 'react' +import useInfiniteScroll from 'common/useInfiniteScroll' +import { Req } from 'common/types/requests' +import { Res, type GitLabIssue, type GitLabMergeRequest } from 'common/types/responses' +import { + useGetGitLabIssuesQuery, + useGetGitLabMergeRequestsQuery, +} from 'common/services/useGitlab' + +type GitLabSearchSelectProps = { + projectId: number + gitlabProjectId: number + linkType: 'issue' | 'merge_request' + value: GitLabIssue | GitLabMergeRequest | null + onChange: (selection: GitLabIssue | GitLabMergeRequest) => void + linkedUrls: string[] +} + +const GitLabSearchSelect: FC = ({ + gitlabProjectId, + linkedUrls, + linkType, + 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 }, + ) + + return ( +
+ ({ label: p.path_with_namespace, value: p.id })) - .find((o) => o.value === value) ?? null - } + placeholder={isLoading ? 'Loading...' : 'Select GitLab Project'} + value={options.find((o) => o.value === value) ?? null} onChange={(v: { value: number }) => onChange(v.value)} - options={ - data?.results?.map((p) => ({ - label: p.path_with_namespace, - value: p.id, - })) ?? [] - } + options={options} isLoading={isLoading} - isDisabled={isError} + isDisabled={isDisabled} />
) From c4a4307bc50d5d665155edeadaf2915c264402e0 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Fri, 17 Apr 2026 20:37:27 -0300 Subject: [PATCH 10/20] =?UTF-8?q?Refactor=20=F0=9F=92=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/web/components/GitLabSearchSelect.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/frontend/web/components/GitLabSearchSelect.tsx b/frontend/web/components/GitLabSearchSelect.tsx index 31c25ac20219..4c2ef34fdb80 100644 --- a/frontend/web/components/GitLabSearchSelect.tsx +++ b/frontend/web/components/GitLabSearchSelect.tsx @@ -48,6 +48,10 @@ const GitLabSearchSelect: FC = ({ { skip: !gitlabProjectId }, ) + const options = data?.results + ?.filter((r) => !linkedUrls.includes(r.web_url)) + .map((r) => ({ label: `${r.title} #${r.iid}`, value: r })) + return (