diff --git a/api/features/feature_external_resources/migrations/0003_add_gitlab_resource_types.py b/api/features/feature_external_resources/migrations/0003_add_gitlab_resource_types.py new file mode 100644 index 000000000000..c49200d560e7 --- /dev/null +++ b/api/features/feature_external_resources/migrations/0003_add_gitlab_resource_types.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("feature_external_resources", "0002_featureexternalresource_feature_ext_type_2b2068_idx"), + ] + + operations = [ + migrations.AlterField( + model_name="featureexternalresource", + name="type", + field=models.CharField( + choices=[ + ("GITHUB_ISSUE", "GitHub Issue"), + ("GITHUB_PR", "GitHub PR"), + ("GITLAB_ISSUE", "GitLab Issue"), + ("GITLAB_MR", "GitLab MR"), + ], + max_length=20, + ), + ), + ] diff --git a/api/features/feature_external_resources/models.py b/api/features/feature_external_resources/models.py index 54699a0d76d2..c1531b19f75f 100644 --- a/api/features/feature_external_resources/models.py +++ b/api/features/feature_external_resources/models.py @@ -27,6 +27,10 @@ class ResourceType(models.TextChoices): GITHUB_ISSUE = "GITHUB_ISSUE", "GitHub Issue" GITHUB_PR = "GITHUB_PR", "GitHub PR" + # GitLab external resource types + GITLAB_ISSUE = "GITLAB_ISSUE", "GitLab Issue" + GITLAB_MR = "GITLAB_MR", "GitLab MR" + tag_by_type_and_state = { ResourceType.GITHUB_ISSUE.value: { @@ -65,8 +69,9 @@ class Meta: models.Index(fields=["type"]), ] - @hook(AFTER_SAVE) - def execute_after_save_actions(self): # type: ignore[no-untyped-def] + @hook(AFTER_SAVE, when="type", is_now="GITHUB_ISSUE") + @hook(AFTER_SAVE, when="type", is_now="GITHUB_PR") + def notify_github_on_link(self): # type: ignore[no-untyped-def] # Tag the feature with the external resource type metadata = json.loads(self.metadata) if self.metadata else {} state = metadata.get("state", "open") @@ -130,8 +135,9 @@ def execute_after_save_actions(self): # type: ignore[no-untyped-def] feature_states=feature_states, ) - @hook(BEFORE_DELETE) # type: ignore[misc] - def execute_before_save_actions(self) -> None: + @hook(BEFORE_DELETE, when="type", is_now="GITHUB_ISSUE") # type: ignore[misc] + @hook(BEFORE_DELETE, when="type", is_now="GITHUB_PR") # type: ignore[misc] + def notify_github_on_unlink(self) -> None: # Add a comment to GitHub Issue/PR when feature is unlinked to the GH external resource if ( Organisation.objects.prefetch_related("github_config") diff --git a/api/features/feature_external_resources/views.py b/api/features/feature_external_resources/views.py index 87d8d8b7844b..edad4aa6a34b 100644 --- a/api/features/feature_external_resources/views.py +++ b/api/features/feature_external_resources/views.py @@ -1,5 +1,6 @@ import re +import structlog from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema @@ -15,7 +16,7 @@ from integrations.github.models import GitHubRepository from organisations.models import Organisation -from .models import FeatureExternalResource +from .models import FeatureExternalResource, ResourceType from .serializers import FeatureExternalResourceSerializer @@ -45,7 +46,6 @@ def get_queryset(self): # type: ignore[no-untyped-def] features_pk = self.kwargs["feature_pk"] return FeatureExternalResource.objects.filter(feature=features_pk) - # Override get list view to add github issue/pr name to each linked external resource def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped-def] queryset = self.get_queryset() # type: ignore[no-untyped-call] serializer = self.get_serializer(queryset, many=True) @@ -56,7 +56,14 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped Feature.objects.filter(id=self.kwargs["feature_pk"]), ).project.organisation_id + # Add github issue/PR name to each linked external resource for resource in data if isinstance(data, list) else []: + if ResourceType(resource["type"]) not in [ + ResourceType.GITHUB_ISSUE, + ResourceType.GITHUB_PR, + ]: + continue + if resource_url := resource.get("url"): resource["metadata"] = get_github_issue_pr_title_and_state( organisation_id=organisation_id, resource_url=resource_url @@ -65,6 +72,12 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped return Response(data={"results": data}) def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] + if request.data.get("type") not in [ + ResourceType.GITHUB_ISSUE, + ResourceType.GITHUB_PR, + ]: + return super().create(request, *args, **kwargs) + feature = get_object_or_404( Feature.objects.filter( id=self.kwargs["feature_pk"], @@ -122,6 +135,22 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] status=status.HTTP_400_BAD_REQUEST, ) + def perform_create(self, serializer: FeatureExternalResourceSerializer) -> None: # type: ignore[override] + resource = serializer.save() + + log_event_names: dict[ResourceType, tuple[str, str]] = { + ResourceType.GITLAB_ISSUE: ("gitlab", "issue.linked"), + ResourceType.GITLAB_MR: ("gitlab", "merge_request.linked"), + } + if (resource_type := ResourceType(resource.type)) in log_event_names: + logger_name, event_name = log_event_names[resource_type] + structlog.get_logger(logger_name).info( + event_name, + organisation__id=resource.feature.project.organisation_id, + project__id=resource.feature.project_id, + feature__id=resource.feature.id, + ) + def perform_update(self, serializer): # type: ignore[no-untyped-def] external_resource_id = int(self.kwargs["pk"]) serializer.save(id=external_resource_id) diff --git a/api/integrations/gitlab/views/browse_gitlab.py b/api/integrations/gitlab/views/browse_gitlab.py index 7dd3aa4587dd..7fcd67c58053 100644 --- a/api/integrations/gitlab/views/browse_gitlab.py +++ b/api/integrations/gitlab/views/browse_gitlab.py @@ -7,6 +7,7 @@ from rest_framework.generics import ListAPIView from rest_framework.request import Request from rest_framework.response import Response +from structlog.typing import FilteringBoundLogger from integrations.gitlab.client import ( GitLabIssue, @@ -55,7 +56,7 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: try: page_data = self.fetch_page(config, serializer.validated_data) except requests.RequestException as exc: - logger.error("api-call-failed", exc_info=exc) + self._log_for(config).error("api_call.failed", exc_info=exc) return Response( data={"detail": "GitLab API is unreachable"}, status=status.HTTP_503_SERVICE_UNAVAILABLE, @@ -63,6 +64,12 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: return self._paginated_response(page_data, request) + def _log_for(self, config: GitLabConfiguration) -> FilteringBoundLogger: + return logger.bind( # type: ignore[no-any-return] + organisation__id=config.project.organisation_id, + project__id=config.project_id, + ) + def _get_gitlab_config(self) -> GitLabConfiguration: return GitLabConfiguration.objects.get( # type: ignore[no-any-return] project_id=self.kwargs["project_pk"], @@ -105,10 +112,7 @@ def fetch_page( page_size=validated_data["page_size"], ) - logger.info( - "projects-fetched", - project__id=self.kwargs["project_pk"], - ) + self._log_for(config).info("projects.fetched") return page_data @@ -130,10 +134,9 @@ def fetch_page( state=validated_data.get("state", "opened"), ) - logger.info( - "issues-fetched", - project__id=self.kwargs["project_pk"], - gitlab_project_id=validated_data["gitlab_project_id"], + self._log_for(config).info( + "issues.fetched", + gitlab_project__id=validated_data["gitlab_project_id"], ) return page_data @@ -156,9 +159,8 @@ def fetch_page( 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"], + self._log_for(config).info( + "merge_requests.fetched", + gitlab_project__id=validated_data["gitlab_project_id"], ) return page_data diff --git a/api/integrations/gitlab/views/configuration.py b/api/integrations/gitlab/views/configuration.py index 6b0499caa719..402cd0d16e6f 100644 --- a/api/integrations/gitlab/views/configuration.py +++ b/api/integrations/gitlab/views/configuration.py @@ -1,4 +1,5 @@ import structlog +from structlog.typing import FilteringBoundLogger from integrations.common.views import ProjectIntegrationBaseViewSet from integrations.gitlab.models import GitLabConfiguration @@ -12,19 +13,17 @@ class GitLabConfigurationViewSet(ProjectIntegrationBaseViewSet): model_class = GitLabConfiguration # type: ignore[assignment] pagination_class = None - def _log_for( - self, instance: GitLabConfiguration - ) -> structlog.typing.FilteringBoundLogger: + def _log_for(self, config: GitLabConfiguration) -> FilteringBoundLogger: return logger.bind( # type: ignore[no-any-return] - project__id=instance.project.id, - organisation__id=instance.project.organisation_id, + project__id=config.project.id, + organisation__id=config.project.organisation_id, ) def perform_create(self, serializer: GitLabConfigurationSerializer) -> None: # type: ignore[override] super().perform_create(serializer) instance: GitLabConfiguration = serializer.instance # type: ignore[assignment] self._log_for(instance).info( - "configuration-created", + "configuration.created", gitlab_instance_url=instance.gitlab_instance_url, ) @@ -32,11 +31,11 @@ def perform_update(self, serializer: GitLabConfigurationSerializer) -> None: # super().perform_update(serializer) instance: GitLabConfiguration = serializer.instance # type: ignore[assignment] self._log_for(instance).info( - "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("configuration-deleted") + log.info("configuration.deleted") diff --git a/api/tests/integration/features/test_gitlab_external_resources.py b/api/tests/integration/features/test_gitlab_external_resources.py new file mode 100644 index 000000000000..86336643e15d --- /dev/null +++ b/api/tests/integration/features/test_gitlab_external_resources.py @@ -0,0 +1,196 @@ +import pytest +from pytest_structlog import StructuredLogCapture +from rest_framework import status +from rest_framework.test import APIClient + +from features.feature_external_resources.models import FeatureExternalResource +from features.models import Feature +from integrations.github.models import GithubConfiguration +from integrations.gitlab.models import GitLabConfiguration +from projects.models import Project + + +@pytest.mark.django_db() +def test_create_external_resource__gitlab_issue__returns_201( + admin_client: APIClient, + project: int, + feature: int, + log: StructuredLogCapture, +) -> None: + # Given + metadata = {"title": "Fix login bug", "state": "opened"} + organisation_id = Project.objects.get(id=project).organisation_id + + # When + response = admin_client.post( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + data={ + "type": "GITLAB_ISSUE", + "url": "https://gitlab.com/testorg/testrepo/-/work_items/42", + "feature": feature, + "metadata": metadata, + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + created = response.json() + assert created["type"] == "GITLAB_ISSUE" + assert created["url"] == "https://gitlab.com/testorg/testrepo/-/work_items/42" + assert created["feature"] == feature + assert created["metadata"] == metadata + + assert log.events == [ + { + "level": "info", + "event": "issue.linked", + "organisation__id": organisation_id, + "project__id": project, + "feature__id": feature, + }, + ] + + +@pytest.mark.django_db() +def test_create_external_resource__gitlab_merge_request__returns_201( + admin_client: APIClient, + project: int, + feature: int, + log: StructuredLogCapture, +) -> None: + # Given + metadata = {"title": "Add login button", "state": "opened"} + organisation_id = Project.objects.get(id=project).organisation_id + + # When + response = admin_client.post( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + data={ + "type": "GITLAB_MR", + "url": "https://gitlab.com/testorg/testrepo/-/merge_requests/7", + "feature": feature, + "metadata": metadata, + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + response_json = response.json() + assert response_json["type"] == "GITLAB_MR" + assert ( + response_json["url"] == "https://gitlab.com/testorg/testrepo/-/merge_requests/7" + ) + assert response_json["metadata"] == metadata + + assert log.events == [ + { + "level": "info", + "event": "merge_request.linked", + "organisation__id": organisation_id, + "project__id": project, + "feature__id": feature, + }, + ] + + +@pytest.mark.django_db() +def test_create_external_resource__gitlab_issue_with_github_also_configured__returns_201( + admin_client: APIClient, + project: int, + feature: int, + log: StructuredLogCapture, +) -> None: + # Given + project_instance = Project.objects.get(id=project) + organisation = project_instance.organisation + + GithubConfiguration.objects.create( + organisation=organisation, + installation_id="9999999", + ) + GitLabConfiguration.objects.create( + project=project_instance, + gitlab_instance_url="https://gitlab.com", + access_token="glpat-test-token", + ) + + # When + response = admin_client.post( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + data={ + "type": "GITLAB_ISSUE", + "url": "https://gitlab.com/testorg/testrepo/-/work_items/99", + "feature": feature, + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["type"] == "GITLAB_ISSUE" + + assert log.events == [ + { + "level": "info", + "event": "issue.linked", + "organisation__id": organisation.id, + "project__id": project, + "feature__id": feature, + }, + ] + + +@pytest.mark.django_db() +def test_list_external_resources__gitlab_issue__returns_200( + admin_client: APIClient, + project: int, + feature: int, +) -> None: + # Given + FeatureExternalResource.objects.create( + url="https://gitlab.com/testorg/testrepo/-/work_items/42", + type="GITLAB_ISSUE", + feature=Feature.objects.get(id=feature), + metadata='{"title": "Fix login bug", "state": "opened"}', + ) + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + ) + + # Then + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["type"] == "GITLAB_ISSUE" + assert results[0]["metadata"] == {"title": "Fix login bug", "state": "opened"} + + +@pytest.mark.django_db() +def test_list_external_resources__gitlab_merge_request__returns_200( + admin_client: APIClient, + project: int, + feature: int, +) -> None: + # Given + FeatureExternalResource.objects.create( + url="https://gitlab.com/testorg/testrepo/-/merge_requests/7", + type="GITLAB_MR", + feature=Feature.objects.get(id=feature), + metadata='{"title": "Add login button", "state": "opened"}', + ) + + # When + response = admin_client.get( + f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/", + ) + + # Then + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["type"] == "GITLAB_MR" + assert results[0]["metadata"] == {"title": "Add login button", "state": "opened"} diff --git a/api/tests/unit/features/test_unit_feature_external_resources_views.py b/api/tests/unit/features/test_unit_feature_external_resources_views.py index db27fac15d16..af38dfac4d2c 100644 --- a/api/tests/unit/features/test_unit_feature_external_resources_views.py +++ b/api/tests/unit/features/test_unit_feature_external_resources_views.py @@ -284,7 +284,8 @@ def test_create_feature_external_resource__incorrect_github_type__returns_400( # Then assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["detail"] == "Incorrect GitHub type" + assert "type" in response.json() + assert "is not a valid choice" in response.json()["type"][0] def test_create_feature_external_resource__no_github_integration__returns_400( diff --git a/api/tests/unit/integrations/gitlab/test_configuration.py b/api/tests/unit/integrations/gitlab/test_configuration.py index aa842081e0fd..fd1e3eb98371 100644 --- a/api/tests/unit/integrations/gitlab/test_configuration.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": "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": "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": "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 index 0151265fc6e8..ef655efb50e4 100644 --- a/api/tests/unit/integrations/gitlab/test_proxy_views.py +++ b/api/tests/unit/integrations/gitlab/test_proxy_views.py @@ -4,6 +4,7 @@ import pytest import requests +from pytest_structlog import StructuredLogCapture from rest_framework import status from integrations.gitlab.models import GitLabConfiguration @@ -29,6 +30,7 @@ def test_gitlab_project_list__valid_config__returns_paginated_response( admin_client: APIClient, project: Project, mocker: MockerFixture, + log: StructuredLogCapture, ) -> None: # Given mocker.patch( @@ -55,6 +57,15 @@ def test_gitlab_project_list__valid_config__returns_paginated_response( assert data["next"] is not None assert data["previous"] is None + assert log.events == [ + { + "level": "info", + "event": "projects.fetched", + "organisation__id": project.organisation_id, + "project__id": project.id, + }, + ] + def test_gitlab_project_list__no_gitlab_config__returns_400( admin_client: APIClient, @@ -74,6 +85,7 @@ def test_gitlab_issue_list__valid_config__returns_issues( admin_client: APIClient, project: Project, mocker: MockerFixture, + log: StructuredLogCapture, ) -> None: # Given mocker.patch( @@ -104,6 +116,16 @@ def test_gitlab_issue_list__valid_config__returns_issues( assert response.status_code == status.HTTP_200_OK assert response.json()["results"][0]["title"] == "Bug" + assert log.events == [ + { + "level": "info", + "event": "issues.fetched", + "organisation__id": project.organisation_id, + "project__id": project.id, + "gitlab_project__id": 42, + }, + ] + @pytest.mark.usefixtures("gitlab_config") def test_gitlab_issue_list__missing_gitlab_project_id__returns_400( @@ -124,6 +146,7 @@ def test_gitlab_merge_request_list__valid_config__returns_merge_requests( admin_client: APIClient, project: Project, mocker: MockerFixture, + log: StructuredLogCapture, ) -> None: # Given mocker.patch( @@ -156,6 +179,16 @@ def test_gitlab_merge_request_list__valid_config__returns_merge_requests( assert response.status_code == status.HTTP_200_OK assert response.json()["results"][0]["title"] == "Feature" + assert log.events == [ + { + "level": "info", + "event": "merge_requests.fetched", + "organisation__id": project.organisation_id, + "project__id": project.id, + "gitlab_project__id": 42, + }, + ] + def test_gitlab_merge_request_list__no_gitlab_config__returns_400( admin_client: APIClient, @@ -190,6 +223,7 @@ def test_browse_gitlab__api_unreachable__returns_503( admin_client: APIClient, project: Project, mocker: MockerFixture, + log: StructuredLogCapture, ) -> None: # Given mocker.patch( @@ -205,3 +239,14 @@ def test_browse_gitlab__api_unreachable__returns_503( # Then assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE assert response.json()["detail"] == "GitLab API is unreachable" + + assert log.events == [ + { + "level": "error", + "event": "api_call.failed", + "organisation__id": project.organisation_id, + "project__id": project.id, + "exc_info": mocker.ANY, + }, + ] + assert isinstance(log.events[0]["exc_info"], requests.RequestException) diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 69a33db88bb8..302737a659f6 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -321,8 +321,6 @@ export type GitLabMergeRequest = { draft: boolean } -export type GitLabLinkType = 'issue' | 'merge_request' - export type GithubPaginatedRepos = { total_count: number repository_selection: string 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/ExternalResourcesTable.tsx b/frontend/web/components/ExternalResourcesTable.tsx index 0ec9009195dd..3d3b16ecea68 100644 --- a/frontend/web/components/ExternalResourcesTable.tsx +++ b/frontend/web/components/ExternalResourcesTable.tsx @@ -36,7 +36,7 @@ const ExternalResourceRow: FC = ({ useEffect(() => { if (isDeleted) { - toast('External resources was deleted') + toast('Link deleted') } }, [isDeleted]) return ( 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 index 44b0a76e0593..10d94862e6d9 100644 --- a/frontend/web/components/GitLabLinkSection.tsx +++ b/frontend/web/components/GitLabLinkSection.tsx @@ -1,30 +1,37 @@ import React, { FC, useState } from 'react' -import Constants from 'common/constants' +import AppActions from 'common/dispatcher/app-actions' import ErrorMessage from './ErrorMessage' import GitLabProjectSelect from './GitLabProjectSelect' import GitLabSearchSelect from './GitLabSearchSelect' +import { useCreateExternalResourceMutation } from 'common/services/useExternalResource' import { useGetGitLabProjectsQuery } from 'common/services/useGitlab' -import type { - GitLabIssue, - GitLabLinkType, - GitLabMergeRequest, -} from 'common/types/responses' +import type { GitLabIssue, GitLabMergeRequest } from 'common/types/responses' type GitLabLinkSectionProps = { projectId: number + featureId: number + featureName: string + environmentId: string linkedUrls: string[] } +const gitlabLinkTypes = { + GITLAB_ISSUE: { label: 'Issue' }, + GITLAB_MR: { label: 'Merge Request' }, +} as const + +export type GitLabLinkType = keyof typeof gitlabLinkTypes + const GitLabLinkSection: FC = ({ + environmentId, + featureId, + featureName, linkedUrls, projectId, }) => { - const gitlabTypes = Object.values(Constants.resourceTypes).filter( - (v) => v.type === 'GITLAB', - ) - + const [createExternalResource] = useCreateExternalResourceMutation() const [gitlabProjectId, setGitlabProjectId] = useState(null) - const [linkType, setLinkType] = useState('issue') + const [linkType, setLinkType] = useState('GITLAB_ISSUE') const [selectedItem, setSelectedItem] = useState< GitLabIssue | GitLabMergeRequest | null >(null) @@ -40,6 +47,33 @@ const GitLabLinkSection: FC = ({ }) const projects = projectsData?.results ?? [] + const linkSelectedItem = async () => { + if (!selectedItem) return + + const linkTypeLabel = gitlabLinkTypes[linkType].label.toLowerCase() + + try { + await createExternalResource({ + body: { + feature: featureId, + metadata: { + state: selectedItem.state, + title: selectedItem.title, + }, + type: linkType, + url: selectedItem.web_url, + }, + feature_id: featureId, + project_id: projectId, + }).unwrap() + toast(`GitLab ${linkTypeLabel} linked to "${featureName}"`) + setSelectedItem(null) + AppActions.refreshFeatures(projectId, environmentId) + } catch (error) { + toast(`Could not link GitLab ${linkTypeLabel}.`, 'danger') + } + } + return (
@@ -86,7 +121,11 @@ const GitLabLinkSection: FC = ({ linkedUrls={linkedUrls} />
-
diff --git a/frontend/web/components/GitLabSearchSelect.tsx b/frontend/web/components/GitLabSearchSelect.tsx index 4c2ef34fdb80..d59b72c0e835 100644 --- a/frontend/web/components/GitLabSearchSelect.tsx +++ b/frontend/web/components/GitLabSearchSelect.tsx @@ -4,13 +4,13 @@ import { Req } from 'common/types/requests' import { Res, type GitLabIssue, - type GitLabLinkType, type GitLabMergeRequest, } from 'common/types/responses' import { useGetGitLabIssuesQuery, useGetGitLabMergeRequestsQuery, } from 'common/services/useGitlab' +import type { GitLabLinkType } from './GitLabLinkSection' type GitLabSearchSelectProps = { projectId: number @@ -30,7 +30,7 @@ const GitLabSearchSelect: FC = ({ value, }) => { const useQuery = - linkType === 'issue' + linkType === 'GITLAB_ISSUE' ? useGetGitLabIssuesQuery : (useGetGitLabMergeRequestsQuery as typeof useGetGitLabIssuesQuery) @@ -59,7 +59,9 @@ const GitLabSearchSelect: FC = ({ value={value ? { label: `${value.title} #${value.iid}`, value } : null} size='select-md' placeholder={ - linkType === 'issue' ? 'Search issues…' : 'Search merge requests…' + linkType === 'GITLAB_ISSUE' + ? 'Search issues…' + : 'Search merge requests…' } onChange={(v: { value: GitLabIssue | GitLabMergeRequest }) => { onChange(v.value) diff --git a/frontend/web/components/modals/create-feature/index.tsx b/frontend/web/components/modals/create-feature/index.tsx index c891f73c6e3a..8b209270faf8 100644 --- a/frontend/web/components/modals/create-feature/index.tsx +++ b/frontend/web/components/modals/create-feature/index.tsx @@ -15,8 +15,10 @@ import classNames from 'classnames' import { useHasPermission } from 'common/providers/Permission' import { setInterceptClose } from 'components/modals/base/ModalDefault' import { getStore } from 'common/store' -import ExternalResourcesLinkTab from 'components/ExternalResourcesLinkTab' +import ExternalResourcesTable from 'components/ExternalResourcesTable' +import GitHubLinkSection from 'components/GitHubLinkSection' import GitLabLinkSection from 'components/GitLabLinkSection' +import type { ExternalResource } from 'common/types/responses' import { saveFeatureWithValidation } from 'components/saveFeatureWithValidation' import FeatureHistory from 'components/FeatureHistory' import { getChangeRequests } from 'common/services/useChangeRequest' @@ -354,6 +356,8 @@ const CreateFeatureModal: FC = (props) => { const isLinksTabEnabled = (hasIntegrationWithGithub || hasGitlabIntegration) && !!projectFlag?.id + const [linkedResources, setLinkedResources] = useState() + const { permission: createFeaturePermission } = useHasPermission({ id: projectId, level: 'project', @@ -700,20 +704,32 @@ const CreateFeatureModal: FC = (props) => { } > {hasIntegrationWithGithub && ( - )} {hasGitlabIntegration && ( r.url) ?? []} /> )} + + setLinkedResources(r) + } + /> )} {!existingChangeRequest && flagId && isVersioned && (