diff --git a/src/sentry/notifications/notification_action/activity_registry/__init__.py b/src/sentry/notifications/notification_action/activity_registry/__init__.py index 81f26fdddfe2..0f8c594de610 100644 --- a/src/sentry/notifications/notification_action/activity_registry/__init__.py +++ b/src/sentry/notifications/notification_action/activity_registry/__init__.py @@ -3,6 +3,7 @@ from .msteams import MSTeamsActivityHandler from .sentry_app import SentryAppActivityHandler from .slack import SlackActivityHandler +from .ticketing import TicketingActivityHandler from .unsupported import UnsupportedActivityHandler __all__ = [ @@ -10,6 +11,7 @@ "EmailActivityHandler", "MSTeamsActivityHandler", "SentryAppActivityHandler", + "TicketingActivityHandler", "UnsupportedActivityHandler", "SlackActivityHandler", ] diff --git a/src/sentry/notifications/notification_action/activity_registry/ticketing.py b/src/sentry/notifications/notification_action/activity_registry/ticketing.py new file mode 100644 index 000000000000..7ee0913b3089 --- /dev/null +++ b/src/sentry/notifications/notification_action/activity_registry/ticketing.py @@ -0,0 +1,218 @@ +import logging +from typing import Any + +from sentry.constants import ObjectStatus +from sentry.exceptions import InvalidIdentity +from sentry.integrations.mixins.issues import IssueBasicIntegration +from sentry.integrations.models.external_issue import ExternalIssue +from sentry.integrations.project_management.metrics import ( + ProjectManagementActionType, + ProjectManagementEvent, +) +from sentry.integrations.services.integration.service import integration_service +from sentry.models.activity import Activity +from sentry.models.group import Group +from sentry.models.grouplink import GroupLink +from sentry.notifications.notification_action.activity_registry.base import require_integration_id +from sentry.notifications.notification_action.registry import activity_handler_registry +from sentry.notifications.notification_action.types import ActivityHandler +from sentry.notifications.utils.links import create_link_to_workflow +from sentry.shared_integrations.exceptions import ( + ApiUnauthorized, + IntegrationConfigurationError, + IntegrationFormError, + IntegrationProviderError, + IntegrationResourceNotFoundError, +) +from sentry.silo.base import cell_silo_function +from sentry.types.activity import ActivityType +from sentry.utils.http import absolute_uri +from sentry.workflow_engine.models import Action +from sentry.workflow_engine.types import ActionInvocation +from sentry.workflow_engine.typings.notification_action import TicketFieldMappingKeys + +logger = logging.getLogger(__name__) + +TICKETING_ACTIVITY_DESCRIPTIONS: dict[ActivityType, str] = { + ActivityType.SEER_RCA_COMPLETED: "Root Cause", + ActivityType.SEER_SOLUTION_COMPLETED: "Plan", + ActivityType.SEER_CODING_COMPLETED: "Code Changes", + ActivityType.SEER_PR_CREATED: "Pull Request", +} + +TICKETING_COMPATIBLE_ACTIVITY_TYPES = list(TICKETING_ACTIVITY_DESCRIPTIONS.keys()) + + +@cell_silo_function +def _create_link( + integration_id: int, + installation: IssueBasicIntegration, + organization_id: int, + group: Group, + group_title: str, + description: str, + response: dict[str, Any], +) -> None: + external_issue_key = installation.make_external_key(response) + + external_issue = ExternalIssue.objects.create( + organization_id=organization_id, + integration_id=integration_id, + key=external_issue_key, + title=group_title, + description=description, + metadata=response.get("metadata"), + ) + GroupLink.objects.create( + group_id=group.id, + project_id=group.project_id, + linked_type=GroupLink.LinkedType.issue, + linked_id=external_issue.id, + relationship=GroupLink.Relationship.references, + data={"provider": installation.model.get_provider().name}, + ) + issue_url = response.get("url") or installation.get_issue_url(external_issue.key) + Activity.objects.create_group_activity( + group=group, + type=ActivityType.CREATE_ISSUE, + data={ + "title": external_issue.title, + "provider": installation.model.get_provider().name, + "location": issue_url, + "label": installation.get_issue_display_name(external_issue) or external_issue.key, + "new": True, + }, + ) + + +def _has_linked_issue(group_id: int, project_id: int, integration_id: int) -> bool: + return ExternalIssue.objects.filter( + id__in=GroupLink.objects.filter( + project_id=project_id, + group_id=group_id, + linked_type=GroupLink.LinkedType.issue, + ).values_list("linked_id", flat=True), + integration_id=integration_id, + ).exists() + + +def _build_description( + installation: IssueBasicIntegration, + group: Group, + workflow_id: int | None, + organization_slug: str, +) -> str: + description_parts = installation.get_group_link(group) + if workflow_id is not None: + workflow_url = create_link_to_workflow(organization_slug, str(workflow_id)) + description_parts.append( + f"\nThis ticket was automatically created by Sentry via [Alert]({absolute_uri(workflow_url)})" + ) + return "\n".join(description_parts) + + +@activity_handler_registry.register(Action.Type.GITHUB) +@activity_handler_registry.register(Action.Type.GITHUB_ENTERPRISE) +@activity_handler_registry.register(Action.Type.JIRA) +@activity_handler_registry.register(Action.Type.JIRA_SERVER) +@activity_handler_registry.register(Action.Type.AZURE_DEVOPS) +class TicketingActivityHandler(ActivityHandler): + compatible_activity_types = TICKETING_COMPATIBLE_ACTIVITY_TYPES + + @classmethod + def invoke_action(cls, invocation: ActionInvocation, activity: Activity) -> None: + from sentry.notifications.platform.templates.activity.base import ( + extract_notification_models_by_activity, + ) + + action = invocation.action + group, project, organization = extract_notification_models_by_activity(activity) + + integration_id = require_integration_id(action) + provider = action.type + + integration = integration_service.get_integration( + integration_id=integration_id, + provider=provider, + organization_id=organization.id, + status=ObjectStatus.ACTIVE, + ) + if not integration: + logger.warning( + "notification_action.activity.ticketing.integration_not_found", + extra={ + "action_id": action.id, + "integration_id": integration_id, + "provider": provider, + }, + ) + return + + installation = integration.get_installation(organization.id) + if not isinstance(installation, IssueBasicIntegration): + logger.error( + "notification_action.activity.ticketing.invalid_installation", + extra={ + "action_id": action.id, + "integration_id": integration_id, + "provider": provider, + }, + ) + return + + if _has_linked_issue(group.id, project.id, integration_id): + logger.info( + "notification_action.activity.ticketing.link_already_exists", + extra={ + "action_id": action.id, + "group_id": group.id, + "project_id": project.id, + "integration_id": integration_id, + "provider": provider, + }, + ) + return + + activity_description = TICKETING_ACTIVITY_DESCRIPTIONS.get(ActivityType(activity.type)) + title = f"[{activity_description}] {group.title}" if activity_description else group.title + + data: dict[str, Any] = { + "title": title, + "description": _build_description( + installation, group, invocation.workflow_id, organization.slug + ), + } + + additional_fields = action.data.get(TicketFieldMappingKeys.ADDITIONAL_FIELDS_KEY.value, {}) + data.update(additional_fields) + + with ProjectManagementEvent( + action_type=ProjectManagementActionType.CREATE_EXTERNAL_ISSUE, + integration=integration, + ).capture() as lifecycle: + lifecycle.add_extra("provider", provider) + lifecycle.add_extra("integration_id", integration_id) + lifecycle.add_extra("action_id", action.id) + + try: + response = installation.create_issue(data) + except ( + IntegrationConfigurationError, + IntegrationFormError, + InvalidIdentity, + ApiUnauthorized, + IntegrationResourceNotFoundError, + IntegrationProviderError, + ) as e: + lifecycle.record_halt(e) + raise + + _create_link( + integration_id=integration.id, + installation=installation, + organization_id=organization.id, + group=group, + group_title=title, + description=data["description"], + response=response, + ) diff --git a/src/sentry/notifications/notification_action/activity_registry/unsupported.py b/src/sentry/notifications/notification_action/activity_registry/unsupported.py index afd8ca2456dc..171e0e82c85c 100644 --- a/src/sentry/notifications/notification_action/activity_registry/unsupported.py +++ b/src/sentry/notifications/notification_action/activity_registry/unsupported.py @@ -12,11 +12,6 @@ @activity_handler_registry.register(Action.Type.PAGERDUTY) @activity_handler_registry.register(Action.Type.OPSGENIE) -@activity_handler_registry.register(Action.Type.GITHUB) -@activity_handler_registry.register(Action.Type.GITHUB_ENTERPRISE) -@activity_handler_registry.register(Action.Type.JIRA) -@activity_handler_registry.register(Action.Type.JIRA_SERVER) -@activity_handler_registry.register(Action.Type.AZURE_DEVOPS) @activity_handler_registry.register(Action.Type.PLUGIN) class UnsupportedActivityHandler(ActivityHandler): compatible_activity_types = [ActivityType(key) for key in ActivityType] diff --git a/tests/sentry/notifications/notification_action/activity_registry/test_ticketing.py b/tests/sentry/notifications/notification_action/activity_registry/test_ticketing.py new file mode 100644 index 000000000000..1dd7f53ad3a5 --- /dev/null +++ b/tests/sentry/notifications/notification_action/activity_registry/test_ticketing.py @@ -0,0 +1,260 @@ +from unittest import mock + +import pytest + +from sentry.integrations.models.external_issue import ExternalIssue +from sentry.models.grouplink import GroupLink +from sentry.notifications.notification_action.activity_registry.ticketing import ( + TicketingActivityHandler, +) +from sentry.notifications.notification_action.registry import activity_handler_registry +from sentry.types.activity import ActivityType +from sentry.workflow_engine.models import Action +from tests.sentry.workflow_engine.test_base import BaseWorkflowTest + +TICKETING_ACTION_TYPES = [ + Action.Type.GITHUB, + Action.Type.GITHUB_ENTERPRISE, + Action.Type.JIRA, + Action.Type.JIRA_SERVER, + Action.Type.AZURE_DEVOPS, +] + + +@pytest.mark.parametrize("action_type", TICKETING_ACTION_TYPES) +def test_ticketing_registrations(action_type: Action.Type) -> None: + assert activity_handler_registry.get(action_type) is TicketingActivityHandler + + +class TestTicketingActivityHandler(BaseWorkflowTest): + def setUp(self) -> None: + super().setUp() + self.group = self.create_group() + self.workflow, self.detector, _, _ = self.create_detector_and_workflow() + self.integration = self.create_integration( + organization=self.organization, provider="github", external_id="gh_ext_id" + ) + self.action = self.create_action( + type=Action.Type.GITHUB, + integration_id=self.integration.id, + data={ + "dynamic_form_fields": [], + "additional_fields": {"repo": "org/repo"}, + }, + config={ + "target_identifier": None, + "target_display": None, + "target_type": 0, + }, + ) + + def _create_invocation(self, activity): + return self.create_action_invocation( + event=activity, + group=self.group, + action=self.action, + detector=self.detector, + workflow_id=self.workflow.id, + ) + + @mock.patch( + "sentry.notifications.notification_action.activity_registry.ticketing.integration_service" + ) + def test_invoke_action_creates_ticket(self, mock_integration_service): + mock_integration = mock.MagicMock() + mock_integration.id = self.integration.id + mock_integration.provider = "github" + mock_integration_service.get_integration.return_value = mock_integration + + mock_installation = mock.MagicMock() + mock_installation.__class__ = type( + "MockInstallation", + (mock.MagicMock.__class__,), + {}, + ) + mock_installation.get_group_link.return_value = [ + f"Sentry Issue: [{self.group.qualified_short_id}](http://testserver)" + ] + mock_installation.create_issue.return_value = { + "key": "GH-123", + "title": "Test Issue", + "url": "https://github.com/org/repo/issues/123", + } + mock_installation.make_external_key.return_value = "GH-123" + mock_installation.get_issue_url.return_value = "https://github.com/org/repo/issues/123" + mock_installation.get_issue_display_name.return_value = "" + mock_installation.model.get_provider.return_value.name = "github" + mock_integration.get_installation.return_value = mock_installation + + # Make isinstance check pass for IssueBasicIntegration + with mock.patch( + "sentry.notifications.notification_action.activity_registry.ticketing.isinstance", + return_value=True, + ): + activity = self.create_group_activity( + group=self.group, + type=ActivityType.SET_RESOLVED.value, + ) + invocation = self._create_invocation(activity) + TicketingActivityHandler.invoke_action(invocation=invocation, activity=activity) + + mock_installation.create_issue.assert_called_once() + call_data = mock_installation.create_issue.call_args[0][0] + assert call_data["title"] == self.group.title + + assert ExternalIssue.objects.filter( + organization_id=self.organization.id, + integration_id=self.integration.id, + key="GH-123", + ).exists() + + assert GroupLink.objects.filter( + group_id=self.group.id, + project_id=self.group.project_id, + linked_type=GroupLink.LinkedType.issue, + ).exists() + + @mock.patch( + "sentry.notifications.notification_action.activity_registry.ticketing.integration_service" + ) + def test_invoke_action_skips_when_link_exists(self, mock_integration_service): + mock_integration = mock.MagicMock() + mock_integration.id = self.integration.id + mock_integration.provider = "github" + mock_integration_service.get_integration.return_value = mock_integration + + mock_installation = mock.MagicMock() + mock_installation.get_group_link.return_value = ["Sentry Issue"] + mock_integration.get_installation.return_value = mock_installation + + external_issue = ExternalIssue.objects.create( + organization_id=self.organization.id, + integration_id=self.integration.id, + key="GH-EXISTING", + ) + GroupLink.objects.create( + group_id=self.group.id, + project_id=self.group.project_id, + linked_type=GroupLink.LinkedType.issue, + linked_id=external_issue.id, + relationship=GroupLink.Relationship.references, + ) + + with mock.patch( + "sentry.notifications.notification_action.activity_registry.ticketing.isinstance", + return_value=True, + ): + activity = self.create_group_activity( + group=self.group, + type=ActivityType.SET_RESOLVED.value, + ) + invocation = self._create_invocation(activity) + TicketingActivityHandler.invoke_action(invocation=invocation, activity=activity) + + mock_installation.create_issue.assert_not_called() + + @mock.patch("sentry.notifications.notification_action.activity_registry.ticketing.logger") + @mock.patch( + "sentry.notifications.notification_action.activity_registry.ticketing.integration_service" + ) + def test_invoke_action_returns_when_integration_not_found( + self, mock_integration_service, mock_logger + ): + mock_integration_service.get_integration.return_value = None + + activity = self.create_group_activity( + group=self.group, + type=ActivityType.SET_RESOLVED.value, + ) + invocation = self._create_invocation(activity) + TicketingActivityHandler.invoke_action(invocation=invocation, activity=activity) + + mock_logger.warning.assert_called_once_with( + "notification_action.activity.ticketing.integration_not_found", + extra=mock.ANY, + ) + + def test_invoke_action_raises_when_no_integration_id(self): + action_no_integration = self.create_action( + type=Action.Type.GITHUB, + integration_id=None, + data={ + "dynamic_form_fields": [], + "additional_fields": {"repo": "org/repo"}, + }, + config={ + "target_identifier": None, + "target_display": None, + "target_type": 0, + }, + ) + activity = self.create_group_activity( + group=self.group, + type=ActivityType.SET_RESOLVED.value, + ) + invocation = self.create_action_invocation( + event=activity, + group=self.group, + action=action_no_integration, + detector=self.detector, + workflow_id=self.workflow.id, + ) + with pytest.raises(ValueError, match="No integration_id"): + TicketingActivityHandler.invoke_action(invocation=invocation, activity=activity) + + @mock.patch( + "sentry.notifications.notification_action.activity_registry.ticketing.integration_service" + ) + def test_invoke_action_includes_additional_fields(self, mock_integration_service): + action_with_data = self.create_action( + type=Action.Type.JIRA, + integration_id=self.integration.id, + data={ + "dynamic_form_fields": [], + "additional_fields": {"project": "PROJ", "issuetype": "Bug"}, + }, + config={ + "target_identifier": None, + "target_display": None, + "target_type": 0, + }, + ) + + mock_integration = mock.MagicMock() + mock_integration.id = self.integration.id + mock_integration.provider = "jira" + mock_integration_service.get_integration.return_value = mock_integration + + mock_installation = mock.MagicMock() + mock_installation.get_group_link.return_value = ["Sentry Issue"] + mock_installation.create_issue.return_value = { + "key": "PROJ-1", + "title": "Test", + "url": "https://jira.example.com/PROJ-1", + } + mock_installation.make_external_key.return_value = "PROJ-1" + mock_installation.get_issue_url.return_value = "https://jira.example.com/PROJ-1" + mock_installation.get_issue_display_name.return_value = "" + mock_installation.model.get_provider.return_value.name = "jira" + mock_integration.get_installation.return_value = mock_installation + + with mock.patch( + "sentry.notifications.notification_action.activity_registry.ticketing.isinstance", + return_value=True, + ): + activity = self.create_group_activity( + group=self.group, + type=ActivityType.SET_RESOLVED.value, + ) + invocation = self.create_action_invocation( + event=activity, + group=self.group, + action=action_with_data, + detector=self.detector, + workflow_id=self.workflow.id, + ) + TicketingActivityHandler.invoke_action(invocation=invocation, activity=activity) + + call_data = mock_installation.create_issue.call_args[0][0] + assert call_data["project"] == "PROJ" + assert call_data["issuetype"] == "Bug" diff --git a/tests/sentry/notifications/notification_action/activity_registry/test_unsupported.py b/tests/sentry/notifications/notification_action/activity_registry/test_unsupported.py index 1bc0192436dd..2cb0b751cce6 100644 --- a/tests/sentry/notifications/notification_action/activity_registry/test_unsupported.py +++ b/tests/sentry/notifications/notification_action/activity_registry/test_unsupported.py @@ -13,11 +13,6 @@ UNSUPPORTED_ACTION_TYPES = [ Action.Type.PAGERDUTY, Action.Type.OPSGENIE, - Action.Type.GITHUB, - Action.Type.GITHUB_ENTERPRISE, - Action.Type.JIRA, - Action.Type.JIRA_SERVER, - Action.Type.AZURE_DEVOPS, Action.Type.PLUGIN, ]