Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ecosystem): Adds Project Management integration SLOs #80719

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
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
112 changes: 63 additions & 49 deletions src/sentry/api/endpoints/group_integration_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
from sentry.integrations.api.serializers.models.integration import IntegrationSerializer
from sentry.integrations.base import IntegrationFeatures, IntegrationInstallation
from sentry.integrations.models.external_issue import ExternalIssue
from sentry.integrations.project_management.metrics import (
ProjectManagementActionType,
ProjectManagementEvent,
)
from sentry.integrations.services.integration import RpcIntegration, integration_service
from sentry.models.activity import Activity
from sentry.models.group import Group
Expand Down Expand Up @@ -162,62 +166,72 @@
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
)
with ProjectManagementEvent(
action_type=ProjectManagementActionType.LINK_EXTERNAL_ISSUE,
integration=integration,
).capture() as lifecycle:
if not self._has_issue_feature_on_integration(integration):
return Response(
{"detail": "This feature is not supported for this integration."}, status=400
)

installation = integration.get_installation(organization_id=organization_id)
try:
data = installation.get_issue(external_issue_id, data=request.data)
except IntegrationFormError as exc:
return Response(exc.field_errors, status=400)
except IntegrationError as e:
return Response({"non_field_errors": [str(e)]}, status=400)
installation = integration.get_installation(organization_id=organization_id)

defaults = {
"title": data.get("title"),
"description": data.get("description"),
"metadata": data.get("metadata"),
}
try:
data = installation.get_issue(external_issue_id, data=request.data)
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)

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.

Copilot Autofix AI 2 days ago

To fix the problem, we should avoid exposing the detailed exception message to the end user. Instead, we can log the detailed error message on the server for debugging purposes and return a generic error message to the user. This approach ensures that sensitive information is not exposed while still allowing developers to diagnose issues.

  • Modify the exception handling code to log the detailed error message.
  • Return a generic error message to the user instead of the detailed exception message.
Suggested changeset 1
src/sentry/api/endpoints/group_integration_details.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/sentry/api/endpoints/group_integration_details.py b/src/sentry/api/endpoints/group_integration_details.py
--- a/src/sentry/api/endpoints/group_integration_details.py
+++ b/src/sentry/api/endpoints/group_integration_details.py
@@ -186,3 +186,4 @@
                 lifecycle.record_failure(e)
-                return Response({"non_field_errors": [str(e)]}, status=400)
+                self.log_error(e)
+                return Response({"non_field_errors": ["An internal error has occurred."]}, status=400)
 
@@ -249,2 +250,8 @@
 
+    def log_error(self, error: Exception) -> None:
+        # Log the detailed error message for debugging purposes
+        import logging
+        logger = logging.getLogger(__name__)
+        logger.error("Integration error occurred: %s", str(error))
+
     def post(self, request: Request, group, integration_id) -> Response:
EOF
@@ -186,3 +186,4 @@
lifecycle.record_failure(e)
return Response({"non_field_errors": [str(e)]}, status=400)
self.log_error(e)
return Response({"non_field_errors": ["An internal error has occurred."]}, status=400)

@@ -249,2 +250,8 @@

def log_error(self, error: Exception) -> None:
# Log the detailed error message for debugging purposes
import logging
logger = logging.getLogger(__name__)
logger.error("Integration error occurred: %s", str(error))

def post(self, request: Request, group, integration_id) -> Response:
Copilot is powered by AI and may make mistakes. Always verify output.
Positive Feedback
Negative Feedback

Provide additional feedback

Please help us improve GitHub Copilot by sharing more details about this comment.

Please select one or more of the options
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't new code, this is what allows errors to propagate up to the UI when ticket creation or linking fails on the integrator side (usually a config issue with the ticket contents)


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=defaults,
)
defaults = {
"title": data.get("title"),
"description": data.get("description"),
"metadata": data.get("metadata"),
}

if created:
integration_issue_linked.send_robust(
integration=integration,
organization=group.project.organization,
user=request.user,
sender=self.__class__,
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=defaults,
)
else:
external_issue.update(**defaults)

installation.store_issue_last_defaults(group.project, request.user, request.data)
try:
installation.after_link_issue(external_issue, data=request.data)
except IntegrationFormError as exc:
return Response(exc.field_errors, status=400)
except IntegrationError as e:
return Response({"non_field_errors": [str(e)]}, status=400)

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,
if created:
integration_issue_linked.send_robust(
integration=integration,
organization=group.project.organization,
user=request.user,
sender=self.__class__,
)
except IntegrityError:
return Response({"non_field_errors": ["That issue is already linked"]}, status=400)
else:
external_issue.update(**defaults)

installation.store_issue_last_defaults(group.project, request.user, request.data)
try:
installation.after_link_issue(external_issue, data=request.data)
except IntegrationFormError as exc:
lifecycle.record_halt(exc)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these errors user config errors?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, typically an IntegrationFormError is an incorrectly configured Jira ticket creation config.

return Response(exc.field_errors, status=400)
except IntegrationError as e:
lifecycle.record_failure(e)
return Response({"non_field_errors": [str(e)]}, status=400)

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.

Copilot Autofix AI 2 days ago

To fix the problem, we need to ensure that detailed exception messages are not exposed to the end user. Instead, we should log the detailed error message on the server and return a generic error message to the user. This can be achieved by modifying the exception handling code to log the exception and return a generic error message.

  1. Import the logging module to log the detailed error message.
  2. Modify the exception handling blocks to log the exception and return a generic error message.
Suggested changeset 1
src/sentry/api/endpoints/group_integration_details.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/sentry/api/endpoints/group_integration_details.py b/src/sentry/api/endpoints/group_integration_details.py
--- a/src/sentry/api/endpoints/group_integration_details.py
+++ b/src/sentry/api/endpoints/group_integration_details.py
@@ -27,2 +27,3 @@
 from sentry.users.models.user import User
+import logging
 
@@ -220,3 +221,4 @@
                 lifecycle.record_failure(e)
-                return Response({"non_field_errors": [str(e)]}, status=400)
+                logging.error("IntegrationError: %s", str(e))
+                return Response({"non_field_errors": ["An internal error has occurred."]}, status=400)
 
@@ -233,2 +235,3 @@
                 lifecycle.record_halt(exc)
+                logging.error("IntegrityError: %s", str(exc))
                 return Response({"non_field_errors": ["That issue is already linked"]}, status=400)
EOF
@@ -27,2 +27,3 @@
from sentry.users.models.user import User
import logging

@@ -220,3 +221,4 @@
lifecycle.record_failure(e)
return Response({"non_field_errors": [str(e)]}, status=400)
logging.error("IntegrationError: %s", str(e))
return Response({"non_field_errors": ["An internal error has occurred."]}, status=400)

@@ -233,2 +235,3 @@
lifecycle.record_halt(exc)
logging.error("IntegrityError: %s", str(exc))
return Response({"non_field_errors": ["That issue is already linked"]}, status=400)
Copilot is powered by AI and may make mistakes. Always verify output.
Positive Feedback
Negative Feedback

Provide additional feedback

Please help us improve GitHub Copilot by sharing more details about this comment.

Please select one or more of the options

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 as exc:
lifecycle.record_halt(exc)
return Response({"non_field_errors": ["That issue is already linked"]}, status=400)

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

Expand Down
55 changes: 35 additions & 20 deletions src/sentry/integrations/jira/utils/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from sentry.shared_integrations.exceptions import ApiError

from ...mixins.issues import IssueSyncIntegration
from ...project_management.metrics import ProjectManagementActionType, ProjectManagementEvent
from ..client import JiraCloudClient

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -75,26 +76,40 @@ def handle_assignee_change(
sync_group_assignee_inbound(integration, email, issue_key, assign=True)


def handle_status_change(integration, data):
issue_key = data["issue"]["key"]
status_changed = any(item for item in data["changelog"]["items"] if item["field"] == "status")
log_context = {"issue_key": issue_key, "integration_id": integration.id}

if not status_changed:
logger.info("jira.handle_status_change.unchanged", extra=log_context)
return

try:
changelog = next(item for item in data["changelog"]["items"] if item["field"] == "status")
except StopIteration:
logger.info("jira.missing-changelog-status", extra=log_context)
return

result = integration_service.organization_contexts(integration_id=integration.id)
for oi in result.organization_integrations:
install = integration.get_installation(organization_id=oi.organization_id)
if isinstance(install, IssueSyncIntegration):
install.sync_status_inbound(issue_key, {"changelog": changelog, "issue": data["issue"]})
# TODO(Gabe): Consolidate this with VSTS's implementation, create DTO for status
# changes.
def handle_status_change(integration: RpcIntegration, data: Mapping[str, Any]) -> None:
with ProjectManagementEvent(
action_type=ProjectManagementActionType.INBOUND_STATUS_SYNC, integration=integration
).capture() as lifecycle:
issue_key = data["issue"]["key"]
status_changed = any(
item for item in data["changelog"]["items"] if item["field"] == "status"
)
log_context = {"issue_key": issue_key, "integration_id": integration.id}

if not status_changed:
logger.info("jira.handle_status_change.unchanged", extra=log_context)
return

try:
changelog = next(
item for item in data["changelog"]["items"] if item["field"] == "status"
)
except StopIteration:
lifecycle.record_halt("missing-changelog-status", extra=log_context)
logger.info("jira.missing-changelog-status", extra=log_context)
return

result = integration_service.organization_contexts(integration_id=integration.id)
for oi in result.organization_integrations:
install = integration.get_installation(organization_id=oi.organization_id)
if isinstance(install, IssueSyncIntegration):
install.sync_status_inbound(
issue_key, {"changelog": changelog, "issue": data["issue"]}
)
else:
lifecycle.record_halt("installation-not-issue-sync-instance", extra=log_context)


def handle_jira_api_error(error: ApiError, message: str = "") -> Mapping[str, str] | None:
Expand Down
58 changes: 33 additions & 25 deletions src/sentry/integrations/jira/webhooks/installed.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from sentry.integrations.utils.atlassian_connect import authenticate_asymmetric_jwt, verify_claims
from sentry.utils import jwt

from ...base import IntegrationDomain
from ...utils.metrics import IntegrationPipelineViewEvent, IntegrationPipelineViewType
from ..integration import JiraIntegrationProvider
from .base import JiraWebhookBase

Expand All @@ -26,28 +28,34 @@ class JiraSentryInstalledWebhook(JiraWebhookBase):
"""

def post(self, request: Request, *args, **kwargs) -> Response:
token = self.get_token(request)

state = request.data
if not state:
return self.respond(status=status.HTTP_400_BAD_REQUEST)

key_id = jwt.peek_header(token).get("kid")
if key_id:
decoded_claims = authenticate_asymmetric_jwt(token, key_id)
verify_claims(decoded_claims, request.path, request.GET, method="POST")

data = JiraIntegrationProvider().build_integration(state)
integration = ensure_integration(self.provider, data)

# Note: Unlike in all other Jira webhooks, we don't call `bind_org_context_from_integration`
# here, because at this point the integration hasn't yet been bound to an organization. The
# best we can do at this point is to record the integration's id.
sentry_sdk.set_tag("integration_id", integration.id)

# Sync integration metadata from Jira. This must be executed *after*
# the integration has been installed on Jira as the access tokens will
# not work until then.
sync_metadata.apply_async(kwargs={"integration_id": integration.id}, countdown=10)

return self.respond()
with IntegrationPipelineViewEvent(
interaction_type=IntegrationPipelineViewType.VERIFY_INSTALLATION,
domain=IntegrationDomain.PROJECT_MANAGEMENT,
provider_key=self.provider,
).capture() as lifecycle:
token = self.get_token(request)

state = request.data
if not state:
lifecycle.record_failure("state-missing-from-installation-request")
return self.respond(status=status.HTTP_400_BAD_REQUEST)

key_id = jwt.peek_header(token).get("kid")
if key_id:
decoded_claims = authenticate_asymmetric_jwt(token, key_id)
verify_claims(decoded_claims, request.path, request.GET, method="POST")

data = JiraIntegrationProvider().build_integration(state)
integration = ensure_integration(self.provider, data)

# Note: Unlike in all other Jira webhooks, we don't call `bind_org_context_from_integration`
# here, because at this point the integration hasn't yet been bound to an organization. The
# best we can do at this point is to record the integration's id.
sentry_sdk.set_tag("integration_id", integration.id)

# Sync integration metadata from Jira. This must be executed *after*
# the integration has been installed on Jira as the access tokens will
# not work until then.
sync_metadata.apply_async(kwargs={"integration_id": integration.id}, countdown=10)

return self.respond()
Empty file.
35 changes: 35 additions & 0 deletions src/sentry/integrations/project_management/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from dataclasses import dataclass
from enum import Enum

from sentry.integrations.base import IntegrationDomain
from sentry.integrations.models import Integration
from sentry.integrations.services.integration import RpcIntegration
from sentry.integrations.utils.metrics import IntegrationEventLifecycleMetric


class ProjectManagementActionType(Enum):
CREATE_EXTERNAL_ISSUE = "create_external_issue"
OUTBOUND_ASSIGNMENT_SYNC = "outbound_assignment_sync"
INBOUND_ASSIGNMENT_SYNC = "inbound_assignment_sync"
COMMENT_SYNC = "comment_sync"
OUTBOUND_STATUS_SYNC = "outbound_status_sync"
INBOUND_STATUS_SYNC = "inbound_status_sync"
LINK_EXTERNAL_ISSUE = "link_external_issue"

def __str__(self):
return self.value.lower()


@dataclass
class ProjectManagementEvent(IntegrationEventLifecycleMetric):
action_type: ProjectManagementActionType
integration: Integration | RpcIntegration
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out we don't always have access to integration installations (specifically the inbound assignment code), so I made this use integration/rpc integration.


def get_integration_name(self) -> str:
return self.integration.provider

def get_integration_domain(self) -> IntegrationDomain:
return IntegrationDomain.PROJECT_MANAGEMENT

def get_interaction_type(self) -> str:
return str(self.action_type)
59 changes: 41 additions & 18 deletions src/sentry/integrations/tasks/sync_assignee_outbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
from sentry.constants import ObjectStatus
from sentry.integrations.models.external_issue import ExternalIssue
from sentry.integrations.models.integration import Integration
from sentry.integrations.project_management.metrics import (
ProjectManagementActionType,
ProjectManagementEvent,
)
from sentry.integrations.services.assignment_source import AssignmentSource
from sentry.integrations.services.integration import integration_service
from sentry.models.organization import Organization
Expand Down Expand Up @@ -48,23 +52,42 @@ def sync_assignee_outbound(
return

installation = integration.get_installation(organization_id=external_issue.organization_id)
if not (
hasattr(installation, "should_sync") and hasattr(installation, "sync_assignee_outbound")
):
return

parsed_assignment_source = (
AssignmentSource.from_dict(assignment_source_dict) if assignment_source_dict else None
)
if installation.should_sync("outbound_assignee", parsed_assignment_source):
# Assume unassign if None.
user = user_service.get_user(user_id) if user_id else None
installation.sync_assignee_outbound(
external_issue, user, assign=assign, assignment_source=parsed_assignment_source
)
analytics.record(
"integration.issue.assignee.synced",
provider=integration.provider,
id=integration.id,
organization_id=external_issue.organization_id,
with ProjectManagementEvent(
action_type=ProjectManagementActionType.OUTBOUND_ASSIGNMENT_SYNC, integration=integration
).capture() as lifecycle:
if not (
hasattr(installation, "should_sync") and hasattr(installation, "sync_assignee_outbound")
):
lifecycle.record_halt(
"sync_assignee_outbound.integration_missing_sync_methods",
extra={
"organization_id": external_issue.organization_id,
"external_issue_id": external_issue_id,
},
)
return

parsed_assignment_source = (
AssignmentSource.from_dict(assignment_source_dict) if assignment_source_dict else None
)
if installation.should_sync("outbound_assignee", parsed_assignment_source):
# Assume unassign if None.
user = user_service.get_user(user_id) if user_id else None
installation.sync_assignee_outbound(
external_issue, user, assign=assign, assignment_source=parsed_assignment_source
)
analytics.record(
"integration.issue.assignee.synced",
provider=integration.provider,
id=integration.id,
organization_id=external_issue.organization_id,
)
else:
lifecycle.record_halt(
"sync_assignee_outbound.marked_should_not_sync",
extra={
"organization_id": external_issue.organization_id,
"external_issue_id": external_issue_id,
},
)
Loading
Loading