Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 156 additions & 143 deletions src/sentry/issues/endpoints/group_integration_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from django.contrib.auth.models import AnonymousUser
from django.db import IntegrityError, router, transaction
from drf_spectacular.utils import extend_schema
from rest_framework.request import Request
from rest_framework.response import Response

Expand Down Expand Up @@ -69,63 +70,20 @@


@region_silo_endpoint
@extend_schema(tags=["Integrations"])
class GroupIntegrationDetailsEndpoint(GroupEndpoint):
owner = ApiOwner.ECOSYSTEM
publish_status = {
"DELETE": ApiPublishStatus.UNKNOWN,
"GET": ApiPublishStatus.UNKNOWN,
"PUT": ApiPublishStatus.UNKNOWN,
"POST": ApiPublishStatus.UNKNOWN,
"GET": ApiPublishStatus.PUBLIC,
"POST": ApiPublishStatus.PUBLIC,
"PUT": ApiPublishStatus.PUBLIC,
"DELETE": ApiPublishStatus.PUBLIC,
}

def _has_issue_feature(self, organization, user) -> bool:
has_issue_basic = features.has(
"organizations:integrations-issue-basic", organization, actor=user
)

has_issue_sync = features.has(
"organizations:integrations-issue-sync", organization, actor=user
)

return has_issue_sync or has_issue_basic

def _has_issue_feature_on_integration(self, integration: RpcIntegration) -> bool:
return integration.has_feature(
feature=IntegrationFeatures.ISSUE_BASIC
) or integration.has_feature(feature=IntegrationFeatures.ISSUE_SYNC)

def _get_installation(
self, integration: RpcIntegration, organization_id: int
) -> IssueBasicIntegration:
installation = integration.get_installation(organization_id=organization_id)
if not isinstance(installation, IssueBasicIntegration):
raise ValueError(installation)
return installation

def create_issue_activity(
self,
request: Request,
group: Group,
installation: IssueBasicIntegration,
external_issue: ExternalIssue,
new: bool,
):
issue_information = {
"title": external_issue.title,
"provider": installation.model.get_provider().name,
"location": installation.get_issue_url(external_issue.key),
"label": installation.get_issue_display_name(external_issue) or external_issue.key,
"new": new,
}
Activity.objects.create(
project=group.project,
group=group,
type=ActivityType.CREATE_ISSUE.value,
user_id=request.user.id,
data=issue_information,
)

def get(self, request: Request, group, integration_id) -> Response:
"""
Retrieves the config needed to either link or create an external issue for a group.
"""
if not request.user.is_authenticated:
return Response(status=400)
elif not self._has_issue_feature(group.organization, request.user):
Expand Down Expand Up @@ -174,8 +132,104 @@
)
)

# was thinking put for link an existing issue, post for create new issue?
def post(self, request: Request, group, integration_id) -> Response:
"""
Creates a new external issue and link it to a group.
"""
if not request.user.is_authenticated:
return Response(status=400)
elif not self._has_issue_feature(group.organization, request.user):
return Response({"detail": MISSING_FEATURE_MESSAGE}, status=400)

organization_id = group.project.organization_id
result = integration_service.organization_context(
organization_id=organization_id, integration_id=integration_id
)
integration = result.integration
org_integration = result.organization_integration
if not integration or not org_integration:
return Response(status=404)

if not self._has_issue_feature_on_integration(integration):
return Response(
{"detail": "This feature is not supported for this integration."}, status=400
)

installation = self._get_installation(integration, organization_id)

with ProjectManagementEvent(
action_type=ProjectManagementActionType.CREATE_EXTERNAL_ISSUE_VIA_ISSUE_DETAIL,
integration=integration,
).capture() as lifecycle:
lifecycle.add_extras(
{
"provider": integration.provider,
"integration_id": integration.id,
}
)

try:
data = installation.create_issue(request.data)
except IntegrationConfigurationError as exc:
lifecycle.record_halt(exc)
return Response({"non_field_errors": [str(exc)]}, status=400)
except IntegrationFormError as exc:
lifecycle.record_halt(exc)
return Response(exc.field_errors, status=400)
except IntegrationError as e:
lifecycle.record_failure(e)
return Response({"non_field_errors": [str(e)]}, status=400)

external_issue_key = installation.make_external_key(data)
external_issue, created = ExternalIssue.objects.get_or_create(
organization_id=organization_id,
integration_id=integration.id,
key=external_issue_key,
defaults={
"title": data.get("title"),
"description": data.get("description"),
"metadata": data.get("metadata"),
},
)

try:
with transaction.atomic(router.db_for_write(GroupLink)):
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,
)
except IntegrityError:
return Response({"detail": "That issue is already linked"}, status=400)

if created:
integration_issue_created.send_robust(
integration=integration,
organization=group.project.organization,
user=request.user,
sender=self.__class__,
)
installation.store_issue_last_defaults(group.project, request.user, request.data)

self.create_issue_activity(request, group, installation, external_issue, new=True)

# TODO(jess): return serialized issue
url = data.get("url") or installation.get_issue_url(external_issue.key)
context = {
"id": external_issue.id,
"key": external_issue.key,
"url": url,
"integrationId": external_issue.integration_id,
"displayName": installation.get_issue_display_name(external_issue),
}
return Response(context, status=201)

def put(self, request: Request, group, integration_id) -> Response:
"""
Links an existing external issue to a group.
"""
if not request.user.is_authenticated:
return Response(status=400)
elif not self._has_issue_feature(group.organization, request.user):
Expand Down Expand Up @@ -258,13 +312,13 @@
relationship=GroupLink.Relationship.references,
)
except IntegrityError as exc:
lifecycle.record_halt(exc)

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.
return Response({"non_field_errors": ["That issue is already linked"]}, status=400)

self.create_issue_activity(request, group, installation, external_issue, new=False)

# TODO(jess): would be helpful to return serialized external issue
# once we have description, title, etc

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.
url = data.get("url") or installation.get_issue_url(external_issue.key)
context = {
"id": external_issue.id,
Expand All @@ -275,98 +329,10 @@
}
return Response(context, status=201)

def post(self, request: Request, group, integration_id) -> Response:
if not request.user.is_authenticated:
return Response(status=400)
elif not self._has_issue_feature(group.organization, request.user):
return Response({"detail": MISSING_FEATURE_MESSAGE}, status=400)

organization_id = group.project.organization_id
result = integration_service.organization_context(
organization_id=organization_id, integration_id=integration_id
)
integration = result.integration
org_integration = result.organization_integration
if not integration or not org_integration:
return Response(status=404)

if not self._has_issue_feature_on_integration(integration):
return Response(
{"detail": "This feature is not supported for this integration."}, status=400
)

installation = self._get_installation(integration, organization_id)

with ProjectManagementEvent(
action_type=ProjectManagementActionType.CREATE_EXTERNAL_ISSUE_VIA_ISSUE_DETAIL,
integration=integration,
).capture() as lifecycle:
lifecycle.add_extras(
{
"provider": integration.provider,
"integration_id": integration.id,
}
)

try:
data = installation.create_issue(request.data)
except IntegrationConfigurationError as exc:
lifecycle.record_halt(exc)
return Response({"non_field_errors": [str(exc)]}, status=400)
except IntegrationFormError as exc:
lifecycle.record_halt(exc)
return Response(exc.field_errors, status=400)
except IntegrationError as e:
lifecycle.record_failure(e)
return Response({"non_field_errors": [str(e)]}, status=400)

external_issue_key = installation.make_external_key(data)
external_issue, created = ExternalIssue.objects.get_or_create(
organization_id=organization_id,
integration_id=integration.id,
key=external_issue_key,
defaults={
"title": data.get("title"),
"description": data.get("description"),
"metadata": data.get("metadata"),
},
)

try:
with transaction.atomic(router.db_for_write(GroupLink)):
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,
)
except IntegrityError:
return Response({"detail": "That issue is already linked"}, status=400)

if created:
integration_issue_created.send_robust(
integration=integration,
organization=group.project.organization,
user=request.user,
sender=self.__class__,
)
installation.store_issue_last_defaults(group.project, request.user, request.data)

self.create_issue_activity(request, group, installation, external_issue, new=True)

# TODO(jess): return serialized issue
url = data.get("url") or installation.get_issue_url(external_issue.key)
context = {
"id": external_issue.id,
"key": external_issue.key,
"url": url,
"integrationId": external_issue.integration_id,
"displayName": installation.get_issue_display_name(external_issue),
}
return Response(context, status=201)

def delete(self, request: Request, group, integration_id) -> Response:
"""
Delete a link between a group and an external issue.
"""
if not self._has_issue_feature(group.organization, request.user):
return Response({"detail": MISSING_FEATURE_MESSAGE}, status=400)

Expand Down Expand Up @@ -408,3 +374,50 @@
external_issue.delete()

return Response(status=204)

def _has_issue_feature(self, organization, user) -> bool:
has_issue_basic = features.has(
"organizations:integrations-issue-basic", organization, actor=user
)

has_issue_sync = features.has(
"organizations:integrations-issue-sync", organization, actor=user
)

return has_issue_sync or has_issue_basic

def _has_issue_feature_on_integration(self, integration: RpcIntegration) -> bool:
return integration.has_feature(
feature=IntegrationFeatures.ISSUE_BASIC
) or integration.has_feature(feature=IntegrationFeatures.ISSUE_SYNC)

def _get_installation(
self, integration: RpcIntegration, organization_id: int
) -> IssueBasicIntegration:
installation = integration.get_installation(organization_id=organization_id)
if not isinstance(installation, IssueBasicIntegration):
raise ValueError(installation)
return installation

def create_issue_activity(
self,
request: Request,
group: Group,
installation: IssueBasicIntegration,
external_issue: ExternalIssue,
new: bool,
):
issue_information = {
"title": external_issue.title,
"provider": installation.model.get_provider().name,
"location": installation.get_issue_url(external_issue.key),
"label": installation.get_issue_display_name(external_issue) or external_issue.key,
"new": new,
}
Activity.objects.create(
project=group.project,
group=group,
type=ActivityType.CREATE_ISSUE.value,
user_id=request.user.id,
data=issue_information,
)
Loading