diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index a66b3b63ace3..6f3de473ec0e 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -170,6 +170,8 @@ def register_temporary_features(manager: FeatureManager) -> None: # Project Management Integrations Feature Parity Flags manager.add("organizations:integrations-github_enterprise-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-gitlab-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Temporary: log full Jira Cloud `issue.updated` webhook payloads so we can design project-change link rewriting. + manager.add("organizations:jira-issue-updated-payload-logging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable inviting billing members to organizations at the member limit. manager.add("organizations:invite-billing", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=False, api_expose=False) # Enable inviting members to organizations. diff --git a/src/sentry/integrations/jira/webhooks/issue_updated.py b/src/sentry/integrations/jira/webhooks/issue_updated.py index cbb1ab6b642c..0c33036d0852 100644 --- a/src/sentry/integrations/jira/webhooks/issue_updated.py +++ b/src/sentry/integrations/jira/webhooks/issue_updated.py @@ -10,11 +10,14 @@ from rest_framework.response import Response from sentry_sdk import Scope +from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint +from sentry.integrations.services.integration import integration_service from sentry.integrations.utils.atlassian_connect import get_integration_from_jwt from sentry.integrations.utils.scope import bind_org_context_from_integration +from sentry.organizations.services.organization import organization_service from sentry.ratelimits.config import RateLimitConfig from sentry.shared_integrations.exceptions import ApiError from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -24,6 +27,26 @@ logger = logging.getLogger(__name__) +PAYLOAD_LOGGING_FEATURE = "organizations:jira-issue-updated-payload-logging" + + +def _payload_logging_enabled(integration_id: int) -> bool: + """True if any org linked to this Jira integration has the + `jira-issue-updated-payload-logging` feature enabled. + + A Jira integration can be shared by multiple Sentry orgs, and + `features.has` needs an `Organization`, so we have to walk the linked + `OrganizationIntegration` rows and look each org up. + """ + contexts = integration_service.organization_contexts(integration_id=integration_id) + for oi in contexts.organization_integrations: + org = organization_service.get_organization_by_id( + id=oi.organization_id, include_teams=False, include_projects=False + ) + if org and features.has(PAYLOAD_LOGGING_FEATURE, org.organization): + return True + return False + @cell_silo_endpoint class JiraIssueUpdatedWebhook(JiraWebhookBase): @@ -75,6 +98,46 @@ def post(self, request: Request, *args, **kwargs) -> Response: sentry_sdk.set_tag("integration_id", rpc_integration.id) data = request.data + + # Temporary: when a linked org has the + # `jira-issue-updated-payload-logging` feature enabled, log the full + # webhook payload so we can see exactly what Jira sends us (especially + # for `project` changes, which we want to use to update the linked + # Jira issue link in Sentry). + # + # This is purely diagnostic, so swallow any failure (including transient + # RPC errors from the feature-flag check) rather than letting it skip + # the real `handle_assignee_change` / `handle_status_change` calls below. + try: + if _payload_logging_enabled(rpc_integration.id): + issue = data.get("issue") or {} + fields = issue.get("fields") or {} + changelog_items = (data.get("changelog") or {}).get("items") or [] + payload_extra = { + "integration_id": rpc_integration.id, + "issue_key": issue.get("key"), + "issue_id": issue.get("id"), + "webhook_event": data.get("webhookEvent"), + "changed_fields": [item.get("field") for item in changelog_items], + "project": fields.get("project"), + "payload": data, + } + logger.info("jira.issue-updated.payload", extra=payload_extra) + project_change = next( + (item for item in changelog_items if item.get("field") == "project"), + None, + ) + if project_change is not None: + logger.info( + "jira.issue-updated.project-changed", + extra={**payload_extra, "project_change": project_change}, + ) + except Exception: + logger.exception( + "jira.issue-updated.payload-logging-failed", + extra={"integration_id": rpc_integration.id}, + ) + if not data.get("changelog"): logger.info("jira.missing-changelog", extra={"integration_id": rpc_integration.id}) return self.respond() diff --git a/tests/sentry/integrations/jira/test_webhooks.py b/tests/sentry/integrations/jira/test_webhooks.py index 05e1c9f5798b..e436a1afccc4 100644 --- a/tests/sentry/integrations/jira/test_webhooks.py +++ b/tests/sentry/integrations/jira/test_webhooks.py @@ -216,6 +216,34 @@ def test_missing_changelog(self) -> None: data = StubService.get_stub_data("jira", "changelog_missing.json") self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) + @patch("sentry.integrations.jira.webhooks.issue_updated.logger") + @patch("sentry.integrations.jira.utils.api.sync_group_assignee_inbound") + @patch("sentry.integrations.jira.webhooks.issue_updated._payload_logging_enabled") + def test_payload_logging_failure_does_not_skip_handlers( + self, + mock_payload_logging_enabled: MagicMock, + mock_sync_group_assignee_inbound: MagicMock, + mock_logger: MagicMock, + ) -> None: + # The diagnostic payload-logging path makes RPC calls that could fail + # transiently. Those failures must not prevent the real webhook + # handlers from running. + mock_payload_logging_enabled.side_effect = RuntimeError("transient RPC failure") + + with patch( + "sentry.integrations.jira.webhooks.issue_updated.get_integration_from_jwt", + return_value=self.integration, + ): + data = StubService.get_stub_data("jira", "edit_issue_assignee_payload.json") + self.get_success_response(**data, extra_headers=dict(HTTP_AUTHORIZATION=TOKEN)) + + mock_sync_group_assignee_inbound.assert_called_with( + self.integration, "jess@sentry.io", "APP-123", assign=True + ) + assert ( + mock_logger.exception.call_args.args[0] == "jira.issue-updated.payload-logging-failed" + ) + class MockErroringJiraEndpoint(JiraWebhookBase): permission_classes = ()